diff --git a/AGENTS.md b/AGENTS.md index 6a86407..3eebe9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Bun-based monorepo with workspace packages under `packages/*`. The marketing sit | Package | Role | Published | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------- | --------------- | | `@getdevintern/code` | CLI for task automation (`devintern`): Jira + multi-PM support, configurable AI agent | yes | -| `@getdevintern/pm` | CLI for PM task/story creation (`devpm`): supports Jira, Linear, Trello, Azure DevOps, Asana, GitHub Issues, Markdown | yes | +| `@getdevintern/pm` | CLI for PM task/story creation (`devpm`): supports Jira, Linear, Trello, Azure DevOps, Asana, GitHub Issues, GitLab, Markdown | yes | | `@devintern/pm-desktop` | Electron desktop app for `@getdevintern/pm`: multi-ticket AI task creation for your tracker | no, application | | `@devintern/agent-harness` | Shared agent harness abstraction | no, source-only | | `@devintern/dashboard-ui` | Local observability dashboard UI (Vite + React), bundled into `@getdevintern/code` at build time | no, source-only | diff --git a/CLAUDE.md b/CLAUDE.md index 33a46a2..32a0c95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ Bun-based monorepo with workspace packages under `packages/*`. The marketing sit | Package | Role | Published | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------- | --------------- | | `@getdevintern/code` | CLI for task automation (`devintern`): Jira + multi-PM support, configurable AI agent | yes | -| `@getdevintern/pm` | CLI for PM task/story creation (`devpm`): supports Jira, Linear, Trello, Azure DevOps, Asana, GitHub Issues, Markdown | yes | +| `@getdevintern/pm` | CLI for PM task/story creation (`devpm`): supports Jira, Linear, Trello, Azure DevOps, Asana, GitHub Issues, GitLab, Markdown | yes | | `@devintern/pm-desktop` | Electron desktop app for `@getdevintern/pm`: multi-ticket AI task creation for your tracker | no, private | | `@devintern/agent-harness` | Shared agent harness abstraction | no, source-only | | `@devintern/dashboard-ui` | Local observability dashboard UI (Vite + React), bundled into `@getdevintern/code` at build time | no, source-only | diff --git a/docs/code/configuration.md b/docs/code/configuration.md index af68432..0d1b57b 100644 --- a/docs/code/configuration.md +++ b/docs/code/configuration.md @@ -26,7 +26,7 @@ You can run `devintern` from any subdirectory of your project and it will find t ## Required Configuration -The active task tracker is set with `TASK_TRACKER` (defaults to `jira`). Supported values: `jira`, `linear`, `trello`, `asana`, `azure-devops`, `github`, `markdown`. +The active task tracker is set with `TASK_TRACKER` (defaults to `jira`). Supported values: `jira`, `linear`, `trello`, `asana`, `azure-devops`, `github`, `gitlab`, `markdown`. ### Jira (default) @@ -219,7 +219,7 @@ The active tracker is read from the `TASK_TRACKER` environment variable (default - `todoStatus`: Status to reset to if implementation fails (e.g., "To Do", "Backlog") - `storyPointsField`: Custom field ID for story points (e.g., `"customfield_10016"` for Jira); auto-discovered if omitted -**Supported trackers:** `jira`, `linear`, `trello`, `asana`, `azure-devops`, `github`, `markdown`. +**Supported trackers:** `jira`, `linear`, `trello`, `asana`, `azure-devops`, `github`, `gitlab`, `markdown`. **Backward compatibility:** Existing Jira-only files using the legacy top-level `projects` key continue to work without any changes. diff --git a/docs/code/gitlab-integration.md b/docs/code/gitlab-integration.md new file mode 100644 index 0000000..fbd2c34 --- /dev/null +++ b/docs/code/gitlab-integration.md @@ -0,0 +1,142 @@ +--- +title: "Implement GitLab Issues with @devintern/code" +sidebarLabel: "GitLab Integration" +description: "Fetch GitLab issues (cloud or self-hosted), track status labels, implement with your coding agent, and post results back." +section: "Code" +order: 6 +dateModified: 2026-08-24 +tags: ["gitlab", "gitlab-self-hosted", "devintern/code", "integration"] +--- + +# Implement GitLab Issues with @devintern/code + +@devintern/code can implement work directly from GitLab issues: fetch issue details and comments, run a feasibility check, move status labels, execute your AI agent, commit changes, and post results back on the issue. Both **GitLab Cloud** and **self-hosted instances** are supported. + +## Prerequisites + +- [Bun](https://bun.sh) and `@getdevintern/code` installed globally +- GitLab personal access token with the `api` scope +- Git repository for your project + +## Setup + +### 1. Set the task tracker + +In `.devintern-code/.env`: + +```bash +TASK_TRACKER=gitlab +``` + +### 2. Add GitLab credentials + +```bash +# Cloud default — omit for gitlab.com; set for self-hosted: +GITLAB_BASE_URL=https://gitlab.example.com + +GITLAB_TOKEN=glpat_xxxxxxxxxxxx +GITLAB_PROJECT=group/sub/repo +``` + +- `GITLAB_BASE_URL` — instance root URL. Omit for GitLab Cloud (`https://gitlab.com` is the default). Self-hosted instances keep their protocol, so internal `http://` hosts work. +- `GITLAB_TOKEN` — personal access token from `/-/user_settings/personal_access_tokens` on the same instance, with the **`api`** scope. +- `GITLAB_PROJECT` — project path (`group/repo`, subgroups allowed: `group/sub/repo`) or a numeric project ID. + +### 3. Configure status labels + +Like GitHub Issues, GitLab has no built-in workflow states that map cleanly across teams, so @devintern/code maps statuses to labels. Create the labels in your project, then configure them in `.devintern-code/settings.json` using the project path as the key: + +```json +{ + "gitlab": { + "projects": { + "acme/team/webapp": { + "inProgressStatus": "In Progress", + "todoStatus": "To Do", + "prStatus": "In Review" + } + } + } +} +``` + +To keep statuses mutually exclusive, also list them in `.devintern-code/.env`: + +```bash +GITLAB_STATUS_LABELS=To Do,In Progress,In Review +``` + +When a status changes, @devintern/code adds the target label and removes the other labels in this list. Transitioning to `closed` or `done` closes the issue instead of applying a label; moving back to an open status reopens it. + +## Running an issue + +Pass an issue number, `#number`, a `group/sub/repo#123` reference, or a full issue URL: + +```bash +# Issue number +devintern 123 --create-pr + +# Full issue URL (self-hosted URLs work too) +devintern https://gitlab.com/acme/team/webapp/-/issues/123 --create-pr +``` + +This workflow: + +1. Fetches the issue body, labels, and comments +2. Runs a feasibility assessment (skippable with `--skip-clarity-check`) +3. Applies the `inProgressStatus` label (unless `--skip-comments` is set) +4. Creates a feature branch, runs your agent, commits, and optionally opens a PR +5. Applies the `prStatus` label after PR creation +6. Posts implementation or assessment comments on the issue + +## Batch processing with --query + +Select multiple issues with familiar qualifiers — @devintern/code translates them to GitLab's [list issues](https://docs.gitlab.com/ee/api/issues.html#list-project-issues) filters. Queries are always scoped to `GITLAB_PROJECT`: + +```bash +devintern --query "is:open label:bug" --create-pr +devintern --query 'is:open "login flow"' --create-pr +devintern --query "assignee:@me" --create-pr +``` + +Supported qualifiers: `is:open` / `is:closed`, `label:name` (repeatable), `assignee:@me` / `assignee:username`, `updated:>=`. Anything else is free-text search. + +The first 100 matching issues are processed in sequence. + +## Story points estimation + +GitLab issues have no estimation field, so `--estimate` runs in comment-only mode: the analysis is posted (or updated) as an issue comment with the suggested points, reasoning, risks, and unclear areas. + +## Token scopes for Cloud vs. self-hosted + +| Scope | Needed for | +| ----- | ---------- | +| `api` | Full read/write access (recommended) | +| `read_api` | Read-only setups (fetching issues works; posting comments and label transitions will fail) | + +Self-hosted tokens only exist on their own instance — a gitlab.com token cannot authenticate against your on-premises GitLab and vice versa. + +## Limitations + +- **Attachments:** files embedded in issue bodies (`/uploads/...` links) are downloaded for the agent using your token; other external links stay as references. +- **Status labels:** labels named in `settings.json` must already exist in the project. The error message lists available labels when one is missing. +- **Comments:** use `--skip-comments` to skip issue comments and label transitions for a run. +- **Pull requests:** PR creation targets GitHub/Bitbucket remotes today; GitLab merge-request automation is not part of this integration yet. + +## Troubleshooting + +**"Missing required GitLab credentials"** + +Ensure `GITLAB_TOKEN` and `GITLAB_PROJECT` are set in `.devintern-code/.env`. + +**"GitLab API error (401)"** + +Token rejected: check that it was created on the same instance as `GITLAB_BASE_URL`, has not expired, and carries the `api` scope. + +**"Label \"In Progress\" not found in the project"** + +Create the label in your project (Issues → Labels) or change the status names in `settings.json` to match existing labels. + +**Old status labels pile up on issues** + +Set `GITLAB_STATUS_LABELS` to the full list of status label names so transitions remove the previous status. diff --git a/docs/pm/configuration.md b/docs/pm/configuration.md index a765e8e..017bc24 100644 --- a/docs/pm/configuration.md +++ b/docs/pm/configuration.md @@ -15,7 +15,7 @@ dateModified: 2026-08-08 Set `TASK_TRACKER` to choose your PM tool. Defaults to `jira` if not specified. -Supported backends: `jira`, `linear`, `trello`, `azure-devops`, `asana`, `github`, `markdown` +Supported backends: `jira`, `linear`, `trello`, `azure-devops`, `asana`, `github`, `gitlab`, `markdown` ```bash TASK_TRACKER=jira diff --git a/docs/pm/gitlab-integration.md b/docs/pm/gitlab-integration.md new file mode 100644 index 0000000..e49634c --- /dev/null +++ b/docs/pm/gitlab-integration.md @@ -0,0 +1,113 @@ +--- +title: "Create GitLab Issues with @devintern/pm" +sidebarLabel: "GitLab Integration" +description: "File well-specified GitLab issues from AI drafts on gitlab.com or a self-hosted instance." +section: "PM" +order: 7 +--- + +# Create GitLab Issues with @devintern/pm + +@devintern/pm creates GitLab issues directly from AI-generated stories and tasks. Setup takes a few minutes: you need a Personal Access Token and a target project. Both **GitLab Cloud (gitlab.com)** and **self-hosted instances** are supported. + +## How It Works + +@devintern/pm uses the [GitLab REST API v4](https://docs.gitlab.com/ee/api/issues.html) to create and update issues in a project you configure. + +- New issues appear under the project's **Issues** tab +- Stories, bugs, tasks, and epics map to issue labels (see below) +- Subtasks become linked issues with a task list on the parent issue +- Epic linking is not supported: project issues have no native parent hierarchy, so the epic linking step is skipped in interactive mode and the `--epic` flag is ignored + +## Cloud vs. Self-Hosted + +| Flavor | `GITLAB_BASE_URL` | Notes | +| --------------------- | ------------------------- | ----------------------------------------------- | +| GitLab Cloud | omit (default `https://gitlab.com`) | Works out of the box | +| Self-managed instance | e.g. `https://gitlab.example.com` | Protocol is kept; `http://` internal hosts work | + +## Setup + +### 1. Set the backend + +In your `.devintern-pm/.env`: + +```bash +TASK_TRACKER=gitlab + +# Cloud (default) — omit or leave commented: +# GITLAB_BASE_URL=https://gitlab.com + +# Self-hosted — set your instance root URL: +GITLAB_BASE_URL=https://gitlab.example.com +``` + +`GITLAB_BASE_URL` is the instance root, without `/api/v4`. + +### 2. Create a Personal Access Token + +1. Sign in to your GitLab instance +2. Go to **User Settings → Access Tokens** (`/-/user_settings/personal_access_tokens`) +3. Create a token with the **`api`** scope (read + write). A read-only setup needs `read_api`, but @devintern/pm creates and updates issues, so `api` is required. +4. Copy the token (tokens starting `glpat-…` cannot be viewed again after creation) + +Project access tokens and group access tokens also work if they include the `api` scope and at least **Reporter** role on the target project. + +### 3. Configure the target project + +```bash +GITLAB_TOKEN=glpat_xxxxxxxxxxxx +GITLAB_PROJECT=group/repo +``` + +`GITLAB_PROJECT` accepts: + +- A project path: `group/repo` or with subgroups `group/sub/repo` +- A numeric project ID (visible under the project name on the project overview) + +Run `devpm --interactive` to create your first issue. + +## Issue Types and Labels + +When you pick an issue type in @devintern/pm, it applies a GitLab label: + +| devpm issue type | GitLab label | +| ---------------- | ------------- | +| Story | `enhancement` | +| Bug | `bug` | +| Task | `task` | +| Epic | `epic` | + +New projects do not include all of these labels by default. Create them under **Issues → Labels** in your project, or issue creation may fail when applying a missing label. + +## What Gets Created + +| devpm concept | GitLab object | +| ------------------------- | ------------------------------------------------------------- | +| Story / Bug / Task / Epic | Issue with title, description, and mapped label | +| Subtask | New issue linked from a `## Subtasks` task list on the parent | +| Epic link | Not supported (step is skipped) | + +## Troubleshooting + +**"Missing required environment variables"** + +Set both `GITLAB_TOKEN` and `GITLAB_PROJECT` in `.devintern-pm/.env`. `GITLAB_BASE_URL` is optional for gitlab.com but required for self-hosted instances. + +**"GitLab API error (401)"** + +- Token is invalid or expired: generate a new one +- On self-hosted instances, confirm the token was created on the same instance as `GITLAB_BASE_URL` + +**"GitLab API error (403)"** + +- Token lacks the `api` scope +- Your account lacks permission to create issues in that project (need at least Reporter) + +**"Invalid GITLAB_PROJECT"** + +Use `group/repo` (subgroups allowed) or a numeric project ID — not the human-readable project name alone. + +**Self-signed certificates** + +The integration talks to your instance's normal HTTPS endpoint. Instances behind self-signed TLS need the certificate trusted at the OS level where @devintern/pm runs. diff --git a/docs/pm/quick-start.md b/docs/pm/quick-start.md index 5d81245..006ed76 100644 --- a/docs/pm/quick-start.md +++ b/docs/pm/quick-start.md @@ -47,7 +47,7 @@ devpm init In a terminal, this starts an interactive setup wizard that: - Detects an existing @devintern/code configuration (`.devintern-code/.env`) in the same project and offers to reuse those tracker credentials, so you skip straight to validation -- Asks which tracker you use (Jira, Linear, Trello, Azure DevOps, Asana, GitHub Issues, or markdown files) +- Asks which tracker you use (Jira, Linear, Trello, Azure DevOps, Asana, GitHub Issues, GitLab, or markdown files) - Links you directly to the provider's token creation page and prompts for each credential, with a pointer to the matching setup guide in these docs - Validates the connection with a real API call before finishing (you can retry, edit values, or skip) - Writes your answers to `.devintern-pm/.env` and updates your `.gitignore` to exclude `.devintern-pm/.env` (to prevent leaking secrets) diff --git a/docs/pm/usage.md b/docs/pm/usage.md index 091ff68..c33d324 100644 --- a/docs/pm/usage.md +++ b/docs/pm/usage.md @@ -74,7 +74,7 @@ devpm --prompt [options] ### Additional Options -- `--epic, -e `: Link the created story to an epic (e.g., PROJ-100). Ignored for trackers that do not support a real epic/parent hierarchy (Trello, GitHub Issues, Markdown). +- `--epic, -e `: Link the created story to an epic (e.g., PROJ-100). Ignored for trackers that do not support a real epic/parent hierarchy (Trello, GitHub Issues, GitLab, Markdown). - `--type, -t `: Issue type (default: "Task"). Common types: Task, Story, Bug, Epic. Only applied by backends that support issue types (Jira, Azure DevOps, GitHub, Markdown); ignored by Linear, Trello, and Asana. - `--custom, -c `: Additional custom instructions for the requirements - `--attach `: Attach a local file for agent context (and upload on create when the tracker supports it). Repeatable. Supported: images, text/docs, PDF (not Office binaries such as `.docx`). Max 10 files. diff --git a/packages/code/.env.example b/packages/code/.env.example index ac192b5..d1a16d2 100644 --- a/packages/code/.env.example +++ b/packages/code/.env.example @@ -2,7 +2,7 @@ # Copy this file to .env and update with your actual values # Task Tracker Selection -# Which task tracker to use: jira (default) | linear | github | azure-devops | asana | trello | markdown +# Which task tracker to use: jira (default) | linear | github | gitlab | azure-devops | asana | trello | markdown # Only the credentials for the selected tracker are required. # TASK_TRACKER=jira @@ -36,6 +36,17 @@ JIRA_API_TOKEN=your-api-token-here # When transitioning an issue, other labels in this list are removed. # GITHUB_STATUS_LABELS=To Do,In Progress,In Review +# GitLab Configuration (required when TASK_TRACKER=gitlab) +# Works with gitlab.com and self-hosted instances (stable REST v4 endpoints). +# GITLAB_BASE_URL=https://gitlab.example.com +# Personal access token with the `api` scope, created on the same instance: +# /-/user_settings/personal_access_tokens +# GITLAB_TOKEN=glpat_xxxxxxxxxxxx +# Target project path (subgroups allowed) or numeric project ID +# GITLAB_PROJECT=group/sub/repo +# Optional: comma-separated status label names treated as mutually exclusive. +# GITLAB_STATUS_LABELS=To Do,In Progress,In Review + # Azure DevOps Configuration (required when TASK_TRACKER=azure-devops; # also required for `devintern worker connect azure-devops`) # Organization from your dev.azure.com URL, a PAT with Work Items read/write, diff --git a/packages/code/CLAUDE.md b/packages/code/CLAUDE.md index bda1227..0dd1368 100644 --- a/packages/code/CLAUDE.md +++ b/packages/code/CLAUDE.md @@ -26,12 +26,14 @@ This file provides guidance to Claude Code when working with this repository. **Environment Variables (.devintern-code/.env):** -- `TASK_TRACKER` - Task tracker type: `jira` (default), `linear`, `github`, `azure-devops`, `asana`, `trello`, or `markdown` +- `TASK_TRACKER` - Task tracker type: `jira` (default), `linear`, `github`, `gitlab`, `azure-devops`, `asana`, `trello`, or `markdown` - `ASANA_API_TOKEN` - Asana personal access token (required when `TASK_TRACKER=asana`); optional `ASANA_DEFAULT_PROJECT_GID`, `ASANA_STORY_POINTS_FIELD` - `AZURE_DEVOPS_ORG`, `AZURE_DEVOPS_PAT`, `AZURE_DEVOPS_PROJECT` - Azure DevOps credentials (required when `TASK_TRACKER=azure-devops`) - `LINEAR_API_KEY` - Linear personal API key (required when `TASK_TRACKER=linear`) - `GITHUB_REPO` - Target `owner/repo` for GitHub Issues (required when `TASK_TRACKER=github`; requires `GITHUB_TOKEN`, App credentials cannot substitute) - `GITHUB_STATUS_LABELS` - Optional comma-separated mutually-exclusive status label names for GitHub transitions +- `GITLAB_TOKEN`, `GITLAB_PROJECT`, `GITLAB_BASE_URL` - GitLab credentials (required when `TASK_TRACKER=gitlab`; base URL optional, defaults to https://gitlab.com) +- `GITLAB_STATUS_LABELS` - Optional comma-separated mutually-exclusive status label names for GitLab transitions - `JIRA_BASE_URL`, `JIRA_EMAIL`, `JIRA_API_TOKEN` - JIRA credentials - `TRELLO_API_KEY`, `TRELLO_API_TOKEN` - Trello credentials (required when `TASK_TRACKER=trello`) - `TRELLO_DEFAULT_BOARD_ID` - Optional Trello board ID for settings lookup and status transitions diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index 0d05d4a..01db19b 100755 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -262,6 +262,9 @@ function resolveProjectKey(taskKey: string, task?: { raw: unknown }): string { if (trackerType === "github" && process.env.GITHUB_REPO) { return process.env.GITHUB_REPO; } + if (trackerType === "gitlab" && process.env.GITLAB_PROJECT) { + return process.env.GITLAB_PROJECT; + } if (trackerType === "azure-devops" && process.env.AZURE_DEVOPS_PROJECT) { return process.env.AZURE_DEVOPS_PROJECT; } @@ -1408,7 +1411,7 @@ program .option("--hook-retries ", "Number of retry attempts for git hook failures", "10") .option( "--estimate", - "Run in estimation mode to add story points estimates to tasks (Jira, Linear, Azure DevOps, Asana via custom field; GitHub posts comment-only estimates)", + "Run in estimation mode to add story points estimates to tasks (Jira, Linear, Azure DevOps, Asana via custom field; GitHub and GitLab post comment-only estimates)", ) .option( "--sandbox ", @@ -1436,6 +1439,11 @@ Examples (GitHub Issues; set TASK_TRACKER=github and GITHUB_REPO in .devintern-c devintern https://github.com/acme/webapp/issues/123 --create-pr devintern --query "is:open label:bug" --create-pr +Examples (GitLab; set TASK_TRACKER=gitlab and GITLAB_PROJECT in .devintern-code/.env): + devintern 123 --create-pr + devintern https://gitlab.com/group/sub/repo/-/issues/123 --create-pr + devintern --query "is:open label:bug" --create-pr + Examples (Azure DevOps; set TASK_TRACKER=azure-devops in .devintern-code/.env): devintern 4211 --create-pr devintern https://dev.azure.com/my-org/MyProject/_workitems/edit/4211 --create-pr diff --git a/packages/code/src/lib/change-detector.ts b/packages/code/src/lib/change-detector.ts index 974cce4..7eed5f2 100644 --- a/packages/code/src/lib/change-detector.ts +++ b/packages/code/src/lib/change-detector.ts @@ -141,6 +141,17 @@ export function createGitHubChangeDetector(searchTasks: SearchTasksFn): ChangeDe }); } +/** + * GitLab: `updated:>=` qualifier, translated by the client to the + * `updated_after` list filter (auto-scoped to the project). + */ +export function createGitLabChangeDetector(searchTasks: SearchTasksFn): ChangeDetector { + return createQueryChangeDetector("gitlab", searchTasks, (since) => { + const iso = new Date(since).toISOString().replace(/\.\d{3}Z$/, "Z"); + return `updated:>=${iso}`; + }); +} + /** * Azure DevOps: WIQL on `[System.ChangedDate]`. WIQL date literals are * day-precision by default, so the window is the cursor's calendar day (UTC); @@ -227,6 +238,8 @@ export function createChangeDetector( return searchTasks ? createLinearChangeDetector(searchTasks) : null; case "github": return searchTasks ? createGitHubChangeDetector(searchTasks) : null; + case "gitlab": + return searchTasks ? createGitLabChangeDetector(searchTasks) : null; case "azure-devops": return searchTasks ? createAzureDevOpsChangeDetector(searchTasks) : null; case "trello": { diff --git a/packages/code/src/lib/init-scaffold.ts b/packages/code/src/lib/init-scaffold.ts index 86d048c..89f0f51 100644 --- a/packages/code/src/lib/init-scaffold.ts +++ b/packages/code/src/lib/init-scaffold.ts @@ -18,6 +18,7 @@ export const TRACKER_DOCS: Record = { jira: "https://devintern.com/docs/code/jira-integration", linear: "https://devintern.com/docs/code/linear-integration", github: "https://devintern.com/docs/code/github-issues-integration", + gitlab: "https://devintern.com/docs/code/gitlab-integration", "azure-devops": "https://devintern.com/docs/code/azure-devops-integration", asana: "https://devintern.com/docs/code/asana-integration", trello: "https://devintern.com/docs/code/trello-integration", @@ -77,6 +78,31 @@ export const TRACKER_SETUP: Record = { optional: true, }, ], + gitlab: [ + { + key: "GITLAB_BASE_URL", + label: "GitLab instance URL (press Enter for https://gitlab.com)", + example: "https://gitlab.example.com", + optional: true, + defaultValue: "https://gitlab.com", + }, + { + key: "GITLAB_TOKEN", + label: "GitLab personal access token (scopes: api)", + link: (values) => { + const host = (values.GITLAB_BASE_URL || "https://gitlab.com").replace(/\/+$/, ""); + const origin = /^https?:\/\//i.test(host) ? host : `https://${host}`; + return `${origin}/-/user_settings/personal_access_tokens?name=DevIntern&scopes=api`; + }, + }, + { key: "GITLAB_PROJECT", label: "Target project path", example: "group/sub/repo" }, + { + key: "GITLAB_STATUS_LABELS", + label: "Comma-separated status label names", + example: "todo,in progress,in review", + optional: true, + }, + ], "azure-devops": [ { key: "AZURE_DEVOPS_ORG", label: "Azure DevOps organization name", example: "my-org" }, { @@ -414,6 +440,15 @@ export function scaffoldProject(options: ScaffoldOptions = {}): boolean { }, }, }, + gitlab: { + projects: { + "PROJECT-KEY": { + inProgressStatus: "In Progress", + todoStatus: "To Do", + prStatus: "In Review", + }, + }, + }, "azure-devops": { projects: { "PROJECT-KEY": { diff --git a/packages/code/src/lib/normalize-task-keys.ts b/packages/code/src/lib/normalize-task-keys.ts index 6292d88..8b03c90 100644 --- a/packages/code/src/lib/normalize-task-keys.ts +++ b/packages/code/src/lib/normalize-task-keys.ts @@ -1,14 +1,16 @@ /** * Normalize CLI task-key arguments for the active tracker. * - * Linear / GitHub / Azure DevOps / Asana accept both bare ids and full URLs; - * Trello always rewrites the argument (short link, URL, or 24-char id). + * Linear / GitHub / GitLab / Azure DevOps / Asana accept both bare ids and + * full URLs; Trello always rewrites the argument (short link, URL, or + * 24-char id). */ import { parseTrelloCardReference } from "@devintern/task-trackers"; import { parseAsanaTaskReference } from "./trackers/asana/asana-task-tracker-client"; import { parseAzureDevOpsWorkItemReference } from "./trackers/azure-devops/azure-devops-task-tracker-client"; import { parseGitHubIssueReference } from "./trackers/github/github-task-tracker-client"; +import { parseGitLabIssueReference } from "./trackers/gitlab/gitlab-task-tracker-client"; import { parseLinearIssueReference } from "./trackers/linear/linear-task-tracker-client"; /** @@ -29,6 +31,9 @@ export function normalizeTaskKeys(keys: string[], trackerType: string): string[] if (tracker === "github") { return keys.map((key) => parseGitHubIssueReference(key) ?? key); } + if (tracker === "gitlab") { + return keys.map((key) => parseGitLabIssueReference(key) ?? key); + } if (tracker === "azure-devops") { return keys.map((key) => parseAzureDevOpsWorkItemReference(key) ?? key); } diff --git a/packages/code/src/lib/task-tracker-manager.ts b/packages/code/src/lib/task-tracker-manager.ts index 1a70ee8..67c2c4b 100644 --- a/packages/code/src/lib/task-tracker-manager.ts +++ b/packages/code/src/lib/task-tracker-manager.ts @@ -12,6 +12,7 @@ import { JiraTaskTrackerClient } from "./trackers/jira/jira-task-tracker-client" import { AsanaTaskTrackerClient } from "./trackers/asana/asana-task-tracker-client"; import { AzureDevOpsTaskTrackerClient } from "./trackers/azure-devops/azure-devops-task-tracker-client"; import { GitHubTaskTrackerClient } from "./trackers/github/github-task-tracker-client"; +import { GitLabTaskTrackerClient } from "./trackers/gitlab/gitlab-task-tracker-client"; import { LinearTaskTrackerClient } from "./trackers/linear/linear-task-tracker-client"; import { MarkdownTaskTrackerClient } from "./trackers/markdown/markdown-task-tracker-client"; import { TrelloTaskTrackerClient } from "./trackers/trello/trello-task-tracker-client"; @@ -126,6 +127,28 @@ export class TaskTrackerManager { break; } + case "gitlab": { + const token = process.env.GITLAB_TOKEN; + const projectPath = process.env.GITLAB_PROJECT; + + if (!token || !projectPath) { + throw new Error( + "Missing required GitLab credentials. Set GITLAB_TOKEN and GITLAB_PROJECT (group/repo) environment variables.", + ); + } + + const gitlabStatusLabels = (process.env.GITLAB_STATUS_LABELS || "") + .split(",") + .map((label) => label.trim()) + .filter(Boolean); + + this.client = new GitLabTaskTrackerClient(token, projectPath, { + baseUrl: process.env.GITLAB_BASE_URL, + statusLabels: gitlabStatusLabels, + }); + break; + } + case "trello": { const apiKey = process.env.TRELLO_API_KEY; const apiToken = process.env.TRELLO_API_TOKEN; @@ -157,7 +180,7 @@ export class TaskTrackerManager { default: throw new Error( - `Unsupported task tracker: "${trackerType}". Supported values: jira, linear, github, azure-devops, asana, trello, markdown`, + `Unsupported task tracker: "${trackerType}". Supported values: jira, linear, github, gitlab, azure-devops, asana, trello, markdown`, ); } diff --git a/packages/code/src/lib/tracker-capabilities.ts b/packages/code/src/lib/tracker-capabilities.ts index bd6975d..036d3d1 100644 --- a/packages/code/src/lib/tracker-capabilities.ts +++ b/packages/code/src/lib/tracker-capabilities.ts @@ -47,6 +47,14 @@ export const TRACKER_CAPABILITIES: Record = { estimate: true, poll: true, }, + gitlab: { + displayName: "GitLab", + requiredEnv: ["GITLAB_TOKEN", "GITLAB_PROJECT"], + query: true, + queryExample: "is:open label:bug", + estimate: true, + poll: true, + }, "azure-devops": { displayName: "Azure DevOps", requiredEnv: ["AZURE_DEVOPS_ORG", "AZURE_DEVOPS_PAT", "AZURE_DEVOPS_PROJECT"], diff --git a/packages/code/src/lib/trackers/gitlab/gitlab-task-tracker-client.ts b/packages/code/src/lib/trackers/gitlab/gitlab-task-tracker-client.ts new file mode 100644 index 0000000..94345db --- /dev/null +++ b/packages/code/src/lib/trackers/gitlab/gitlab-task-tracker-client.ts @@ -0,0 +1,410 @@ +/** + * GitLab implementation of the platform-agnostic {@link TaskTrackerClient}. + * + * Delegates REST calls to {@link GitLabClient} from `@devintern/task-trackers`. + * Issue bodies and comments are markdown, so shared markdown comment + * formatters are posted as-is. + * + * Status transitions are label-based: transitioning to a status adds the + * matching project label and removes other configured status labels (see + * `statusLabels`). Transitioning to `closed`/`done` closes the issue instead. + * Estimation has no native field, so estimation runs in comment-only mode. + */ + +import { GitLabClient, sanitizeGitlabBaseUrl } from "@devintern/task-trackers"; +import type { GitLabIssue } from "@devintern/task-trackers"; +import type { + Comment, + DetailedRelatedIssue, + FormattedTaskDetails, + LinkedResource, + Task, + TaskTrackerCommentContent, +} from "../../../types/task-tracker"; +import { TaskTrackerError } from "../../../types/task-tracker"; +import type { TaskTrackerClient } from "../../task-tracker-client"; +import { + ESTIMATION_COMMENT_MARKER, + formatAssessmentFailureMarkdown, + formatClarityAssessmentMarkdown, + formatEstimationCommentMarkdown, + formatImplementationCommentMarkdown, + formatIncompleteImplementationCommentMarkdown, + isDevInternCommentText, + isIncompleteImplementationCommentText, +} from "../shared/markdown-comment-formatter"; +import type { + ClarityAssessmentLike, + EstimationResultLike, +} from "../shared/markdown-comment-formatter"; +import { mkdirSync, writeFileSync } from "fs"; +import path from "path"; + +/** Status names treated as "close the issue" rather than a label swap. */ +const CLOSE_STATUS_NAMES = new Set(["closed", "done", "complete", "completed"]); + +/** + * URL patterns for GitLab-hosted attachments embedded in issue markdown. + * Uploads live under `/(/-/)uploads//`; + * any absolute URL containing an `/uploads/` path segment is matched. + */ +const GITLAB_ATTACHMENT_URL_REGEX = /https?:\/\/[^\s)"'\]]+\/uploads\/[^\s)"'\]]+/g; + +/** + * Extract a GitLab issue iid from a raw CLI argument, accepting `123`, + * `#123`, `group/sub/repo#123`, and full issue URLs (with or without the + * `/-/` path segment). + * + * The `group/sub/repo#n` shape is distinct from GitHub's `owner/repo#n` + * because subgroups make multi-segment paths unambiguous — but both parsers + * accept bare numbers, so callers must not mix tracker ids. + * + * @returns Issue iid as a string, or `null` when the value has none of those shapes. + */ +export function parseGitLabIssueReference(value: string): string | null { + const urlMatch = value.match(/\/([^/]+(?:\/[^/]+)+)\/(?:-\/)?issues\/(\d+)/); + if (urlMatch) return urlMatch[2]; + + // A project path prefix must contain at least one slash so Jira-style keys + // like `PROJ-123` are never mistaken for issue numbers. + const refMatch = value.match(/^(?:[\w.-]+(?:\/[\w.-]+)+)?#?(\d+)$/); + if (refMatch && /[#/]/.test(value)) return refMatch[1]; + + const bareMatch = value.match(/^#?(\d+)$/); + return bareMatch ? bareMatch[1] : null; +} + +export class GitLabTaskTrackerClient implements TaskTrackerClient { + private gitlabClient: GitLabClient; + private token: string; + private baseUrl: string; + /** Mutually exclusive status label names swapped on transition. */ + private statusLabels: string[]; + + constructor( + token: string, + projectPath: string, + options?: { baseUrl?: string; statusLabels?: string[] }, + ) { + this.token = token; + this.baseUrl = sanitizeGitlabBaseUrl(options?.baseUrl); + this.gitlabClient = new GitLabClient({ token, projectPath, baseUrl: this.baseUrl }); + this.statusLabels = options?.statusLabels ?? []; + } + + // ------------------------------------------------------------------ + // Core task operations + // ------------------------------------------------------------------ + + async getTask(taskKey: string): Promise { + const issue = await this.gitlabClient.getIssue(this.toIssueIid(taskKey)); + return this.normalizeIssue(issue); + } + + async searchTasks(query: string): Promise<{ tasks: Task[]; total: number }> { + const result = await this.gitlabClient.searchIssues(query); + return { + tasks: result.issues.map((issue) => this.normalizeIssue(issue)), + total: result.total, + }; + } + + async getComments(taskKey: string): Promise { + const comments = await this.gitlabClient.listIssueComments(this.toIssueIid(taskKey)); + const filtered = comments.filter((c) => !isDevInternCommentText(c.body || "")); + + const filteredCount = comments.length - filtered.length; + if (filteredCount > 0) { + console.log(`🔍 Filtered out ${filteredCount} @devintern/code comment(s) from #${taskKey}`); + } + + return filtered.map((c) => ({ + id: String(c.id), + author: c.author?.username || "Unknown", + body: c.body || "", + created: c.created_at, + updated: c.updated_at, + })); + } + + async transitionStatus(taskKey: string, statusName: string): Promise { + const issueIid = this.toIssueIid(taskKey); + + if (CLOSE_STATUS_NAMES.has(statusName.toLowerCase())) { + await this.gitlabClient.updateIssue(issueIid, { state: "closed" }); + return; + } + + // Status transitions need an authoritative catalog — the picker soft-cap + // (default 500) can omit a configured status label that exists further on. + let { labels: projectLabels, truncated } = await this.gitlabClient.getLabels(); + let target = projectLabels.find((l) => l.name.toLowerCase() === statusName.toLowerCase()); + if (!target && truncated) { + ({ labels: projectLabels } = await this.gitlabClient.getLabels(Number.POSITIVE_INFINITY)); + target = projectLabels.find((l) => l.name.toLowerCase() === statusName.toLowerCase()); + } + + if (!target) { + const available = projectLabels.map((l) => l.name).join(", "); + throw new TaskTrackerError( + `Label "${statusName}" not found in the project. Available labels: ${available}. ` + + "Create the label or update the status names in .devintern-code/settings.json.", + ); + } + + // Swap out other configured status labels so only one status is active. + const issue = await this.gitlabClient.getIssue(issueIid); + const currentLabels = issue.labels ?? []; + const otherStatusLabels = currentLabels.filter( + (name) => + name.toLowerCase() !== target.name.toLowerCase() && + this.statusLabels.some((s) => s.toLowerCase() === name.toLowerCase()), + ); + + await this.gitlabClient.addLabels(issueIid, [target.name]); + for (const label of otherStatusLabels) { + await this.gitlabClient.removeLabel(issueIid, label); + } + + // Moving back to an open status reopens a closed issue. + if (issue.state === "closed") { + await this.gitlabClient.updateIssue(issueIid, { state: "opened" }); + } + } + + extractDescriptionText(task: Task): string { + return (task.raw as GitLabIssue).description || ""; + } + + // ------------------------------------------------------------------ + // Related work + // ------------------------------------------------------------------ + + extractLinkedResources(task: Task): LinkedResource[] { + const issue = task.raw as GitLabIssue; + const resources: LinkedResource[] = []; + + const urlRegex = /(https?:\/\/[^\s)]+)/g; + const body = issue.description || ""; + let match: RegExpExecArray | null; + while ((match = urlRegex.exec(body)) !== null) { + resources.push({ + type: "description_link", + url: match[1], + description: match[1], + }); + } + + return resources; + } + + async getRelatedWorkItems(_task: Task): Promise { + return []; + } + + formatTaskDetails( + task: Task, + comments: Comment[], + linkedResources: LinkedResource[], + relatedIssues: DetailedRelatedIssue[], + ): FormattedTaskDetails { + return { + key: task.key, + summary: task.summary, + description: task.description, + renderedDescription: task.renderedDescription, + issueType: task.issueType, + status: task.status, + priority: task.priority, + assignee: task.assignee, + reporter: task.reporter, + created: task.created, + updated: task.updated, + labels: task.labels, + components: task.components, + fixVersions: task.fixVersions, + linkedResources, + relatedIssues, + comments, + attachments: [], + }; + } + + // ------------------------------------------------------------------ + // Attachments (scan upload links embedded in the issue body) + // ------------------------------------------------------------------ + + async downloadAttachments(taskKey: string, outputDir: string): Promise> { + const issue = await this.gitlabClient.getIssue(this.toIssueIid(taskKey)); + return this.downloadAttachmentsFromContent(issue.description || "", outputDir); + } + + async downloadAttachmentsFromContent( + htmlContent: string, + outputDir: string, + existingMap?: Map, + ): Promise> { + const result = existingMap ?? new Map(); + const urls = htmlContent.match(GITLAB_ATTACHMENT_URL_REGEX) || []; + // Issue bodies are attacker-writable: only same-instance upload links may + // be fetched (with the PAT); external hosts are skipped entirely. + const instanceOrigin = new URL(this.baseUrl).origin; + + for (const url of urls) { + try { + const parsed = new URL(url); + if (parsed.origin !== instanceOrigin) continue; + let filename = path.basename(parsed.pathname); + try { + filename = decodeURIComponent(filename); + } catch { + // Malformed percent-encoding: skip this link, keep the rest. + continue; + } + if (!filename || filename === "/" || result.has(filename)) continue; + const response = await fetch(url, { + headers: { "PRIVATE-TOKEN": this.token }, + }); + if (!response.ok) continue; + const buffer = await response.arrayBuffer(); + mkdirSync(outputDir, { recursive: true }); + const filePath = path.join(outputDir, filename); + writeFileSync(filePath, Buffer.from(buffer)); + result.set(filename, filePath); + } catch { + // Skip attachments that fail to download + } + } + + return result; + } + + // ------------------------------------------------------------------ + // Comments + // ------------------------------------------------------------------ + + async postComment(taskKey: string, content: TaskTrackerCommentContent): Promise { + await this.gitlabClient.createIssueComment(this.toIssueIid(taskKey), content.body); + } + + async postImplementationComment( + taskKey: string, + agentOutput: string, + taskSummary?: string, + ): Promise { + const body = formatImplementationCommentMarkdown(agentOutput, taskSummary); + await this.gitlabClient.createIssueComment(this.toIssueIid(taskKey), body); + console.log(`✅ Successfully posted implementation comment to #${taskKey}`); + } + + async postClarityComment(taskKey: string, assessment: unknown): Promise { + const body = formatClarityAssessmentMarkdown(assessment as ClarityAssessmentLike); + await this.gitlabClient.createIssueComment(this.toIssueIid(taskKey), body); + console.log(`✅ Successfully posted clarity assessment to #${taskKey}`); + } + + async postIncompleteImplementationComment( + taskKey: string, + agentOutput: string, + taskSummary?: string, + ): Promise { + const body = formatIncompleteImplementationCommentMarkdown(agentOutput, taskSummary); + await this.gitlabClient.createIssueComment(this.toIssueIid(taskKey), body); + console.log(`✅ Successfully posted incomplete implementation comment to #${taskKey}`); + } + + async hasIncompleteImplementationMarker(taskKey: string): Promise { + try { + const comments = await this.gitlabClient.listIssueComments(this.toIssueIid(taskKey)); + return comments.some((c) => isIncompleteImplementationCommentText(c.body || "")); + } catch (error) { + console.warn(`Failed to check for duplicate comments: ${error}`); + return false; + } + } + + async postAssessmentFailure( + taskKey: string, + failureType: "max-turns" | "parse-error", + _rawOutput: string, + ): Promise { + await this.gitlabClient.createIssueComment( + this.toIssueIid(taskKey), + formatAssessmentFailureMarkdown(failureType), + ); + } + + // ------------------------------------------------------------------ + // Estimation (comment-only; GitLab issues have no estimation field) + // ------------------------------------------------------------------ + + async findEstimationComment( + taskKey: string, + ): Promise<{ commentId: string; created: string } | null> { + try { + const comments = await this.gitlabClient.listIssueComments(this.toIssueIid(taskKey)); + const existing = comments.find((c) => (c.body || "").includes(ESTIMATION_COMMENT_MARKER)); + return existing ? { commentId: String(existing.id), created: existing.created_at } : null; + } catch (error) { + console.warn(`⚠️ Failed to check for estimation comment on #${taskKey}: ${error}`); + return null; + } + } + + async discoverEstimationField(_taskKey?: string): Promise { + return null; + } + + async updateEstimation(_taskKey: string, _fieldId: string, _value: number): Promise { + throw new TaskTrackerError( + "GitLab issues have no estimation field. Estimates are posted as comments only.", + ); + } + + async postEstimationComment(taskKey: string, result: unknown): Promise { + const body = formatEstimationCommentMarkdown(result as EstimationResultLike); + await this.gitlabClient.createIssueComment(this.toIssueIid(taskKey), body); + } + + async updateEstimationComment( + taskKey: string, + commentId: string, + result: unknown, + ): Promise { + const body = formatEstimationCommentMarkdown(result as EstimationResultLike); + await this.gitlabClient.updateIssueComment(this.toIssueIid(taskKey), Number(commentId), body); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private toIssueIid(taskKey: string): number { + const parsed = parseGitLabIssueReference(taskKey); + const issueIid = Number(parsed ?? taskKey); + if (!Number.isInteger(issueIid) || issueIid <= 0) { + throw new TaskTrackerError( + `Invalid GitLab issue reference: "${taskKey}". Use an issue number (123, #123) or issue URL.`, + ); + } + return issueIid; + } + + private normalizeIssue(issue: GitLabIssue): Task { + return { + key: String(issue.iid), + summary: issue.title, + description: issue.description || undefined, + issueType: "Issue", + status: issue.state || "", + assignee: issue.assignees?.[0]?.username, + reporter: issue.author?.username || "Unknown", + created: issue.created_at || "", + updated: issue.updated_at || "", + labels: (issue.labels ?? []).filter(Boolean), + components: [], + fixVersions: [], + raw: issue, + }; + } +} diff --git a/packages/code/src/types/settings.ts b/packages/code/src/types/settings.ts index f01d1bf..0e655d2 100644 --- a/packages/code/src/types/settings.ts +++ b/packages/code/src/types/settings.ts @@ -57,6 +57,9 @@ export type AsanaProjectConfig = BaseProjectConfig; /** GitHub Issues-specific project configuration (currently uses the common base). */ export type GitHubProjectConfig = BaseProjectConfig; +/** GitLab-specific project configuration (currently uses the common base). */ +export type GitLabProjectConfig = BaseProjectConfig; + /** Markdown-specific project configuration (currently uses the common base). */ export type MarkdownProjectConfig = BaseProjectConfig; @@ -102,6 +105,8 @@ export interface ProjectSettings { asana?: TrackerSection; /** GitHub Issues-specific project configurations */ github?: TrackerSection; + /** GitLab-specific project configurations */ + gitlab?: TrackerSection; /** Markdown-specific project configurations */ markdown?: TrackerSection; } diff --git a/packages/code/tests/gitlab-task-tracker-client.test.ts b/packages/code/tests/gitlab-task-tracker-client.test.ts new file mode 100644 index 0000000..c7850e2 --- /dev/null +++ b/packages/code/tests/gitlab-task-tracker-client.test.ts @@ -0,0 +1,357 @@ +import { describe, expect, test, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { + GitLabTaskTrackerClient, + parseGitLabIssueReference, +} from "../src/lib/trackers/gitlab/gitlab-task-tracker-client"; +import type { GitLabClient, GitLabIssue } from "@devintern/task-trackers"; + +function makeIssue(overrides: Partial = {}): GitLabIssue { + return { + id: 9001, + iid: 123, + project_id: 42, + title: "Fix login bug", + description: "Steps in https://example.com/spec", + state: "opened", + labels: ["bug"], + author: { username: "grace" }, + assignees: [{ username: "ada" }], + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + web_url: "https://gitlab.com/acme/team/webapp/-/issues/123", + ...overrides, + }; +} + +/** Inject a stubbed GitLabClient into the adapter (bypasses HTTP). */ +function makeAdapter( + stub: Partial, + options?: { statusLabels?: string[] }, +): GitLabTaskTrackerClient { + const adapter = new GitLabTaskTrackerClient("tok", "acme/team/webapp", options); + (adapter as unknown as { gitlabClient: Partial }).gitlabClient = stub; + return adapter; +} + +describe("parseGitLabIssueReference", () => { + test("accepts bare numbers and #-prefixed numbers", () => { + expect(parseGitLabIssueReference("123")).toBe("123"); + expect(parseGitLabIssueReference("#123")).toBe("123"); + }); + + test("accepts group/sub/repo#123 references with subgroups", () => { + expect(parseGitLabIssueReference("acme/team/webapp#123")).toBe("123"); + expect(parseGitLabIssueReference("acme/webapp#7")).toBe("7"); + }); + + test("extracts the iid from issue URLs with and without /-/", () => { + expect(parseGitLabIssueReference("https://gitlab.com/acme/team/webapp/-/issues/123")).toBe( + "123", + ); + expect(parseGitLabIssueReference("https://gitlab.internal:8443/acme/webapp/issues/9")).toBe( + "9", + ); + }); + + test("returns null for non-issue values", () => { + expect(parseGitLabIssueReference("PROJ-123")).toBeNull(); + expect(parseGitLabIssueReference("./task.md")).toBeNull(); + expect( + parseGitLabIssueReference("https://gitlab.com/acme/webapp/-/merge_requests/9"), + ).toBeNull(); + expect(parseGitLabIssueReference("https://github.com/acme/webapp/pull/9")).toBeNull(); + }); +}); + +describe("GitLabTaskTrackerClient.getTask", () => { + test("normalizes issue into Task", async () => { + const adapter = makeAdapter({ getIssue: async () => makeIssue() }); + + const task = await adapter.getTask("123"); + + expect(task.key).toBe("123"); + expect(task.summary).toBe("Fix login bug"); + expect(task.status).toBe("opened"); + expect(task.assignee).toBe("ada"); + expect(task.reporter).toBe("grace"); + expect(task.labels).toEqual(["bug"]); + }); + + test("rejects invalid issue references", async () => { + const adapter = makeAdapter({}); + await expect(adapter.getTask("not-a-number")).rejects.toThrow("Invalid GitLab issue reference"); + }); +}); + +describe("GitLabTaskTrackerClient.transitionStatus", () => { + test("closes the issue for closed/done statuses", async () => { + const updates: unknown[] = []; + const adapter = makeAdapter({ + updateIssue: async (_n: number, patch: unknown) => { + updates.push(patch); + return makeIssue(); + }, + }); + + await adapter.transitionStatus("123", "Done"); + + expect(updates).toEqual([{ state: "closed" }]); + }); + + test("adds target label and removes other status labels", async () => { + const added: string[][] = []; + const removed: string[] = []; + const adapter = makeAdapter( + { + getLabels: async () => ({ + labels: [ + { id: 1, name: "To Do", description: null }, + { id: 2, name: "In Progress", description: null }, + { id: 3, name: "bug", description: null }, + ], + truncated: false, + }), + getIssue: async () => makeIssue({ labels: ["To Do", "bug"] }), + addLabels: async (_n: number, labels: string[]) => { + added.push(labels); + }, + removeLabel: async (_n: number, label: string) => { + removed.push(label); + }, + }, + { statusLabels: ["To Do", "In Progress", "In Review"] }, + ); + + await adapter.transitionStatus("123", "in progress"); + + expect(added).toEqual([["In Progress"]]); + expect(removed).toEqual(["To Do"]); + }); + + test("lists project labels when target label is missing", async () => { + const adapter = makeAdapter({ + getLabels: async () => ({ + labels: [ + { id: 1, name: "bug", description: null }, + { id: 2, name: "enhancement", description: null }, + ], + truncated: false, + }), + }); + + await expect(adapter.transitionStatus("123", "In Progress")).rejects.toThrow( + "Available labels: bug, enhancement", + ); + }); + + test("exhausts truncated catalog when status label is beyond the soft cap", async () => { + const caps: Array = []; + const added: string[][] = []; + const adapter = makeAdapter( + { + getLabels: async (maxLabels?: number) => { + caps.push(maxLabels); + if (maxLabels === Number.POSITIVE_INFINITY) { + return { + labels: [ + { id: 1, name: "bug", description: null }, + { id: 2, name: "In Progress", description: null }, + ], + truncated: false, + }; + } + return { + labels: [{ id: 1, name: "bug", description: null }], + truncated: true, + }; + }, + getIssue: async () => makeIssue({ labels: ["bug"] }), + addLabels: async (_n: number, labels: string[]) => { + added.push(labels); + }, + removeLabel: async () => {}, + }, + { statusLabels: ["To Do", "In Progress"] }, + ); + + await adapter.transitionStatus("123", "In Progress"); + + expect(caps).toEqual([undefined, Number.POSITIVE_INFINITY]); + expect(added).toEqual([["In Progress"]]); + }); + + test("reopens a closed issue when moving to an open status", async () => { + const updates: unknown[] = []; + const adapter = makeAdapter({ + getLabels: async () => ({ + labels: [{ id: 1, name: "To Do", description: null }], + truncated: false, + }), + getIssue: async () => makeIssue({ state: "closed", labels: [] }), + addLabels: async () => {}, + updateIssue: async (_n: number, patch: unknown) => { + updates.push(patch); + return makeIssue(); + }, + }); + + await adapter.transitionStatus("123", "To Do"); + + expect(updates).toEqual([{ state: "opened" }]); + }); +}); + +describe("GitLabTaskTrackerClient.getComments", () => { + test("filters devintern automation comments and maps usernames", async () => { + const adapter = makeAdapter({ + listIssueComments: async () => [ + { + id: 1, + body: "Human question", + author: { username: "ada" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + { + id: 2, + body: "Implementation Completed by @devintern/code\n\nDetails", + author: { username: "bot" }, + created_at: "2026-01-02T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + }, + ], + }); + + const comments = await adapter.getComments("123"); + + expect(comments.length).toBe(1); + expect(comments[0].author).toBe("ada"); + }); +}); + +describe("GitLabTaskTrackerClient estimation", () => { + test("has no estimation field", async () => { + const adapter = makeAdapter({}); + expect(await adapter.discoverEstimationField()).toBeNull(); + await expect(adapter.updateEstimation("123", "any", 5)).rejects.toThrow("no estimation field"); + }); + + test("findEstimationComment locates prior estimation comment", async () => { + const adapter = makeAdapter({ + listIssueComments: async () => [ + { + id: 42, + body: "### 🤖 Automated Story Points Estimation\n\n**Story Points:** 3", + author: { username: "bot" }, + created_at: "2026-01-03T00:00:00Z", + updated_at: "2026-01-03T00:00:00Z", + }, + ], + }); + + const found = await adapter.findEstimationComment("123"); + expect(found).toEqual({ commentId: "42", created: "2026-01-03T00:00:00Z" }); + }); + + test("updateEstimationComment patches the note scoped to its issue", async () => { + const calls: Array<{ url: string; method: string; body: string }> = []; + const adapter = makeAdapter({ + updateIssueComment: (async (issueIid: number, commentId: number, body: string) => { + calls.push({ url: `issues/${issueIid}/notes/${commentId}`, method: "PUT", body }); + }) as unknown as GitLabClient["updateIssueComment"], + }); + + await adapter.updateEstimationComment("123", "42", { + storyPoints: 3, + confidence: "high", + reasoning: "small change", + risks: [], + unclearAreas: [], + }); + + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe("issues/123/notes/42"); + expect(calls[0].method).toBe("PUT"); + expect(calls[0].body).toContain("**Story Points:** 3"); + }); +}); + +describe("GitLabTaskTrackerClient security and base URL handling", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function captureFetch( + handler: (url: string, headers: Headers) => Response, + ): Array<{ url: string; token: string | null }> { + const calls: Array<{ url: string; token: string | null }> = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const headers = new Headers(init?.headers); + calls.push({ url: String(url), token: headers.get("PRIVATE-TOKEN") }); + return handler(String(url), headers); + }) as typeof fetch; + return calls; + } + + test("sanitizes base URLs that are missing a protocol", () => { + const adapter = new GitLabTaskTrackerClient("tok", "acme/team/webapp", { + baseUrl: "gitlab.example.com/", + }); + // @ts-expect-error accessing private field for assertion + expect(adapter.baseUrl).toBe("https://gitlab.example.com"); + }); + + test("never sends PRIVATE-TOKEN to off-instance upload links", async () => { + const outputDir = mkdtempSync(path.join(tmpdir(), "gitlab-attachments-")); + try { + const calls = captureFetch( + (_url, _headers) => new Response(Buffer.from("attachment-bytes"), { status: 200 }), + ); + const adapter = new GitLabTaskTrackerClient("secret-pat", "acme/team/webapp"); + + const result = await adapter.downloadAttachmentsFromContent( + [ + "Internal: https://gitlab.com/acme/team/webapp/uploads/hash/report.png", + "Evil: https://evil.example/uploads/x.png", + ].join("\n"), + outputDir, + ); + + expect(calls.map((c) => c.url)).toEqual([ + "https://gitlab.com/acme/team/webapp/uploads/hash/report.png", + ]); + expect(calls[0].token).toBe("secret-pat"); + expect([...result.keys()]).toEqual(["report.png"]); + } finally { + rmSync(outputDir, { recursive: true, force: true }); + } + }); + + test("skips malformed upload links without aborting remaining downloads", async () => { + const outputDir = mkdtempSync(path.join(tmpdir(), "gitlab-attachments-")); + try { + const calls = captureFetch( + () => new Response(Buffer.from("attachment-bytes"), { status: 200 }), + ); + const adapter = new GitLabTaskTrackerClient("tok", "acme/team/webapp"); + + const result = await adapter.downloadAttachmentsFromContent( + [ + "Bad escape: https://gitlab.com/acme/team/webapp/uploads/hash/%zzbroken.png", + "Good: https://gitlab.com/acme/team/webapp/uploads/hash/spec%20doc.png", + ].join("\n"), + outputDir, + ); + + expect(calls).toHaveLength(1); + expect([...result.keys()]).toEqual(["spec doc.png"]); + } finally { + rmSync(outputDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/code/tests/init-wizard.test.ts b/packages/code/tests/init-wizard.test.ts index 6d52091..55b218f 100644 --- a/packages/code/tests/init-wizard.test.ts +++ b/packages/code/tests/init-wizard.test.ts @@ -68,6 +68,10 @@ describe("buildEnvExample", () => { expect(template).toContain("https://linear.app/settings/account/security"); expect(template).toContain("https://app.asana.com/0/my-apps"); expect(template).toContain("# MARKDOWN_TASKS_DIR="); + // GitLab section: instance URL defaults to cloud, project path allows subgroups + expect(template).toContain("# GITLAB_TOKEN="); + expect(template).toContain("# GITLAB_PROJECT=group/sub/repo"); + expect(template).toContain("# GITLAB_BASE_URL=https://gitlab.example.com"); // Agent + PR integration tail is preserved expect(template).toContain("AGENT_HARNESS=claude-code"); expect(template).toContain("BITBUCKET_TOKEN"); @@ -88,6 +92,18 @@ describe("renderEnvFile", () => { expect(env).toContain("AGENT_HARNESS=claude-code"); }); + test("renders GitLab env with base URL default written out", () => { + const env = renderEnvFile("gitlab", { + GITLAB_TOKEN: "glpat-secret", + GITLAB_PROJECT: "acme/team/webapp", + }); + expect(env).toContain("TASK_TRACKER=gitlab"); + expect(env).toContain("GITLAB_TOKEN=glpat-secret"); + expect(env).toContain("GITLAB_PROJECT=acme/team/webapp"); + // Skipped optional is commented so users can find it later + expect(env).toContain("# GITLAB_BASE_URL=https://gitlab.example.com"); + }); + test("writes extra values (PR token) under a dedicated section", () => { const env = renderEnvFile("linear", { LINEAR_API_KEY: "lin_api_123", diff --git a/packages/pm-desktop/src/shared/project-init.test.ts b/packages/pm-desktop/src/shared/project-init.test.ts index 8011c80..db4858d 100644 --- a/packages/pm-desktop/src/shared/project-init.test.ts +++ b/packages/pm-desktop/src/shared/project-init.test.ts @@ -30,6 +30,9 @@ describe("setup field helpers (shared with wizard UI)", () => { }); test("every tracker in SETUP is reachable from the desktop menu order", () => { - expect(Object.keys(PM_TRACKER_SETUP).length).toBeGreaterThanOrEqual(7); + expect(Object.keys(PM_TRACKER_SETUP).length).toBeGreaterThanOrEqual(8); + // GitLab must appear alongside the other remote trackers so the setup + // wizard and tracker switcher pick it up without desktop-side changes. + expect(Object.keys(PM_TRACKER_SETUP)).toContain("gitlab"); }); }); diff --git a/packages/pm/.env.example b/packages/pm/.env.example index 1209af9..1112190 100644 --- a/packages/pm/.env.example +++ b/packages/pm/.env.example @@ -4,7 +4,7 @@ # Per-tracker setup guides: https://devintern.com/docs/pm/quick-start # Backend Configuration -# Which task backend to use: jira | linear | trello | azure-devops | asana | github | markdown +# Which task backend to use: jira | linear | trello | azure-devops | asana | github | gitlab | markdown TASK_TRACKER=jira # Markdown backend directory (relative to project root, only used when TASK_TRACKER=markdown) @@ -119,6 +119,24 @@ GITHUB_TOKEN=ghp_xxxxxxxxxxxx # Target repository as owner/repo (e.g. acme/my-app) GITHUB_REPO=your-username/your-repo +# GitLab Configuration (TASK_TRACKER=gitlab) +# Creates issues via the GitLab REST API v4 — gitlab.com or a self-hosted instance. +# +# Instance URL (optional for GitLab Cloud; defaults to https://gitlab.com). +# For self-managed instances set the instance root, protocol included: +# https://gitlab.example.com +# GITLAB_BASE_URL=https://gitlab.example.com +# +# Personal Access Token (required): +# Create at: /-/user_settings/personal_access_tokens +# Scope: api (read/write). Project/group tokens with `api` also work. +GITLAB_TOKEN=glpat_xxxxxxxxxxxx + +# Target project path (required) — subgroups allowed: +# https://gitlab.com/acme/team/my-app → GITLAB_PROJECT=acme/team/my-app +# A numeric project ID also works. +GITLAB_PROJECT=group/sub/repo + # Chat Bot Configuration (alpha, devpm serve) # Experimental: this feature may not work properly and can change without notice. # Set up via `devpm connect telegram` (guided) or paste tokens manually. diff --git a/packages/pm/backends.test.ts b/packages/pm/backends.test.ts index 536e2d3..622021e 100644 --- a/packages/pm/backends.test.ts +++ b/packages/pm/backends.test.ts @@ -8,6 +8,7 @@ import { TrelloBackend } from "./lib/backends/trello"; import { AzureDevOpsBackend } from "./lib/backends/azure-devops"; import { AsanaBackend } from "./lib/backends/asana"; import { GitHubBackend } from "./lib/backends/github"; +import { GitLabBackend } from "./lib/backends/gitlab"; import { JiraBackend } from "./lib/backends/jira"; const TEST_DIR = join(getModuleDir(import.meta.url), "tmp-test-tasks"); @@ -1465,6 +1466,118 @@ describe("GitHubBackend", () => { }); }); +describe("GitLabBackend", () => { + let backend: GitLabBackend; + let originalFetch: typeof fetch; + + beforeEach(() => { + backend = new GitLabBackend({ + token: "glpat-test", + projectPath: "test-org/team/test-repo", + baseUrl: "https://gitlab.example.com", + }); + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function mockFetch(response: unknown) { + (globalThis as any).fetch = async () => + new Response(JSON.stringify(response), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + test("should have correct name", () => { + expect(backend.name).toBe("GitLab"); + }); + + test("should support issue types", () => { + expect(backend.supportsIssueTypes).toBe(true); + }); + + test("should not support epic linking", () => { + expect(backend.supportsEpicLinking).toBe(false); + }); + + test("should support labels", () => { + expect(backend.supportsLabels).toBe(true); + }); + + test("should not support attachments", () => { + expect(backend.supportsAttachments).toBe(false); + }); + + describe("createTask", () => { + test("should create an issue via the GitLab API on the configured instance", async () => { + const calls: Array<{ url: string; method?: string; body?: string }> = []; + (globalThis as any).fetch = async (url: string, init?: RequestInit) => { + calls.push({ url, method: init?.method, body: init?.body as string | undefined }); + return new Response( + JSON.stringify({ + iid: 42, + web_url: "https://gitlab.example.com/test-org/team/test-repo/-/issues/42", + title: "Add auth", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }; + + const result = await backend.createTask("Add auth", "Implement OAuth login", "Story"); + + expect(result.key).toBe("42"); + expect(result.url).toBe("https://gitlab.example.com/test-org/team/test-repo/-/issues/42"); + expect(calls[0]?.url).toContain( + "https://gitlab.example.com/api/v4/projects/test-org%2Fteam%2Ftest-repo/issues", + ); + const body = JSON.parse(calls[0]!.body!); + expect(body.labels).toBe("enhancement"); + }); + }); + + describe("getProjects", () => { + test("should return projects keyed by path_with_namespace", async () => { + mockFetch([ + { id: 1, name: "test-repo", path_with_namespace: "test-org/team/test-repo" }, + { id: 2, name: "other-repo", path_with_namespace: "test-org/other-repo" }, + ]); + + const projects = await backend.getProjects(); + expect(projects).toEqual([ + { key: "test-org/team/test-repo", name: "test-repo" }, + { key: "test-org/other-repo", name: "other-repo" }, + ]); + }); + }); + + describe("applyLabels", () => { + test("should use add_labels so existing labels are kept", async () => { + const calls: Array<{ url: string; method?: string; body?: string }> = []; + (globalThis as any).fetch = async (url: string, init?: RequestInit) => { + calls.push({ url, method: init?.method, body: init?.body as string | undefined }); + return new Response("{}", { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + await backend.applyLabels("42", ["bug"]); + + expect(calls[0]?.method).toBe("PUT"); + expect(JSON.parse(calls[0]!.body!)).toEqual({ add_labels: "bug" }); + }); + + test("should throw when taskKey is not a valid issue iid", async () => { + expect(backend.applyLabels("not-a-number", ["bug"])).rejects.toThrow( + "Invalid issue number: not-a-number", + ); + }); + }); +}); + describe("JiraBackend", () => { let backend: JiraBackend; let originalFetch: typeof fetch; diff --git a/packages/pm/init-wizard.test.ts b/packages/pm/init-wizard.test.ts index 4f72d6c..87866db 100644 --- a/packages/pm/init-wizard.test.ts +++ b/packages/pm/init-wizard.test.ts @@ -59,7 +59,7 @@ describe("isInteractive", () => { describe("PM_TRACKER_SETUP", () => { test("covers every tracker in the backends registry", () => { expect(Object.keys(PM_TRACKER_SETUP).sort()).toEqual( - ["asana", "azure-devops", "github", "jira", "linear", "markdown", "trello"].sort(), + ["asana", "azure-devops", "github", "gitlab", "jira", "linear", "markdown", "trello"].sort(), ); }); diff --git a/packages/pm/lib/backends/gitlab.ts b/packages/pm/lib/backends/gitlab.ts new file mode 100644 index 0000000..9abed01 --- /dev/null +++ b/packages/pm/lib/backends/gitlab.ts @@ -0,0 +1,179 @@ +import { GitLabClient } from "@devintern/task-trackers"; +import { DEFAULT_ISSUE_TYPES } from "../issue-types.js"; +import type { CreatedTask, LabelListResult, ProjectInfo, TaskBackend } from "./types"; + +/** + * GitLab backend adapter (gitlab.com and self-hosted instances). + * + * @see {@link GitLabClient} for REST API implementation details. + */ +export class GitLabBackend implements TaskBackend { + readonly name = "GitLab"; + readonly supportsIssueTypes = true; + // Project issues have no native epic hierarchy; linkToEpic only adds a + // "Part of #N" text reference, so epic linking is treated as unsupported. + readonly supportsEpicLinking = false; + readonly supportsLabels = true; + readonly supportsFreeformLabels = false; + /** Issue notes carry no first-class local-file upload API in this integration. */ + readonly supportsAttachments = false; + private client: GitLabClient; + + /** + * Create a GitLab backend for one project on an instance. + * + * @param config - PAT, project path/id, and instance base URL. + */ + constructor(config: { token: string; projectPath: string; baseUrl: string }) { + this.client = new GitLabClient({ + token: config.token, + projectPath: config.projectPath, + baseUrl: config.baseUrl, + }); + } + + /** + * Create a GitLab issue with optional type-to-label mapping. + * + * @param summary - Issue title. + * @param description - Issue body (markdown). + * @param issueType - Logical type mapped to labels (`Story` → `enhancement`, etc.). + * @param _projectKey - Ignored; backend is bound to one project. + * @returns Issue iid and web URL. + * @throws When the GitLab API request fails. + */ + async createTask( + summary: string, + description: string, + issueType: string, + _projectKey?: string, + ): Promise { + const labels: string[] = []; + + // Map common issue types to GitLab labels + const labelMap: Record = { + Story: "enhancement", + Bug: "bug", + Task: "task", + Epic: "epic", + }; + + const mappedLabel = labelMap[issueType]; + if (mappedLabel) { + labels.push(mappedLabel); + } + + const issue = await this.client.createIssue(summary, description, labels); + + return { + key: String(issue.iid), + url: issue.web_url, + }; + } + + /** + * Create a sub-issue and append it to the parent's subtasks checklist. + * + * @param parentKey - Parent issue iid as string. + * @param summary - Sub-issue title. + * @param description - Optional sub-issue body. + * @param _projectKey - Ignored. + * @returns Sub-issue iid and web URL. + * @throws When `parentKey` is not a valid issue iid or API calls fail. + */ + async createSubtask( + parentKey: string, + summary: string, + description?: string, + _projectKey?: string, + ): Promise { + const parentIid = parseInt(parentKey, 10); + if (isNaN(parentIid)) { + throw new Error(`Invalid parent issue number: ${parentKey}`); + } + + const subtask = await this.client.createSubtask(parentIid, summary, description); + + return { + key: String(subtask.iid), + url: subtask.web_url, + }; + } + + /** + * Add an epic reference (`Part of #N`) to the issue body. + * + * @param storyKey - Child issue iid as string. + * @param epicKey - Epic issue iid as string. + * @throws When either key is not a valid issue iid or update fails. + */ + async linkToEpic(storyKey: string, epicKey: string): Promise { + const storyIid = parseInt(storyKey, 10); + const epicIid = parseInt(epicKey, 10); + + if (isNaN(storyIid) || isNaN(epicIid)) { + throw new Error("Invalid issue number"); + } + + await this.client.linkToEpic(storyIid, epicIid); + } + + /** + * List projects accessible to the authenticated user. + * + * @returns Full path (`group/sub/repo`) and display name pairs. + * @throws When the GitLab API request fails. + */ + async getProjects(): Promise { + const projects = await this.client.getProjects(); + return projects.map((p) => ({ + key: p.path_with_namespace, + name: p.name, + })); + } + + /** + * Return static issue-type names for UI compatibility. + * + * @returns Default GitLab-oriented type names. + */ + async getIssueTypes(): Promise { + return [...DEFAULT_ISSUE_TYPES]; + } + + /** + * List labels defined on the configured project. + * + * @returns Label refs keyed by name (GitLab uses names as ids), plus truncation. + * @throws When the GitLab API request fails. + */ + async getLabels( + _projectKey?: string, + options?: { maxLabels?: number }, + ): Promise { + const result = await this.client.getLabels(options?.maxLabels); + return { + labels: result.labels.map((label) => ({ id: label.name, name: label.name })), + truncated: result.truncated, + }; + } + + /** + * Add labels to an issue without removing existing ones. + * + * Callers (engine) must pass names from {@link getLabels}; unknown names are + * rejected before this method runs so GitLab cannot auto-create labels. + * + * @param taskKey - Issue iid as string. + * @param labelIds - Existing label names to add. + * @throws When `taskKey` is not a valid issue iid or the API request fails. + */ + async applyLabels(taskKey: string, labelIds: string[]): Promise { + if (labelIds.length === 0) return; + const issueIid = parseInt(taskKey, 10); + if (isNaN(issueIid)) { + throw new Error(`Invalid issue number: ${taskKey}`); + } + await this.client.addLabels(issueIid, labelIds); + } +} diff --git a/packages/pm/lib/backends/index.ts b/packages/pm/lib/backends/index.ts index c15ce84..1bd0318 100644 --- a/packages/pm/lib/backends/index.ts +++ b/packages/pm/lib/backends/index.ts @@ -3,6 +3,7 @@ import type { Config } from "../config"; import { AsanaBackend } from "./asana"; import { AzureDevOpsBackend } from "./azure-devops"; import { GitHubBackend } from "./github"; +import { GitLabBackend } from "./gitlab"; import { JiraBackend } from "./jira"; import { LinearBackend } from "./linear"; import { MarkdownBackend } from "./markdown"; @@ -17,7 +18,7 @@ export type { TaskBackend, CreatedTask, ProjectInfo, LabelRef, LabelListResult } * @param config - Loaded application configuration. * @param baseDir - Base directory for resolving relative paths like the * markdown tasks directory (defaults to cwd; desktop hosts pass the project dir). - * @returns Backend instance for Jira, Linear, Trello, Azure DevOps, Asana, GitHub, or Markdown. + * @returns Backend instance for Jira, Linear, Trello, Azure DevOps, Asana, GitHub, GitLab, or Markdown. * @throws When the backend type is unknown or required backend config is missing. */ export async function createBackend(config: Config, baseDir?: string): Promise { @@ -83,6 +84,17 @@ export async function createBackend(config: Config, baseDir?: string): Promise(); diff --git a/packages/pm/lib/init-shared.ts b/packages/pm/lib/init-shared.ts index 9cac628..6c0bc04 100644 --- a/packages/pm/lib/init-shared.ts +++ b/packages/pm/lib/init-shared.ts @@ -43,6 +43,7 @@ export const PM_TRACKER_NAMES: Record = { "azure-devops": "Azure DevOps", asana: "Asana", github: "GitHub Issues", + gitlab: "GitLab", markdown: "Markdown files", }; @@ -54,6 +55,7 @@ export const PM_TRACKER_DOCS: Record = { "azure-devops": "https://devintern.com/docs/pm/azure-devops-integration", asana: "https://devintern.com/docs/pm/asana-integration", github: "https://devintern.com/docs/pm/github-integration", + gitlab: "https://devintern.com/docs/pm/gitlab-integration", }; /** Per-tracker credential prompts. Env keys match `loadTrackerConfig` and the backends. */ @@ -149,6 +151,30 @@ export const PM_TRACKER_SETUP: Record = { }, { key: "GITHUB_REPO", label: "Target repository", example: "owner/repo" }, ], + gitlab: [ + { + key: "GITLAB_BASE_URL", + label: "GitLab instance URL (press Enter for https://gitlab.com)", + example: "https://gitlab.example.com", + optional: true, + defaultValue: "https://gitlab.com", + }, + { + key: "GITLAB_TOKEN", + label: + "GitLab personal access token (scopes: api, or read_api + write_repository for read/write)", + link: (values) => { + const host = (values.GITLAB_BASE_URL || "https://gitlab.com").replace(/\/+$/, ""); + const origin = /^https?:\/\//i.test(host) ? host : `https://${host}`; + return `${origin}/-/user_settings/personal_access_tokens?name=DevIntern&scopes=api`; + }, + }, + { + key: "GITLAB_PROJECT", + label: "Target project path (subgroups allowed) or numeric project ID", + example: "group/sub/repo", + }, + ], markdown: [ { key: "MARKDOWN_TASKS_DIR", diff --git a/packages/task-trackers/gitlab-client.test.ts b/packages/task-trackers/gitlab-client.test.ts new file mode 100644 index 0000000..e4f8115 --- /dev/null +++ b/packages/task-trackers/gitlab-client.test.ts @@ -0,0 +1,327 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { DEFAULT_GITLAB_BASE_URL, GitLabClient } from "./src/clients/gitlab.ts"; +import { parseGitLabProject, sanitizeGitlabBaseUrl } from "./src/config/load-tracker-config.ts"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +type CapturedRequest = { url: string; method: string; body?: unknown }; + +function mockFetch( + handler: (req: CapturedRequest) => { + status?: number; + json?: unknown; + headers?: Record; + }, +) { + const calls: CapturedRequest[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const req: CapturedRequest = { + url: String(url), + method: init?.method || "GET", + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }; + calls.push(req); + const result = handler(req); + return new Response(JSON.stringify(result.json ?? {}), { + status: result.status ?? 200, + headers: result.headers, + }); + }) as typeof fetch; + return calls; +} + +function makeClient(overrides?: Partial<{ baseUrl: string }>): GitLabClient { + return new GitLabClient({ + token: "glpat-tok", + projectPath: "acme/team/webapp", + baseUrl: overrides?.baseUrl, + }); +} + +describe("GitLabClient", () => { + test("defaults to gitlab.com and encodes subgroup paths", async () => { + const calls = mockFetch(() => ({ json: [] })); + + await makeClient().getProjects(); + + expect(calls[0].url).toContain(`${DEFAULT_GITLAB_BASE_URL}/api/v4`); + await makeClient().getIssue(7); + expect(calls[1].url).toContain("/projects/acme%2Fteam%2Fwebapp/issues/7"); + }); + + test("normalizes custom base URLs with trailing slashes", () => { + const client = new GitLabClient({ + token: "t", + projectPath: "a/b", + baseUrl: "https://gitlab.example.com/", + }); + // @ts-expect-error accessing private field for assertion + expect(client.baseUrl).toBe("https://gitlab.example.com"); + }); + + test("sends PRIVATE-TOKEN header", async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return new Response(JSON.stringify({ id: 1, username: "tester", name: "Tester" }), { + status: 200, + }); + }) as typeof fetch; + + await makeClient().getCurrentUser(); + + const headers = new Headers(calls[0].init?.headers); + expect(headers.get("PRIVATE-TOKEN")).toBe("glpat-tok"); + expect(calls[0].url).toBe("https://gitlab.com/api/v4/user"); + }); + + test("createIssue posts title/description and comma-joined labels", async () => { + const calls = mockFetch(() => ({ + json: { iid: 12, web_url: "https://gitlab.com/acme/team/webapp/-/issues/12" }, + })); + + const issue = await makeClient().createIssue("Add auth", "Implement OAuth", [ + "enhancement", + "backend", + ]); + + expect(calls[0].method).toBe("POST"); + expect(calls[0].body).toEqual({ + title: "Add auth", + description: "Implement OAuth", + labels: "enhancement,backend", + }); + expect(issue.iid).toBe(12); + }); + + test("createIssue omits blank descriptions instead of sending empty string", async () => { + const calls = mockFetch(() => ({ json: { iid: 13, web_url: "u" } })); + + await makeClient().createIssue("Title", "", []); + + expect(calls[0].body).toEqual({ title: "Title" }); + }); + + test("updateIssue maps state to state_event values", async () => { + const calls = mockFetch(() => ({ json: {} })); + + await makeClient().updateIssue(5, { state: "closed" }); + expect(calls[0].body).toEqual({ state_event: "close" }); + + await makeClient().updateIssue(5, { state: "opened" }); + expect(calls[1].body).toEqual({ state_event: "reopen" }); + }); + + test("addLabels uses add_labels so existing labels are kept", async () => { + const calls = mockFetch(() => ({ json: {} })); + + await makeClient().addLabels(9, ["In Progress"]); + + expect(calls[0].method).toBe("PUT"); + expect(calls[0].body).toEqual({ add_labels: "In Progress" }); + }); + + test("addLabels is a no-op for an empty list", async () => { + const calls = mockFetch(() => ({ json: {} })); + + await makeClient().addLabels(9, []); + + expect(calls).toHaveLength(0); + }); + + test("removeLabel deletes via remove_labels and swallows 404", async () => { + mockFetch(() => ({ json: {} })); + await expect(makeClient().removeLabel(7, "gone")).resolves.toBeUndefined(); + + mockFetch(() => ({ status: 404, json: { message: "404 Not Found" } })); + await expect(makeClient().removeLabel(7, "gone")).resolves.toBeUndefined(); + }); + + test("listIssueComments filters system notes", async () => { + mockFetch(() => ({ + json: [ + { + id: 1, + body: "changed the description", + system: true, + author: null, + created_at: "", + updated_at: "", + }, + { + id: 2, + body: "Looks good", + system: false, + author: { username: "alice" }, + created_at: "", + updated_at: "", + }, + ], + })); + + const comments = await makeClient().listIssueComments(3); + + expect(comments.map((c) => c.id)).toEqual([2]); + }); + + test("listIssueComments paginates until a short page", async () => { + const calls = mockFetch((req) => { + const page = new URL(req.url).searchParams.get("page"); + if (page === "1") { + return { + json: Array.from({ length: 100 }, (_, i) => ({ + id: i, + body: `note ${i}`, + system: true, + author: null, + created_at: "", + updated_at: "", + })), + }; + } + return { + json: [ + { + id: 100, + body: "Human comment on the second page", + system: false, + author: { username: "alice" }, + created_at: "", + updated_at: "", + }, + ], + }; + }); + + const comments = await makeClient().listIssueComments(3); + + expect(comments.map((c) => c.id)).toEqual([100]); + expect(calls).toHaveLength(2); + }); + + test("updateIssueComment patches a note scoped to its issue", async () => { + const calls = mockFetch(() => ({ json: {} })); + + await makeClient().updateIssueComment(7, 99, "updated"); + + expect(calls[0].method).toBe("PUT"); + expect(calls[0].url).toContain("/projects/acme%2Fteam%2Fwebapp/issues/7/notes/99"); + expect(calls[0].body).toEqual({ body: "updated" }); + }); + + test("searchIssues translates qualifiers into list params", async () => { + const calls = mockFetch(() => ({ json: [], headers: { "x-total": "42" } })); + + const result = await makeClient().searchIssues('is:open label:"needs review" login flow'); + + const url = new URL(calls[0].url); + expect(url.pathname).toBe("/api/v4/projects/acme%2Fteam%2Fwebapp/issues"); + expect(url.searchParams.get("state")).toBe("opened"); + expect(url.searchParams.get("labels")).toBe("needs review"); + expect(url.searchParams.get("search")).toBe("login flow"); + expect(result.total).toBe(42); + }); + + test("searchIssues resolves assignee:@me through /user", async () => { + const calls = mockFetch((req) => { + if (req.url.includes("/api/v4/user")) { + return { json: { id: 1, username: "tester", name: "Tester" } }; + } + return { json: [] }; + }); + + await makeClient().searchIssues("assignee:@me"); + + const issuesCall = calls.find((c) => c.url.includes("/issues")); + expect(new URL(issuesCall!.url).searchParams.get("assignee_username")).toBe("tester"); + }); + + test("searchIssues maps updated:>= to updated_after", async () => { + const calls = mockFetch(() => ({ json: [] })); + + await makeClient().searchIssues("updated:>=2026-01-31T00:00:00Z"); + + expect(new URL(calls[0].url).searchParams.get("updated_after")).toBe( + "2026-01-31T00:00:00.000Z", + ); + }); + + test("searchIssues falls back to result length when x-total is absent", async () => { + mockFetch(() => ({ json: [{ iid: 1 }, { iid: 2 }] })); + + const result = await makeClient().searchIssues(""); + + expect(result.total).toBe(2); + }); + + test("getLabels paginates until a short page", async () => { + const calls = mockFetch((req) => { + const page = new URL(req.url).searchParams.get("page"); + if (page === "1") { + return { + json: Array.from({ length: 100 }, (_, i) => ({ + id: i, + name: `label-${i}`, + description: null, + })), + }; + } + return { json: [{ id: 100, name: "label-100", description: "last" }] }; + }); + + const result = await makeClient().getLabels(); + + expect(result.labels).toHaveLength(101); + expect(result.truncated).toBe(false); + expect(result.labels[100]?.name).toBe("label-100"); + expect(calls).toHaveLength(2); + }); +}); + +describe("sanitizeGitlabBaseUrl", () => { + test("defaults to gitlab.com when unset or blank", () => { + expect(sanitizeGitlabBaseUrl(undefined)).toBe(DEFAULT_GITLAB_BASE_URL); + expect(sanitizeGitlabBaseUrl(" ")).toBe(DEFAULT_GITLAB_BASE_URL); + }); + + test("adds https:// when the protocol is missing and strips trailing slashes", () => { + expect(sanitizeGitlabBaseUrl("gitlab.example.com")).toBe("https://gitlab.example.com"); + expect(sanitizeGitlabBaseUrl("http://gitlab.internal:8080///")).toBe( + "http://gitlab.internal:8080", + ); + expect(sanitizeGitlabBaseUrl("https://gitlab.example.com/")).toBe("https://gitlab.example.com"); + }); +}); + +describe("parseGitLabProject", () => { + test("accepts group/repo and subgroup paths", () => { + expect(parseGitLabProject("acme/my-app")).toBe("acme/my-app"); + expect(parseGitLabProject("acme/team/my-app")).toBe("acme/team/my-app"); + }); + + test("accepts numeric project IDs", () => { + expect(parseGitLabProject("42179")).toBe("42179"); + }); + + test("strips origins, slashes, and /-/ suffixes from pasted URLs", () => { + expect(parseGitLabProject("https://gitlab.com/acme/my-app")).toBe("acme/my-app"); + expect(parseGitLabProject("https://gitlab.internal/acme/team/my-app/-/issues")).toBe( + "acme/team/my-app", + ); + expect(parseGitLabProject("/acme/my-app/")).toBe("acme/my-app"); + }); + + test("throws on empty values and bare namespaces", () => { + expect(() => parseGitLabProject("")).toThrow(/Invalid GITLAB_PROJECT/); + expect(() => parseGitLabProject("just-a-name")).toThrow(/Invalid GITLAB_PROJECT/); + expect(() => parseGitLabProject("https://gitlab.com")).toThrow(/Invalid GITLAB_PROJECT/); + }); + + test("throws the friendly validation error on malformed percent-escapes", () => { + expect(() => parseGitLabProject("acme/%zz")).toThrow(/Invalid GITLAB_PROJECT/); + }); +}); diff --git a/packages/task-trackers/src/clients/gitlab.ts b/packages/task-trackers/src/clients/gitlab.ts new file mode 100644 index 0000000..ce063e3 --- /dev/null +++ b/packages/task-trackers/src/clients/gitlab.ts @@ -0,0 +1,562 @@ +/** + * GitLab REST API v4 client for Issues + * + * Works against GitLab Cloud (gitlab.com) and self-hosted instances: every + * request goes through the instance base URL (`GITLAB_BASE_URL`, default + * https://gitlab.com). + * + * API docs: + * - Create issue: https://docs.gitlab.com/ee/api/issues.html#new-issue + * - Get single issue: https://docs.gitlab.com/ee/api/issues.html#single-issue + * - Edit issue: https://docs.gitlab.com/ee/api/issues.html#edit-issue + * - List project issues: https://docs.gitlab.com/ee/api/issues.html#list-project-issues + * - Comments (notes): https://docs.gitlab.com/ee/api/notes.html#list-project-issue-notes + * - Labels: https://docs.gitlab.com/ee/api/labels.html#list-labels + */ + +/** Default instance used when `GITLAB_BASE_URL` is unset. */ +export const DEFAULT_GITLAB_BASE_URL = "https://gitlab.com"; + +export interface GitLabIssue { + id: number; + /** Project-scoped issue number used in URLs and references. */ + iid: number; + project_id: number; + title: string; + description: string | null; + /** `opened` or `closed`. */ + state?: string; + /** Label names (plain array; `with_labels_details` is not requested). */ + labels?: string[]; + author?: { username: string }; + assignees?: Array<{ username: string }>; + created_at?: string; + updated_at?: string; + web_url: string; + references?: { full: string }; +} + +export interface GitLabIssueComment { + id: number; + /** Comment body in markdown. */ + body: string; + author: { username: string } | null; + /** True for system-generated notes (status changes, assignments, …). */ + system?: boolean; + created_at: string; + updated_at: string; +} + +export interface GitLabProject { + id: number; + name: string; + /** Full path including subgroups, e.g. `group/sub/repo`. */ + path_with_namespace: string; +} + +export interface GitLabLabel { + id: number; + name: string; + description: string | null; +} + +export interface GitLabUser { + id: number; + username: string; + name: string; +} + +export interface GitLabClientConfig { + token: string; + /** Project path with optional subgroups (`group/sub/repo`) or numeric id. */ + projectPath: string; + /** Instance root (default {@link DEFAULT_GITLAB_BASE_URL}); no `/api/v4` suffix. */ + baseUrl?: string; +} + +export class GitLabClient { + private token: string; + private projectPath: string; + private baseUrl: string; + private currentUser: GitLabUser | null = null; + + /** + * Create a GitLab REST v4 client bound to one project. + * + * @param config - PAT, target project path/id, and optional instance base URL. + */ + constructor(config: GitLabClientConfig) { + this.token = config.token; + this.projectPath = config.projectPath; + this.baseUrl = (config.baseUrl?.trim() || DEFAULT_GITLAB_BASE_URL).replace(/\/+$/, ""); + } + + /** + * Send an authenticated request to the GitLab REST API v4. + * + * @param endpoint - API path after `/api/v4` (e.g. `/projects/42/issues`). + * @param method - HTTP method (default `GET`). + * @param body - Optional JSON request body. + * @returns Parsed JSON response body. + * @throws When the response status is not OK. + */ + private async request( + endpoint: string, + method: string = "GET", + body?: Record, + ): Promise { + const url = `${this.baseUrl}/api/v4${endpoint}`; + const headers: Record = { + "PRIVATE-TOKEN": this.token, + Accept: "application/json", + }; + + const options: RequestInit = { method, headers }; + + if (body && method !== "GET") { + headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + + const response = await fetch(url, options); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`GitLab API error (${response.status}): ${errorText}`); + } + + return response.json() as T; + } + + /** + * Project-scoped API prefix with the URL-encoded project path/id. + * + * Subgroup paths (`group/sub/repo`) must be encoded with encodeURIComponent + * so slashes become `%2F`. + */ + private get projectEndpoint(): string { + return `/projects/${encodeURIComponent(this.projectPath)}`; + } + + /** + * Fetch the authenticated user (used for `assignee:@me` queries). + * + * @returns Current user record (cached after the first call). + * @throws When the token is invalid or the API request fails. + */ + async getCurrentUser(): Promise { + this.currentUser ??= await this.request("/user"); + return this.currentUser; + } + + /** + * Create an issue in the configured project. + * + * @param title - Issue title. + * @param description - Issue body (markdown). + * @param labels - Optional label names to apply. + * @returns Created issue metadata. + * @throws When the GitLab API request fails. + */ + async createIssue(title: string, description: string, labels?: string[]): Promise { + const data: Record = { title }; + + // GitLab rejects empty-string descriptions on create; omit instead. + if (description && description.trim()) { + data.description = description; + } + if (labels && labels.length > 0) { + data.labels = labels.join(","); + } + + return this.request(`${this.projectEndpoint}/issues`, "POST", data); + } + + /** + * Fetch an issue by project-scoped number (iid). + * + * @param issueIid - Issue iid in the configured project. + * @returns Issue metadata. + * @throws When the issue is not found or the API request fails. + */ + async getIssue(issueIid: number): Promise { + return this.request(`${this.projectEndpoint}/issues/${issueIid}`); + } + + /** + * Partially update an existing issue. + * + * @param issueIid - Issue iid to update. + * @param updates - Fields to patch (title, description, labels, state). + * Passing `labels` replaces the full label set; use {@link addLabels} / + * {@link removeLabel} for additive edits. `state: "opened"` reopens a + * closed issue and `state: "closed"` closes an open one. + * @returns Updated issue metadata. + * @throws When the GitLab API request fails. + */ + async updateIssue( + issueIid: number, + updates: { + title?: string; + description?: string; + labels?: string[]; + state?: "opened" | "closed"; + }, + ): Promise { + const data: Record = {}; + if (updates.title !== undefined) data.title = updates.title; + if (updates.description !== undefined) data.description = updates.description; + if (updates.labels) data.labels = updates.labels.join(","); + if (updates.state === "closed") data.state_event = "close"; + if (updates.state === "opened") data.state_event = "reopen"; + + return this.request(`${this.projectEndpoint}/issues/${issueIid}`, "PUT", data); + } + + /** + * Create a sub-issue and append a task-list reference on the parent issue. + * + * @param parentIid - Parent issue iid. + * @param title - Sub-issue title. + * @param description - Optional sub-issue body. + * @returns Created sub-issue metadata. + * @throws When issue creation or parent update fails. + */ + async createSubtask( + parentIid: number, + title: string, + description?: string, + ): Promise { + // Create the subtask as a new issue + const subtask = await this.createIssue(title, description || ""); + + // Add a task list item to the parent issue body + const parent = await this.getIssue(parentIid); + const parentBody = parent.description || ""; + const taskListItem = `- [ ] #${subtask.iid}`; + + // Check if there's already a subtasks section + const subtasksHeader = "## Subtasks"; + let newBody: string; + + if (parentBody.includes(subtasksHeader)) { + // Append to existing subtasks section + newBody = parentBody.replace(subtasksHeader, `${subtasksHeader}\n${taskListItem}`); + } else { + // Add new subtasks section + newBody = parentBody + ? `${parentBody}\n\n${subtasksHeader}\n${taskListItem}` + : `${subtasksHeader}\n${taskListItem}`; + } + + await this.updateIssue(parentIid, { description: newBody }); + + return subtask; + } + + /** + * Add a `Part of #N` epic reference to an issue body (idempotent). + * + * @param issueIid - Child issue iid. + * @param epicIid - Epic issue iid. + * @throws When the GitLab API request fails. + */ + async linkToEpic(issueIid: number, epicIid: number): Promise { + // Add a reference to the epic in the issue body + const issue = await this.getIssue(issueIid); + const currentBody = issue.description || ""; + + const epicReference = `Part of #${epicIid}`; + + // Avoid duplicate references + if (currentBody.includes(epicReference)) { + return; + } + + const newBody = currentBody ? `${currentBody}\n\n${epicReference}` : epicReference; + + await this.updateIssue(issueIid, { description: newBody }); + } + + /** + * List projects accessible to the authenticated user. + * + * @returns Up to 100 project records ordered by last activity. + * @throws When the GitLab API request fails. + */ + async getProjects(): Promise { + return this.request( + "/projects?membership=true&order_by=last_activity_at&per_page=100", + ); + } + + /** + * List labels defined on the configured project (paginated until exhausted or cap). + * + * Soft-capped at {@link maxLabels} (default 500). When `truncated` is true, + * more labels may exist — validation of a selected set should page without + * the cap rather than rejecting missing names as unknown. + * + * @param maxLabels - Soft upper bound on labels returned (default 500). + * @returns Label records plus whether the soft cap truncated the catalog. + * @throws When the GitLab API request fails. + */ + async getLabels(maxLabels: number = 500): Promise<{ labels: GitLabLabel[]; truncated: boolean }> { + const labels: GitLabLabel[] = []; + // Keep per_page fixed: GitLab's `page` offset is relative to per_page, so + // shrinking the last request would re-fetch earlier items. + const pageSize = 100; + let page = 1; + let truncated = false; + + while (labels.length < maxLabels) { + const batch = await this.request( + `${this.projectEndpoint}/labels?with_counts=false&per_page=${pageSize}&page=${page}`, + ); + labels.push(...batch); + if (batch.length < pageSize) { + break; + } + page += 1; + if (labels.length >= maxLabels) { + // Full page hit the soft cap — probe one more page before claiming + // truncation (exact multiples of pageSize would otherwise false-positive). + const sentinel = await this.request( + `${this.projectEndpoint}/labels?per_page=${pageSize}&page=${page}`, + ); + truncated = sentinel.length > 0; + break; + } + } + + // A short final page can overshoot the soft cap before we break; slicing + // must still report truncated so callers exhaust before treating misses as unknown. + return { + labels: labels.slice(0, maxLabels), + truncated: truncated || labels.length > maxLabels, + }; + } + + /** + * Add labels to an issue (does not remove existing labels). + * + * @param issueIid - Target issue iid. + * @param labels - Label names to add. + * @throws When the GitLab API request fails. + */ + async addLabels(issueIid: number, labels: string[]): Promise { + if (labels.length === 0) return; + await this.request(`${this.projectEndpoint}/issues/${issueIid}`, "PUT", { + add_labels: labels.join(","), + }); + } + + /** + * Remove a label from an issue. Missing labels are ignored. + * + * @param issueIid - Target issue iid. + * @param label - Label name to remove. + * @throws When the GitLab API request fails for reasons other than 404. + */ + async removeLabel(issueIid: number, label: string): Promise { + try { + await this.request(`${this.projectEndpoint}/issues/${issueIid}`, "PUT", { + remove_labels: label, + }); + } catch (error) { + if (error instanceof Error && error.message.includes("(404)")) { + return; + } + throw error; + } + } + + /** + * List comments (notes) on an issue (oldest first). + * + * System-generated notes (status changes, assignments, label edits) are + * filtered out per page so callers see human comments only. Pagination runs + * until exhausted so dedup checks never miss older comments on busy issues. + * + * @param issueIid - Target issue iid. + * @returns All human comment records. + * @throws When the GitLab API request fails. + */ + async listIssueComments(issueIid: number): Promise { + const comments: GitLabIssueComment[] = []; + // Keep per_page fixed: GitLab's `page` offset is relative to per_page, so + // shrinking the last request would re-fetch earlier items. + const pageSize = 100; + let page = 1; + + while (true) { + const batch = await this.request( + `${this.projectEndpoint}/issues/${issueIid}/notes?sort=asc&order_by=created_at&per_page=${pageSize}&page=${page}`, + ); + comments.push(...batch.filter((note) => !note.system)); + if (batch.length < pageSize) { + break; + } + page += 1; + } + + return comments; + } + + /** + * Post a markdown comment on an issue. + * + * @param issueIid - Target issue iid. + * @param body - Comment body (markdown). + * @returns Created comment record. + * @throws When the GitLab API request fails. + */ + async createIssueComment(issueIid: number, body: string): Promise { + return this.request( + `${this.projectEndpoint}/issues/${issueIid}/notes`, + "POST", + { body }, + ); + } + + /** + * Update an existing issue comment's markdown body. + * + * The Notes API is noteable-scoped, so the request must address both the + * issue and the note: `PUT /projects/:id/issues/:iid/notes/:note_id`. + * + * @param issueIid - Issue iid the comment belongs to. + * @param commentId - Note id (not the issue iid). + * @param body - New comment body (markdown). + * @throws When the GitLab API request fails. + */ + async updateIssueComment(issueIid: number, commentId: number, body: string): Promise { + await this.request(`${this.projectEndpoint}/issues/${issueIid}/notes/${commentId}`, "PUT", { + body, + }); + } + + /** + * Search issues in the configured project using GitHub-style qualifiers. + * + * Supported qualifiers (case-insensitive keys): + * `is:open` / `state:opened` — open issues + * `is:closed` / `state:closed` — closed issues + * `label:name` — repeatable; ANDed together + * `assignee:@me` — resolved to the token's username + * `assignee:username` — single assignee username + * `updated:>=2026-01-01T00:00Z` — updated_after filter + * Anything else (quoted phrases supported) becomes a free-text `search`. + * + * Unlike GitHub's global search, results are always scoped to the + * configured project; there is no `repo:` qualifier. + * + * @param query - Qualifier string (e.g. `is:open label:bug`). + * @returns Matching issues (first 100) and the total match count. + * @throws When the GitLab API request fails. + */ + async searchIssues(query: string): Promise<{ issues: GitLabIssue[]; total: number }> { + const params = new URLSearchParams(); + params.set("per_page", "100"); + + const labels: string[] = []; + const freeText: string[] = []; + + for (const token of tokenizeQuery(query)) { + const colon = token.indexOf(":"); + const key = colon >= 0 ? token.slice(0, colon).toLowerCase() : ""; + const value = colon >= 0 ? token.slice(colon + 1) : ""; + switch (key) { + case "is": + case "state": { + const lowered = value.toLowerCase(); + if (lowered === "open" || lowered === "opened") params.set("state", "opened"); + else if (lowered === "closed") params.set("state", "closed"); + else freeText.push(token); + break; + } + case "label": + case "labels": + if (value) labels.push(stripQuotes(value)); + break; + case "assignee": + case "assigned": { + const name = stripQuotes(value); + if (!name) break; + const username = + name.toLowerCase() === "@me" ? (await this.getCurrentUser()).username : name; + params.set("assignee_username", username); + break; + } + case "updated": { + // GitHub syntax: updated:>= + const match = value.match(/^>=\s*(.+)$/); + const iso = match ? match[1] : value; + if (iso) params.set("updated_after", normalizeIsoDate(iso)); + break; + } + case "updated_after": + if (value) params.set("updated_after", normalizeIsoDate(value)); + break; + default: + freeText.push(token); + } + } + + if (labels.length > 0) { + params.set("labels", labels.join(",")); + } + if (freeText.length > 0) { + params.set("search", freeText.map(stripQuotes).join(" ").trim()); + } + + const url = `${this.baseUrl}/api/v4${this.projectEndpoint}/issues?${params.toString()}`; + const response = await fetch(url, { + method: "GET", + headers: { "PRIVATE-TOKEN": this.token, Accept: "application/json" }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`GitLab API error (${response.status}): ${errorText}`); + } + + const issues = (await response.json()) as GitLabIssue[]; + // X-Total carries the unpaginated match count; missing on some proxies. + const totalHeader = response.headers.get("x-total"); + const total = Number.parseInt(totalHeader ?? "", 10); + + return { issues, total: Number.isFinite(total) ? total : issues.length }; + } +} + +/** + * Tokenize a query string on whitespace while keeping quoted phrases intact, + * including qualifiers attached to them (e.g. `is:open "login flow"` + * `label:"needs review"`). + */ +function tokenizeQuery(query: string): string[] { + const tokens: string[] = []; + const pattern = /(?:[^\s":]+:)?"[^"]*"|\S+/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(query)) !== null) { + tokens.push(match[0]); + } + return tokens; +} + +function stripQuotes(value: string): string { + return value.replace(/^"(.*)"$/, "$1"); +} + +/** + * Normalize a date literal to the ISO-8601 form GitLab expects. + * Bare dates (`2026-01-31`) pass through unchanged; other values are parsed + * and re-serialized, falling back to the input when unparseable. + */ +function normalizeIsoDate(value: string): string { + const trimmed = stripQuotes(value); + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed; + const parsed = new Date(trimmed); + return Number.isNaN(parsed.getTime()) ? trimmed : parsed.toISOString(); +} diff --git a/packages/task-trackers/src/clients/index.ts b/packages/task-trackers/src/clients/index.ts index 8885059..86854d0 100644 --- a/packages/task-trackers/src/clients/index.ts +++ b/packages/task-trackers/src/clients/index.ts @@ -25,6 +25,15 @@ export type { export { GitHubClient } from "./github.ts"; export type { GitHubIssue, GitHubIssueComment, GitHubLabel, GitHubRepository } from "./github.ts"; +export { DEFAULT_GITLAB_BASE_URL, GitLabClient } from "./gitlab.ts"; +export type { + GitLabIssue, + GitLabIssueComment, + GitLabLabel, + GitLabProject, + GitLabUser, +} from "./gitlab.ts"; + export { LinearClient } from "./linear.ts"; export type { LinearAttachment, diff --git a/packages/task-trackers/src/config/load-tracker-config.ts b/packages/task-trackers/src/config/load-tracker-config.ts index a5d5828..d8ffea0 100644 --- a/packages/task-trackers/src/config/load-tracker-config.ts +++ b/packages/task-trackers/src/config/load-tracker-config.ts @@ -1,5 +1,6 @@ import { readFile } from "node:fs/promises"; import { findEnvFile } from "@devintern/utils"; +import { DEFAULT_GITLAB_BASE_URL } from "../clients/gitlab.ts"; import { getMissingRequiredEnv, isTrackerId } from "./tracker-meta.ts"; import type { TrackerConfig, TrackerType } from "./types.ts"; @@ -43,6 +44,71 @@ export function sanitizeDomain(domain: string): string { return domain.replace(/^https?:\/\//, "").replace(/\/+$/, ""); } +/** + * Normalize a GitLab instance URL. + * + * Unlike Jira domains, GitLab needs the protocol kept (self-hosted instances + * may run on plain http). Adds `https://` when no protocol is present and + * strips trailing slashes; blank values fall back to + * {@link DEFAULT_GITLAB_BASE_URL}. + * + * @param raw - Raw `GITLAB_BASE_URL` value (may be empty). + * @returns Instance root URL suitable for API requests (e.g. `https://gitlab.com`). + */ +export function sanitizeGitlabBaseUrl(raw: string | undefined): string { + const trimmed = raw?.trim(); + if (!trimmed) return DEFAULT_GITLAB_BASE_URL; + const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + return withProtocol.replace(/\/+$/, ""); +} + +/** + * Normalize a `GITLAB_PROJECT` value into an API project path. + * + * Accepts `group/repo`, subgroup paths (`group/sub/repo`), numeric project + * IDs, and pasted web URLs (`https://host/group/repo/-/issues`). The `/-/` + * suffix and everything after it is dropped. + * + * @param value - Raw `GITLAB_PROJECT` environment variable value. + * @returns Project path for API URLs (encoded later by {@link GitLabClient}). + * @throws When the value does not identify a project. + */ +export function parseGitLabProject(value: string): string { + const trimmed = value.trim(); + // Drop the instance origin from pasted web URLs, then cut at "/-/". + let path = trimmed.replace(/^https?:\/\/[^/]+/i, ""); + const dashIndex = path.indexOf("/-/"); + if (dashIndex >= 0) { + path = path.slice(0, dashIndex); + } + path = path + .split("/") + .filter(Boolean) + .map((segment) => { + // Malformed escapes (`%zz`) throw URIError; keep the raw segment so the + // validation below reports a friendly error instead. + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }) + .join("/"); + + if (/^\d+$/.test(path)) { + return path; + } + + if (!/^[\w.-]+(?:\/[\w.-]+)+$/.test(path)) { + throw new Error( + `Invalid GITLAB_PROJECT "${value}". Expected group/repo (subgroups allowed, ` + + "e.g. acme/team/my-app) or a numeric project ID.", + ); + } + + return path; +} + /** * Parse `GITHUB_REPO` into owner and repo name. * @@ -148,6 +214,7 @@ export function parseTrackerConfigFromEnv(): TrackerConfig { let azureDevOpsConfig: TrackerConfig["azureDevOps"]; let asanaConfig: TrackerConfig["asana"]; let githubConfig: TrackerConfig["github"]; + let gitlabConfig: TrackerConfig["gitlab"]; // Markdown keeps MARKDOWN_TASKS_DIR optional here (backends default the path); // other trackers validate against TRACKER_META.requiredEnv. @@ -211,6 +278,14 @@ export function parseTrackerConfigFromEnv(): TrackerConfig { }; } + if (backendType === "gitlab") { + gitlabConfig = { + token: process.env.GITLAB_TOKEN!, + projectPath: parseGitLabProject(process.env.GITLAB_PROJECT!), + baseUrl: sanitizeGitlabBaseUrl(process.env.GITLAB_BASE_URL), + }; + } + return { backend: backendConfig, verbose, @@ -220,6 +295,7 @@ export function parseTrackerConfigFromEnv(): TrackerConfig { azureDevOps: azureDevOpsConfig, asana: asanaConfig, github: githubConfig, + gitlab: gitlabConfig, }; } diff --git a/packages/task-trackers/src/config/tracker-meta.ts b/packages/task-trackers/src/config/tracker-meta.ts index 69a7740..e418d5b 100644 --- a/packages/task-trackers/src/config/tracker-meta.ts +++ b/packages/task-trackers/src/config/tracker-meta.ts @@ -36,6 +36,7 @@ export const TRACKER_IDS: readonly TrackerId[] = [ "azure-devops", "asana", "github", + "gitlab", "markdown", ] as const; @@ -77,6 +78,14 @@ export const TRACKER_META: Record = { requiredEnv: ["GITHUB_TOKEN", "GITHUB_REPO"], projectKeyEnv: "GITHUB_REPO", }, + gitlab: { + id: "gitlab", + displayName: "GitLab", + // GITLAB_BASE_URL is optional (defaults to https://gitlab.com) so + // self-hosted instances are supported without extra ceremony. + requiredEnv: ["GITLAB_TOKEN", "GITLAB_PROJECT"], + projectKeyEnv: "GITLAB_PROJECT", + }, markdown: { id: "markdown", displayName: "Markdown files", diff --git a/packages/task-trackers/src/config/types.ts b/packages/task-trackers/src/config/types.ts index f715af6..306c15e 100644 --- a/packages/task-trackers/src/config/types.ts +++ b/packages/task-trackers/src/config/types.ts @@ -5,7 +5,8 @@ export type TrackerType = | "trello" | "azure-devops" | "asana" - | "github"; + | "github" + | "gitlab"; export interface TrackerConfig { backend: { @@ -45,4 +46,9 @@ export interface TrackerConfig { repo: string; repository: string; }; + gitlab?: { + token: string; + projectPath: string; + baseUrl: string; + }; } diff --git a/packages/task-trackers/src/index.ts b/packages/task-trackers/src/index.ts index f3aa81d..44b0c36 100644 --- a/packages/task-trackers/src/index.ts +++ b/packages/task-trackers/src/index.ts @@ -4,8 +4,10 @@ export { loadEnvFromConfigDir, loadTrackerConfig, parseGitHubRepo, + parseGitLabProject, parseTrackerConfigFromEnv, sanitizeDomain, + sanitizeGitlabBaseUrl, } from "./config/load-tracker-config.ts"; export type { ConfiguredTracker, TrackerId, TrackerMeta } from "./config/tracker-meta.ts"; export { diff --git a/packages/task-trackers/src/init/wizard-core.ts b/packages/task-trackers/src/init/wizard-core.ts index 82d1208..6dee677 100644 --- a/packages/task-trackers/src/init/wizard-core.ts +++ b/packages/task-trackers/src/init/wizard-core.ts @@ -13,6 +13,7 @@ import { AsanaClient, AzureDevOpsClient, GitHubClient, + GitLabClient, JiraClient, LinearClient, TrelloClient, @@ -74,6 +75,13 @@ export async function defaultProbe(trackerId: string, env: Record { test("getTrackerDisplayName returns human-readable names", () => { expect(getTrackerDisplayName("jira")).toBe("Jira"); expect(getTrackerDisplayName("github")).toBe("GitHub Issues"); + expect(getTrackerDisplayName("gitlab")).toBe("GitLab"); expect(getTrackerDisplayName("mystery")).toBe("mystery"); }); + test("gitlab is configured without GITLAB_BASE_URL (defaults apply)", () => { + expect( + isTrackerConfigured("gitlab", { + GITLAB_TOKEN: "glpat_x", + GITLAB_PROJECT: "acme/my-app", + }), + ).toBe(true); + expect(isTrackerConfigured("gitlab", { GITLAB_TOKEN: "glpat_x" })).toBe(false); + expect(getProjectKeyEnvVar("gitlab")).toBe("GITLAB_PROJECT"); + expect(getMissingRequiredEnv("gitlab", {})).toEqual(["GITLAB_TOKEN", "GITLAB_PROJECT"]); + }); + test("listConfiguredTrackers returns only fully configured trackers", () => { const env = { TASK_TRACKER: "jira",