diff --git a/.changeset/fn-7424-gitlab-imports.md b/.changeset/fn-7424-gitlab-imports.md new file mode 100644 index 0000000000..5dd32673ea --- /dev/null +++ b/.changeset/fn-7424-gitlab-imports.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add GitLab project issue, group issue, and merge request imports. +category: feature +dev: Adds HTTP API GitLab import routes, dashboard affordances, CLI commands, and extension tools. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 43fbf48db4..2f790cd380 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -649,6 +649,8 @@ Default behavior: PR title/body are AI-generated unless both `--title` and `--bo `fn task import` creates Fusion tasks from GitHub issues. If project or global GitHub tracking defaults are enabled, imported issue tasks are marked as tracked and the tracking hook links the source issue itself instead of opening a duplicate Fusion tracking issue. +`fn task import-gitlab` creates Fusion tasks from GitLab project issues, group issues, or project merge requests using the configured GitLab instance/API URL and access token (`read_api` or `api` scope). It uses the GitLab HTTP API only (no `glab` dependency), supports GitLab.com and self-managed instances, stores `gitlab_import` provenance, and skips duplicates by source URL/provenance. + ```bash fn pr create FN-001 fn pr create FN-001 --draft --reviewer octocat --reviewer hubot --base main @@ -657,6 +659,10 @@ fn pr automerge-cleanup --json fn pr automerge-cleanup --apply fn task import owner/repo --labels bug --limit 10 fn task import owner/repo --interactive +fn task import-gitlab group/project --resource project-issues --labels bug --limit 10 +fn task import-gitlab group/subgroup --resource group-issues --limit 20 +fn task import-gitlab 12345 --resource merge-requests --limit 5 +fn task import-gitlab platform/team/app --resource project-issues --project self-managed ``` --- @@ -1166,8 +1172,9 @@ Subcommands: `search`, `install`. | `--node` | `fn task create` | | `--feedback` | `fn task refine` | | `--yes` | confirmation-skipping flows (`task plan`, `settings import`, git pull/push, etc.) | -| `--limit`, `-l` | `fn task import` (default: 30, max: 100), `fn skills search` (default: 10, max: 50) | -| `--labels`, `-L` | `fn task import` | +| `--limit`, `-l` | `fn task import`, `fn task import-gitlab` (default: 30, max: 100), `fn skills search` (default: 10, max: 50) | +| `--labels`, `-L` | `fn task import`, `fn task import-gitlab` | +| `--resource`, `-r` | `fn task import-gitlab` (`project-issues`, `group-issues`, or `merge-requests`) | | `--skill` | `fn skills install` | | `--dry-run` | `fn agent import` | | `--skip-existing` | `fn agent import` | diff --git a/docs/gitlab-parity-inventory.md b/docs/gitlab-parity-inventory.md index 5c04e00302..237d90a792 100644 --- a/docs/gitlab-parity-inventory.md +++ b/docs/gitlab-parity-inventory.md @@ -95,12 +95,18 @@ Merge request review-task metadata should use `mergeRequestId` and `mergeRequest - Do not add a GitLab-star prompt or any GitHub-star-equivalent promotional prompt. - Do not auto-merge or auto-close merge requests as a side effect of task completion unless a later, explicit MR lifecycle task defines that policy. +## FN-7424 implemented import slice + +FN-7424 implements the runtime import rows for dashboard/API, CLI, extension tools, source provenance, duplicate detection, and Import Tasks navigation for GitLab project issues, group issues, and project merge requests. Implemented surfaces are HTTP API only, use configured GitLab.com/self-managed URL and `PRIVATE-TOKEN` auth, require read-only `read_api`/`api` scope for list/import, persist `sourceType: "gitlab_import"` with `sourceMetadata.provider: "gitlab"`, and preserve originating project identity for group issue rows. + +Still deferred to later parity tasks: linked GitLab tracking issue creation/adoption and Task Detail/List chips, GitLab notes/comments on completion, source issue close/reopen and exact closed-at backfill, merge request lifecycle actions, Command Center GitLab analytics/signals, GitLab research/search provider support, and any GitLab-star or promotional prompt. + ## Downstream handoff: FN-7422 through FN-7428 - **FN-7422 — GitLab core URL configuration:** implemented GitLab.com/self-managed instance and API URL settings/resolution. Preserve GitHub settings untouched. - **FN-7423 — GitLab access-token settings/auth contract:** implemented personal/project/group token settings, `GITLAB_TOKEN` fallback, `PRIVATE-TOKEN` auth metadata, and required scope documentation. Preserve GitHub settings untouched and do not add `glab`. -- **FN-7424 — GitLab HTTP client and import routes:** implement dashboard/API issue and merge-request fetch/detail/import rows. Support GitLab.com and self-managed instances, project and group issue discovery, project issue comments, and MR detail previews. -- **FN-7425 — CLI and extension tools:** implement GitLab CLI import/browse commands and `fn_task_import_gitlab*` tools with permission classification matching GitHub import tools. Use HTTP token auth, not `glab`. +- **FN-7424 — GitLab import runtime:** implemented dashboard/API fetch/import rows, Import Tasks provider controls, CLI `fn task import-gitlab`, and `fn_task_*_gitlab_*` browse/import extension tools for project issues, group issues, and merge requests. GitLab comments, close/reopen, linked tracking, Command Center, research/search, and star prompts remain deferred. +- **FN-7425 — GitLab tracking lifecycle:** implement provider-specific tracking issue creation/adoption, post-create hooks, Task Detail/List chips and controls, lifecycle notes, close/reopen/delete/unlink behavior, stale state, and batch status refresh. - **FN-7426 — Completion comments and auto-close:** implement `gitlabCommentOnDone`, `gitlabCommentTemplate`, `gitlabCloseSourceIssueOnDone`, source issue reconciliation, and exact closed-at backfill for project issues. Keep MR close/merge behavior out unless explicitly scoped. - **FN-7427 — Command Center GitLab analytics and signals:** add local-task-store-only GitLab analytics with CSV export and optional manual exact-time backfill. Add GitLab webhook/system-hook Signals connector only after the signed verification and normalization contract is defined. - **FN-7428 — Documentation, migration, and parity QA:** update user docs, settings reference, dashboard guide, task-management docs, extension skill docs, and add contract tests covering included surfaces and the explicit research/star prompt exclusions. diff --git a/docs/task-management.md b/docs/task-management.md index e066cdbb9b..970d0ef869 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -674,7 +674,11 @@ Recovery/backfill guidance: ## GitHub Issue Import and PR Creation -GitLab instance/API URL and access-token configuration are available in Settings for GitLab.com and self-managed GitLab (`gitlabInstanceUrl`, optional `gitlabApiBaseUrl`, `gitlabAuthToken`, `gitlabAuthTokenType`). Fusion accepts personal, project, and group access tokens for later GitLab HTTP API tasks; read-only operations require `read_api` or `api`, while later write actions such as comments and auto-close require `api`. Runtime GitLab issue/MR import, tracking, comments, auto-close, Command Center signals, research/search support, and star-prompt behavior remain out of scope until the later GitLab parity tasks mapped in [GitLab Parity Inventory](./gitlab-parity-inventory.md). +GitLab instance/API URL and access-token configuration are available in Settings for GitLab.com and self-managed GitLab (`gitlabInstanceUrl`, optional `gitlabApiBaseUrl`, `gitlabAuthToken`, `gitlabAuthTokenType`). Fusion accepts personal, project, and group access tokens for GitLab HTTP API import tasks; read-only project issue, group issue, and merge request imports require `read_api` or `api`, while later write actions such as comments and auto-close require `api`. + +GitLab imports are HTTP API only and do not require or invoke `glab`. Operators can import project issues, group issues, and project merge requests from the Import Tasks surface, CLI, or pi extension tools. Imported tasks are created in `triage`, include the GitLab body (or `(no description)`) plus `Source: `, and persist `source.sourceType: "gitlab_import"`, `source.sourceMetadata.provider: "gitlab"`, `resourceType` (`project_issue`, `group_issue`, or `merge_request`), instance/API URL, project/group identity, IID, and web URL. Group issue imports preserve the originating project identity from GitLab so duplicate detection is project-aware instead of group-path-only. Merge request imports use MR IID as the visible number and a namespaced external ID so they do not collide with issue imports. + +Duplicate detection checks existing non-archived task provenance and source URLs before creating another GitLab-imported task. GitLab linked tracking display, comments/notes, auto-close/reopen, Command Center signals/analytics, research/search support, and any GitLab-star prompt remain out of scope until the later GitLab parity tasks mapped in [GitLab Parity Inventory](./gitlab-parity-inventory.md). Import issues: diff --git a/packages/cli/skill/fusion/SKILL.md b/packages/cli/skill/fusion/SKILL.md index 516f87dad3..c6ac01b95a 100644 --- a/packages/cli/skill/fusion/SKILL.md +++ b/packages/cli/skill/fusion/SKILL.md @@ -27,7 +27,7 @@ Mission → Milestone → Slice → Feature → Task **Tool categories:** -- **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_plan` +- **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_browse_gitlab_project_issues`, `fn_task_import_gitlab_project_issues`, `fn_task_browse_gitlab_group_issues`, `fn_task_import_gitlab_group_issues`, `fn_task_browse_gitlab_merge_requests`, `fn_task_import_gitlab_merge_requests`, `fn_task_plan` - **Workflow tools** — `fn_workflow_list`, `fn_workflow_get`, `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_trait_list`, `fn_workflow_select` - **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues` - **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_list_goals`, `fn_mission_link_goal`, `fn_mission_unlink_goal`, `fn_mission_backfill_assertions`, `fn_mission_delete`, `fn_mission_update`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_feature_delete`, `fn_slice_delete`, `fn_milestone_delete`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_update`, `fn_milestone_update` diff --git a/packages/cli/skill/fusion/references/extension-tools.md b/packages/cli/skill/fusion/references/extension-tools.md index 095ea51425..270f5130c0 100644 --- a/packages/cli/skill/fusion/references/extension-tools.md +++ b/packages/cli/skill/fusion/references/extension-tools.md @@ -127,6 +127,66 @@ Soft-delete a task from active Fusion board views. The task row and artifacts ar | `id` | string | ✓ | Task ID to delete (e.g. FN-001) | | `allowResurrection` | boolean | — | When true, mark this tombstone as explicitly reusable for future recreation. | +### fn_task_browse_gitlab_project_issues + +List GitLab project issues from the configured GitLab instance. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `project` | string | ✓ | GitLab project path or numeric ID | +| `limit` | number | — | | +| `labels` | array | — | | + +### fn_task_import_gitlab_project_issues + +Import GitLab project issues as Fusion tasks using configured GitLab HTTP API auth. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `project` | string | ✓ | GitLab project path or numeric ID | +| `limit` | number | — | | +| `labels` | array | — | | + +### fn_task_browse_gitlab_group_issues + +List GitLab group issues while preserving each issue's originating project identity. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `group` | string | ✓ | GitLab group path or numeric ID | +| `limit` | number | — | | +| `labels` | array | — | | + +### fn_task_import_gitlab_group_issues + +Import GitLab group issues as Fusion tasks using each issue's originating project identity. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `group` | string | ✓ | GitLab group path or numeric ID | +| `limit` | number | — | | +| `labels` | array | — | | + +### fn_task_browse_gitlab_merge_requests + +List GitLab project merge requests from the configured GitLab instance. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `project` | string | ✓ | GitLab project path or numeric ID | +| `limit` | number | — | | +| `labels` | array | — | | + +### fn_task_import_gitlab_merge_requests + +Import GitLab project merge requests as Fusion review tasks using configured GitLab HTTP API auth. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `project` | string | ✓ | GitLab project path or numeric ID | +| `limit` | number | — | | +| `labels` | array | — | | + ### fn_task_plan Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task. diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index fc5ea125c7..867a1a38c1 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -36,6 +36,12 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names | `fn_task_import_github` | Import GitHub issues as Fusion tasks. Fetches open issues from a repository and creates tasks in the planning column. Each task includes the issue title and body with a link to the source issue. | | `fn_task_import_github_issue` | Import a specific GitHub issue as a Fusion task. Fetches the issue by number and creates a single task in the planning column with the issue title and body. | | `fn_task_browse_github_issues` | List open GitHub issues from a repository to browse before importing. Returns issue numbers, titles, and URLs for selection. Use with fn_task_import_github_issue to import specific issues by number. | +| `fn_task_browse_gitlab_project_issues` | List GitLab project issues from the configured GitLab instance. | +| `fn_task_import_gitlab_project_issues` | Import GitLab project issues as Fusion tasks using configured GitLab HTTP API auth. | +| `fn_task_browse_gitlab_group_issues` | List GitLab group issues while preserving each issue's originating project identity. | +| `fn_task_import_gitlab_group_issues` | Import GitLab group issues as Fusion tasks using each issue's originating project identity. | +| `fn_task_browse_gitlab_merge_requests` | List GitLab project merge requests from the configured GitLab instance. | +| `fn_task_import_gitlab_merge_requests` | Import GitLab project merge requests as Fusion review tasks using configured GitLab HTTP API auth. | | `fn_task_plan` | Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task. | | `fn_web_fetch` | Lightweight URL fetch (no JS rendering). Use agent-browser skill for JS-heavy pages. URL to fetch (http/https) Optional extraction hint for downstream summarization Timeout in milliseconds (default: 30000) Max bytes to return (default: 512000) | | `fn_secret_get` | Read a secret by key using per-secret access policy. | diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index 0a1bba230c..2745855777 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -293,6 +293,12 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega "fn_task_import_github", "fn_task_import_github_issue", "fn_task_browse_github_issues", + "fn_task_browse_gitlab_project_issues", + "fn_task_import_gitlab_project_issues", + "fn_task_browse_gitlab_group_issues", + "fn_task_import_gitlab_group_issues", + "fn_task_browse_gitlab_merge_requests", + "fn_task_import_gitlab_merge_requests", "fn_task_archive", "fn_task_unarchive", "fn_task_delete", diff --git a/packages/cli/src/__tests__/task-command-gitlab-import.test.ts b/packages/cli/src/__tests__/task-command-gitlab-import.test.ts new file mode 100644 index 0000000000..33d5aa6cc6 --- /dev/null +++ b/packages/cli/src/__tests__/task-command-gitlab-import.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createTask: vi.fn(), + logEntry: vi.fn(), + listTasks: vi.fn(), + getSettings: vi.fn(), + getGlobalSettings: vi.fn(), +})); + +vi.mock("@fusion/core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + TaskStore: vi.fn(function TaskStore() { + return { + init: vi.fn(), + getSettings: mocks.getSettings, + getGlobalSettingsStore: () => ({ getSettings: mocks.getGlobalSettings }), + listTasks: mocks.listTasks, + createTask: mocks.createTask, + logEntry: mocks.logEntry, + }; + }), + }; +}); + +vi.mock("@fusion/dashboard", () => { + class GitLabClient { + auth: any; + constructor(auth: any) { this.auth = auth; } + async listProjectIssues(project: string, options: any = {}) { + const response = await fetch(`https://gitlab.example.com/api/v4/projects/${encodeURIComponent(project)}/issues?per_page=${options.limit ?? 30}&page=1&state=opened`); + return response.json(); + } + async listGroupIssues(group: string, options: any = {}) { + const response = await fetch(`https://gitlab.example.com/api/v4/groups/${encodeURIComponent(group)}/issues?per_page=${options.limit ?? 30}&page=1&state=opened`); + return response.json(); + } + async listMergeRequests(project: string, options: any = {}) { + const response = await fetch(`https://gitlab.example.com/api/v4/projects/${encodeURIComponent(project)}/merge_requests?per_page=${options.limit ?? 30}&page=1&state=opened`); + return response.json(); + } + } + return { + registerGithubTrackingHook: vi.fn(), + resolveGitlabAuth: vi.fn(() => ({ ok: true, auth: { apiBaseUrl: "https://gitlab.example.com/api/v4", webBaseUrl: "https://gitlab.example.com", token: "token", tokenType: "personal", headerName: "PRIVATE-TOKEN" } })), + GitLabClient, + buildGitLabTaskDescription: (item: any) => `${item.description?.trim() || "(no description)"}\n\nSource: ${item.webUrl ?? item.web_url}`, + buildGitLabTaskProvenance: ({ resourceType, item }: any) => ({ sourceIssue: { provider: "gitlab", repository: String(item.projectPath ?? item.project_id ?? "unknown"), externalIssueId: resourceType === "merge_request" ? `gitlab:mr:${item.project_id}:${item.id}` : String(item.id), issueNumber: item.iid, url: item.webUrl ?? item.web_url }, sourceMetadata: { provider: "gitlab", resourceType, iid: item.iid, webUrl: item.webUrl ?? item.web_url } }), + isGitLabAlreadyImported: (task: any, provenance: any) => task.description?.includes(provenance.sourceIssue.url) || task.sourceIssue?.externalIssueId === provenance.sourceIssue.externalIssueId, + }; +}); + +import { runTaskImportFromGitLab } from "../commands/task.js"; + +describe("fn task import-gitlab", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + mocks.getSettings.mockResolvedValue({ gitlabAuthToken: "token", gitlabInstanceUrl: "https://gitlab.example.com" }); + mocks.getGlobalSettings.mockResolvedValue({}); + mocks.listTasks.mockResolvedValue([]); + mocks.createTask.mockImplementation(async (input) => ({ id: "FN-001", ...input })); + fetchSpy = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify([{ id: 1, iid: 2, project_id: 3, title: "GitLab issue", description: null, webUrl: "https://gitlab.example.com/g/p/-/issues/2", web_url: "https://gitlab.example.com/g/p/-/issues/2", state: "opened", labels: [] }]), { status: 200 }))); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("imports GitLab project issues with gitlab provenance", async () => { + await runTaskImportFromGitLab("g/p", { resource: "project-issues", limit: 1 }); + expect(fetchSpy.mock.calls[0][0]).toContain("/projects/g%2Fp/issues?"); + expect(mocks.createTask).toHaveBeenCalledWith(expect.objectContaining({ + sourceIssue: expect.objectContaining({ provider: "gitlab", issueNumber: 2 }), + source: expect.objectContaining({ sourceType: "gitlab_import", sourceMetadata: expect.objectContaining({ resourceType: "project_issue" }) }), + })); + }); + + it("skips duplicates by GitLab source URL", async () => { + mocks.listTasks.mockResolvedValue([{ id: "FN-OLD", description: "Source: https://gitlab.example.com/g/p/-/issues/2" }]); + await runTaskImportFromGitLab("g/p", { resource: "project-issues", limit: 1 }); + expect(mocks.createTask).not.toHaveBeenCalled(); + }); + + it("uses group issue and merge request endpoints", async () => { + await runTaskImportFromGitLab("g", { resource: "group-issues", limit: 1 }); + expect(fetchSpy.mock.calls.at(-1)?.[0]).toContain("/groups/g/issues?"); + await runTaskImportFromGitLab("g/p", { resource: "merge-requests", limit: 1 }); + expect(fetchSpy.mock.calls.at(-1)?.[0]).toContain("/projects/g%2Fp/merge_requests?"); + }); +}); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index a58f522443..4c86cb6801 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -121,7 +121,7 @@ async function loadCommandHandlers() { const { runServe } = await import("./commands/serve.js"); const { runDaemon } = await import("./commands/daemon.js"); const { runDesktop } = await import("./commands/desktop.js"); - const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskDeps, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode } = await import("./commands/task.js"); + const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskDeps, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskImportFromGitLab, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode } = await import("./commands/task.js"); const { runPrCreate, runPrShow, runPrList, runPrRespond, runPrApprove, runPrRetry, runPrMerge, runPrClose, runPrAutomerge, runPrAutomergeCleanup } = await import("./commands/pr.js"); const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js"); const { runSettingsExport } = await import("./commands/settings-export.js"); @@ -168,6 +168,7 @@ async function loadCommandHandlers() { runTaskPause, runTaskUnpause, runTaskImportFromGitHub, + runTaskImportFromGitLab, runTaskDuplicate, runTaskArchive, runTaskUnarchive, @@ -334,6 +335,7 @@ Usage: fn task clear-node Clear a per-task node override fn task retry Retry a failed task (clears error, moves to todo) fn task import [opts] Import GitHub issues as tasks + fn task import-gitlab [opts] Import GitLab project issues, group issues, or merge requests PR: fn pr create [--title ] [--base <branch>] [--body <body>] [--draft] [--no-ai] [--reviewer <login>] @@ -676,6 +678,7 @@ async function main() { runTaskPause, runTaskUnpause, runTaskImportFromGitHub, + runTaskImportFromGitLab, runTaskDuplicate, runTaskArchive, runTaskUnarchive, @@ -1450,6 +1453,26 @@ async function main() { await runTaskRetry(id, projectName); break; } + case "import-gitlab": { + const target = args[2]; + if (!target) { + console.error("Usage: fn task import-gitlab <project-path-or-id|group-path-or-id> [options]"); + console.error("Options: --resource <project-issues|group-issues|merge-requests>, --limit <n>, --labels <labels>"); + process.exit(1); + } + const limitIndex = args.findIndex((arg) => arg === "--limit" || arg === "-l"); + const limit = limitIndex >= 0 && args[limitIndex + 1] ? parseInt(args[limitIndex + 1], 10) : 30; + const labelsIndex = args.findIndex((arg) => arg === "--labels" || arg === "-L"); + const labels = labelsIndex >= 0 && args[labelsIndex + 1] ? args[labelsIndex + 1].split(",").map((value) => value.trim()).filter(Boolean) : undefined; + const resourceIndex = args.findIndex((arg) => arg === "--resource" || arg === "-r"); + const resourceValue = resourceIndex >= 0 && args[resourceIndex + 1] ? args[resourceIndex + 1] : "project-issues"; + if (resourceValue !== "project-issues" && resourceValue !== "group-issues" && resourceValue !== "merge-requests") { + console.error("Invalid --resource. Expected project-issues, group-issues, or merge-requests."); + process.exit(1); + } + await runTaskImportFromGitLab(target, { limit, labels, resource: resourceValue }, projectName); + break; + } case "import": { const ownerRepo = args[2]; if (!ownerRepo) { diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 8f40f1c175..1e91f5793f 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -90,6 +90,8 @@ function formatTaskSource(task: { const issueUrl = getGitHubIssueUrl(task.sourceMetadata); return issueUrl ? `GitHub Import (${issueUrl})` : "GitHub Import"; } + case "gitlab_import": + return "GitLab Import"; case "research": { const context = getResearchSourceContext(task.sourceMetadata); return context ? `Research (${context})` : "Research"; @@ -1520,6 +1522,62 @@ export async function runTaskImportFromGitHub( console.log(); } +export type GitLabImportResource = "project-issues" | "group-issues" | "merge-requests"; + +async function createGitLabClientForStore(store: TaskStore): Promise<dashboard.GitLabClient> { + const projectSettings = await store.getSettings(); + const globalSettings = await store.getGlobalSettingsStore().getSettings(); + const auth = dashboard.resolveGitlabAuth({ projectSettings, globalSettings }); + if (!auth.ok) throw new Error(auth.message); + return new dashboard.GitLabClient(auth.auth); +} + +export async function runTaskImportFromGitLab( + target: string, + options: TaskImportOptions & { resource?: GitLabImportResource } = {}, + projectName?: string, +): Promise<void> { + const resource = options.resource ?? "project-issues"; + const store = await getStore(projectName); + const client = await createGitLabClientForStore(store); + console.log(`\n Importing GitLab ${resource} from ${target}...\n`); + const labelArray = options.labels ?? []; + const items = resource === "project-issues" + ? await client.listProjectIssues(target, { limit: options.limit, labels: labelArray }) + : resource === "group-issues" + ? await client.listGroupIssues(target, { limit: options.limit, labels: labelArray }) + : await client.listMergeRequests(target, { limit: options.limit, labels: labelArray }); + if (items.length === 0) { + console.log(" No GitLab resources found.\n"); + return; + } + const existingTasks = await store.listTasks({ slim: false, includeArchived: false }); + let created = 0; + let skipped = 0; + for (const item of items) { + const provenance = dashboard.buildGitLabTaskProvenance({ auth: client.auth, resourceType: resource === "merge-requests" ? "merge_request" : resource === "group-issues" ? "group_issue" : "project_issue", item, projectInput: resource !== "group-issues" ? target : undefined, groupInput: resource === "group-issues" ? target : undefined }); + if (existingTasks.some((task) => dashboard.isGitLabAlreadyImported(task, provenance))) { + console.log(` → Skipping #${item.iid}: already imported`); + skipped += 1; + continue; + } + const title = resource === "merge-requests" ? `Review MR !${item.iid}: ${item.title.slice(0, 180)}` : item.title.slice(0, 200); + const task = await store.createTask({ + title: title || undefined, + description: dashboard.buildGitLabTaskDescription(item), + column: "triage", + dependencies: [], + sourceIssue: provenance.sourceIssue, + source: { sourceType: "gitlab_import", sourceMetadata: provenance.sourceMetadata }, + }); + await store.logEntry(task.id, resource === "merge-requests" ? "Imported merge request from GitLab" : "Imported from GitLab", item.webUrl); + existingTasks.push(task); + created += 1; + console.log(` ✓ Created ${task.id}: ${task.title}`); + } + console.log(`\n ✓ Imported ${created} GitLab tasks${skipped > 0 ? ` (${skipped} skipped)` : ""}\n`); +} + export async function runTaskComment(id: string, message?: string, author = "user", projectName?: string) { const store = await getStore(projectName); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index d2aac1f466..7f577ea74b 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -1858,6 +1858,112 @@ export default function kbExtension(pi: ExtensionAPI) { }, }); + async function createGitLabClient(ctx: { cwd: string }) { + const store = await getStore(ctx.cwd); + const projectSettings = await store.getSettings(); + const globalSettings = await store.getGlobalSettingsStore().getSettings(); + const auth = dashboard.resolveGitlabAuth({ projectSettings, globalSettings }); + if (!auth.ok) throw new Error(auth.message); + return { store, client: new dashboard.GitLabClient(auth.auth) }; + } + + async function importGitLabItems(ctx: { cwd: string }, resourceType: dashboard.GitLabResourceType, target: string, items: Array<dashboard.GitLabIssue | dashboard.GitLabMergeRequest>) { + const { store, client } = await createGitLabClient(ctx); + const existingTasks = await store.listTasks({ slim: false, includeArchived: false }); + const createdTasks: Array<{ id: string; title: string }> = []; + for (const item of items) { + const provenance = dashboard.buildGitLabTaskProvenance({ auth: client.auth, resourceType, item, projectInput: resourceType !== "group_issue" ? target : undefined, groupInput: resourceType === "group_issue" ? target : undefined }); + if (existingTasks.some((task) => dashboard.isGitLabAlreadyImported(task, provenance))) continue; + const title = resourceType === "merge_request" ? `Review MR !${item.iid}: ${item.title.slice(0, 180)}` : item.title.slice(0, 200); + const task = await store.createTask({ title: title || undefined, description: dashboard.buildGitLabTaskDescription(item), column: "triage", dependencies: [], sourceIssue: provenance.sourceIssue, source: { sourceType: "gitlab_import", sourceMetadata: provenance.sourceMetadata } }); + await store.logEntry(task.id, resourceType === "merge_request" ? "Imported merge request from GitLab" : "Imported from GitLab", item.webUrl); + existingTasks.push(task); + createdTasks.push({ id: task.id, title: task.title || item.title }); + } + return createdTasks; + } + + pi.registerTool({ + name: "fn_task_browse_gitlab_project_issues", + label: "fn: Browse GitLab Project Issues", + description: "List GitLab project issues from the configured GitLab instance.", + promptSnippet: "Browse GitLab project issues", + parameters: Type.Object({ project: Type.String({ description: "GitLab project path or numeric ID" }), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })), labels: Type.Optional(Type.Array(Type.String())) }), + async execute(_id, params, _signal, _onUpdate, ctx) { + const { client } = await createGitLabClient(ctx); + const issues = await client.listProjectIssues(params.project, { limit: params.limit, labels: params.labels }); + return { content: [{ type: "text", text: issues.map((issue) => `#${issue.iid}: ${issue.title}\n${issue.webUrl}`).join("\n") || `No GitLab project issues found in ${params.project}.` }], details: { count: issues.length, issues } }; + }, + }); + + pi.registerTool({ + name: "fn_task_import_gitlab_project_issues", + label: "fn: Import GitLab Project Issues", + description: "Import GitLab project issues as Fusion tasks using configured GitLab HTTP API auth.", + promptSnippet: "Import GitLab project issues", + parameters: Type.Object({ project: Type.String({ description: "GitLab project path or numeric ID" }), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })), labels: Type.Optional(Type.Array(Type.String())) }), + async execute(_id, params, _signal, _onUpdate, ctx) { + const { client } = await createGitLabClient(ctx); + const issues = await client.listProjectIssues(params.project, { limit: params.limit, labels: params.labels }); + const createdTasks = await importGitLabItems(ctx, "project_issue", params.project, issues); + return { content: [{ type: "text", text: `Imported ${createdTasks.length} GitLab project issue tasks.` }], details: { createdTasks } }; + }, + }); + + pi.registerTool({ + name: "fn_task_browse_gitlab_group_issues", + label: "fn: Browse GitLab Group Issues", + description: "List GitLab group issues while preserving each issue's originating project identity.", + promptSnippet: "Browse GitLab group issues", + parameters: Type.Object({ group: Type.String({ description: "GitLab group path or numeric ID" }), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })), labels: Type.Optional(Type.Array(Type.String())) }), + async execute(_id, params, _signal, _onUpdate, ctx) { + const { client } = await createGitLabClient(ctx); + const issues = await client.listGroupIssues(params.group, { limit: params.limit, labels: params.labels }); + return { content: [{ type: "text", text: issues.map((issue) => `#${issue.iid}: ${issue.projectPath ?? issue.projectId} — ${issue.title}\n${issue.webUrl}`).join("\n") || `No GitLab group issues found in ${params.group}.` }], details: { count: issues.length, issues } }; + }, + }); + + pi.registerTool({ + name: "fn_task_import_gitlab_group_issues", + label: "fn: Import GitLab Group Issues", + description: "Import GitLab group issues as Fusion tasks using each issue's originating project identity.", + promptSnippet: "Import GitLab group issues", + parameters: Type.Object({ group: Type.String({ description: "GitLab group path or numeric ID" }), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })), labels: Type.Optional(Type.Array(Type.String())) }), + async execute(_id, params, _signal, _onUpdate, ctx) { + const { client } = await createGitLabClient(ctx); + const issues = await client.listGroupIssues(params.group, { limit: params.limit, labels: params.labels }); + const createdTasks = await importGitLabItems(ctx, "group_issue", params.group, issues); + return { content: [{ type: "text", text: `Imported ${createdTasks.length} GitLab group issue tasks.` }], details: { createdTasks } }; + }, + }); + + pi.registerTool({ + name: "fn_task_browse_gitlab_merge_requests", + label: "fn: Browse GitLab Merge Requests", + description: "List GitLab project merge requests from the configured GitLab instance.", + promptSnippet: "Browse GitLab merge requests", + parameters: Type.Object({ project: Type.String({ description: "GitLab project path or numeric ID" }), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })), labels: Type.Optional(Type.Array(Type.String())) }), + async execute(_id, params, _signal, _onUpdate, ctx) { + const { client } = await createGitLabClient(ctx); + const mergeRequests = await client.listMergeRequests(params.project, { limit: params.limit, labels: params.labels }); + return { content: [{ type: "text", text: mergeRequests.map((mr) => `!${mr.iid}: ${mr.title}\n${mr.webUrl}`).join("\n") || `No GitLab merge requests found in ${params.project}.` }], details: { count: mergeRequests.length, mergeRequests } }; + }, + }); + + pi.registerTool({ + name: "fn_task_import_gitlab_merge_requests", + label: "fn: Import GitLab Merge Requests", + description: "Import GitLab project merge requests as Fusion review tasks using configured GitLab HTTP API auth.", + promptSnippet: "Import GitLab merge requests", + parameters: Type.Object({ project: Type.String({ description: "GitLab project path or numeric ID" }), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })), labels: Type.Optional(Type.Array(Type.String())) }), + async execute(_id, params, _signal, _onUpdate, ctx) { + const { client } = await createGitLabClient(ctx); + const mergeRequests = await client.listMergeRequests(params.project, { limit: params.limit, labels: params.labels }); + const createdTasks = await importGitLabItems(ctx, "merge_request", params.project, mergeRequests); + return { content: [{ type: "text", text: `Imported ${createdTasks.length} GitLab merge request tasks.` }], details: { createdTasks } }; + }, + }); + // ── fn_task_plan ──────────────────────────────────────────────── // Create a task via AI-guided planning mode diff --git a/packages/core/src/__tests__/usage-events.test.ts b/packages/core/src/__tests__/usage-events.test.ts index 6dfc5a62ba..332a3d1fb0 100644 --- a/packages/core/src/__tests__/usage-events.test.ts +++ b/packages/core/src/__tests__/usage-events.test.ts @@ -135,6 +135,8 @@ describe("usage_events", () => { ["fn_task_import_github", "github"], ["fn_task_import_github_issue", "github"], ["fn_task_browse_github_issues", "github"], + ["fn_task_import_gitlab_project_issues", "gitlab"], + ["fn_task_browse_gitlab_merge_requests", "gitlab"], ["fn_workflow_create", "workflow"], ["fn_review_spec", "workflow"], ["mcp__server__search", "read"], diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4e502f4a24..4c77063087 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1865,6 +1865,7 @@ export type SourceType = | "cron" | "workflow_step" | "github_import" + | "gitlab_import" | "task_refine" | "task_duplicate" | "cli" @@ -6376,6 +6377,12 @@ export const AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES: Record< "fn_delegate_task", "fn_task_import_github", "fn_task_import_github_issue", + "fn_task_import_gitlab_project_issues", + "fn_task_import_gitlab_group_issues", + "fn_task_import_gitlab_merge_requests", + "fn_task_browse_gitlab_project_issues", + "fn_task_browse_gitlab_group_issues", + "fn_task_browse_gitlab_merge_requests", "fn_spawn_agent", "fn_update_agent_config", "fn_task_update", diff --git a/packages/core/src/usage-events.ts b/packages/core/src/usage-events.ts index fbce4c90e1..b8ca5ea0a4 100644 --- a/packages/core/src/usage-events.ts +++ b/packages/core/src/usage-events.ts @@ -112,6 +112,9 @@ export function categorizeToolName(toolName: string | null | undefined): string if (name.startsWith("fn_task_import_github") || name.startsWith("fn_task_browse_github")) { return "github"; } + if (name.startsWith("fn_task_import_gitlab") || name.startsWith("fn_task_browse_gitlab")) { + return "gitlab"; + } if (name === "fn_web_fetch") return "network"; if (name.startsWith("fn_secret_")) return "secrets"; if (name.startsWith("fn_skills_")) return "skills"; diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 257f4a1e54..e7a4ac4fca 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -2569,6 +2569,58 @@ export function apiImportGitHubPull(owner: string, repo: string, prNumber: numbe }); } +// --- GitLab Import API --- + +export interface GitLabImportItem { + resourceKind: "project_issue" | "group_issue" | "merge_request"; + id?: number; + iid: number; + projectId?: number; + projectPath?: string; + groupId?: number | string; + groupPath?: string; + title: string; + description: string | null; + webUrl: string; + state: string; + author?: { username?: string; name?: string } | null; + labels: string[]; + createdAt?: string; + updatedAt?: string; + commentsCount?: number; + sourceBranch?: string; + targetBranch?: string; + draft?: boolean; +} + +export function apiFetchGitLabProjectIssues(project: string, limit?: number, labels?: string[], state?: string): Promise<GitLabImportItem[]> { + return api<GitLabImportItem[]>("/gitlab/project/issues/fetch", { method: "POST", body: JSON.stringify({ project, limit, labels, state }) }); +} + +export function apiFetchGitLabGroupIssues(group: string, limit?: number, labels?: string[], state?: string): Promise<GitLabImportItem[]> { + return api<GitLabImportItem[]>("/gitlab/group/issues/fetch", { method: "POST", body: JSON.stringify({ group, limit, labels, state }) }); +} + +export function apiFetchGitLabMergeRequests(project: string, limit?: number, labels?: string[], state?: string): Promise<GitLabImportItem[]> { + return api<GitLabImportItem[]>("/gitlab/merge-requests/fetch", { method: "POST", body: JSON.stringify({ project, limit, labels, state }) }); +} + +export function apiImportGitLabProjectIssue(project: string, iid: number, projectId?: string): Promise<Task> { + return api<Task>(withProjectId("/gitlab/project/issues/import", projectId), { method: "POST", body: JSON.stringify({ project, iid }) }); +} + +export function apiImportGitLabGroupIssue(issue: GitLabImportItem, group?: string, projectId?: string): Promise<Task> { + return api<Task>(withProjectId("/gitlab/group/issues/import", projectId), { method: "POST", body: JSON.stringify({ issue, group }) }); +} + +export function apiImportGitLabMergeRequest(project: string, iid: number, projectId?: string): Promise<Task> { + return api<Task>(withProjectId("/gitlab/merge-requests/import", projectId), { method: "POST", body: JSON.stringify({ project, iid }) }); +} + +export function apiBatchImportGitLab(items: Array<Record<string, unknown>>, projectId?: string): Promise<{ results: Array<{ success: boolean; taskId?: string; error?: string; iid?: number }> }> { + return api<{ results: Array<{ success: boolean; taskId?: string; error?: string; iid?: number }> }>(withProjectId("/gitlab/batch-import", projectId), { method: "POST", body: JSON.stringify({ items }) }); +} + // --- Git Remote Detection API --- /** Git remote info returned by the remotes endpoint */ diff --git a/packages/dashboard/app/components/GitHubImportModal.css b/packages/dashboard/app/components/GitHubImportModal.css index 13cf007226..ad5ebb7a95 100644 --- a/packages/dashboard/app/components/GitHubImportModal.css +++ b/packages/dashboard/app/components/GitHubImportModal.css @@ -1613,3 +1613,44 @@ body. Let the content take its full intrinsic height and hand vertical scrolling display: block; } } + +/* +FNXC:GitLabImport 2026-07-02-00:00: +FN-7424 keeps GitLab project issue, group issue, and merge request imports inside the existing Import Tasks surface. Use the established tab, toolbar, list, and preview classes so desktop and mobile affordances do not create a second navigation destination or hidden button shell. +*/ +.github-import-provider { + display: flex; + gap: var(--spacing-2); + padding: var(--spacing-3) var(--spacing-4) 0; +} + +.github-import-gitlab { + display: flex; + flex-direction: column; + gap: var(--spacing-3); + min-height: 0; + flex: 1; +} + +.github-import-gitlab__workspace { + display: grid; + grid-template-columns: minmax(0, var(--import-list-width, 16rem)) minmax(0, 1fr); + gap: var(--spacing-3); + min-height: 0; + flex: 1; +} + +.github-import-gitlab .github-import-preview-pane { + min-width: 0; +} + +@media (max-width: 768px) { + .github-import-provider, + .github-import-gitlab .github-import-toolbar { + flex-wrap: wrap; + } + + .github-import-gitlab__workspace { + grid-template-columns: 1fr; + } +} diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index a1f272f6b0..612082ec4e 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -11,6 +11,12 @@ import { apiFetchGitHubIssueDetail, apiCloseGitHubIssue, apiImportGitHubPull, + apiFetchGitLabProjectIssues, + apiFetchGitLabGroupIssues, + apiFetchGitLabMergeRequests, + apiImportGitLabProjectIssue, + apiImportGitLabGroupIssue, + apiImportGitLabMergeRequest, fetchGitRemotes, type GitHubIssue, type GitHubPull, @@ -18,6 +24,7 @@ import { type GitHubIssueDetail, type GitHubCommentDetail, type GitRemote, + type GitLabImportItem, } from "../api"; import { Loader2, RefreshCw, ArrowLeft, GitPullRequest, CircleDot, ChevronUp, ChevronDown, Bot, User } from "lucide-react"; import { GithubIcon } from "./GithubIcon"; @@ -62,6 +69,8 @@ const GITHUB_IMPORT_LIST_PANE_KEYBOARD_STEP = 16; const GITHUB_IMPORT_LIST_WIDTH_STORAGE_KEY = "kb-dashboard-github-import-list-width"; type TabType = "issues" | "pulls"; +type ImportProvider = "github" | "gitlab"; +type GitLabResourceTab = "project_issue" | "group_issue" | "merge_request"; /** * Clamp the list-pane width to [MIN, min(MAX, container * MAX_RATIO)]. @@ -313,6 +322,12 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, const [repo, setRepo] = useState(""); const [labels, setLabels] = useState(""); const [loading, setLoading] = useState(false); + const [provider, setProvider] = useState<ImportProvider>("github"); + const [gitlabResource, setGitlabResource] = useState<GitLabResourceTab>("project_issue"); + const [gitlabProject, setGitlabProject] = useState(""); + const [gitlabGroup, setGitlabGroup] = useState(""); + const [gitlabItems, setGitlabItems] = useState<GitLabImportItem[]>([]); + const [selectedGitlabKey, setSelectedGitlabKey] = useState<string | null>(null); // Tab state const [activeTab, setActiveTab] = useState<TabType>("issues"); @@ -417,6 +432,10 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, if (prMatch) { importedUrls.add(prMatch[1]); } + const gitlabMatch = task.description.match(/Source: (https?:\/\/[^\s]+\/-(?:\/issues|\/merge_requests)\/\d+)/); + if (gitlabMatch) { + importedUrls.add(gitlabMatch[1]); + } } // Reset state when modal opens and fetch remotes @@ -425,6 +444,12 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, setOwner(""); setRepo(""); setLabels(""); + setProvider("github"); + setGitlabResource("project_issue"); + setGitlabProject(""); + setGitlabGroup(""); + setGitlabItems([]); + setSelectedGitlabKey(null); setIssues([]); setSelectedIssueNumber(null); setPulls([]); @@ -560,8 +585,62 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, } }, [owner, repo]); + const selectedGitlabItem = gitlabItems.find((item) => `${item.resourceKind}:${item.projectId ?? item.projectPath ?? ""}:${item.iid}` === selectedGitlabKey) ?? null; + + const handleLoadGitLab = useCallback(async () => { + const project = gitlabProject.trim(); + const group = gitlabGroup.trim(); + if ((gitlabResource === "project_issue" || gitlabResource === "merge_request") && !project) { + setError(t("git.gitlabProjectRequired", "GitLab project path or ID is required")); + return; + } + if (gitlabResource === "group_issue" && !group) { + setError(t("git.gitlabGroupRequired", "GitLab group path or ID is required")); + return; + } + setLoading(true); + setError(null); + setGitlabItems([]); + setSelectedGitlabKey(null); + try { + const labelArray = labels.split(",").map((label) => label.trim()).filter(Boolean); + const fetched = gitlabResource === "project_issue" + ? await apiFetchGitLabProjectIssues(project, 30, labelArray.length > 0 ? labelArray : undefined) + : gitlabResource === "group_issue" + ? await apiFetchGitLabGroupIssues(group, 30, labelArray.length > 0 ? labelArray : undefined) + : await apiFetchGitLabMergeRequests(project, 30, labelArray.length > 0 ? labelArray : undefined); + setGitlabItems(fetched); + if (fetched.length === 0) setIsIssuesEmptyState(true); + } catch (err) { + setError(getErrorMessage(err) || t("git.failedToFetchGitlab", "Failed to fetch GitLab resources")); + } finally { + setLoading(false); + } + }, [gitlabProject, gitlabGroup, gitlabResource, labels, t]); + + const handleImportGitLab = useCallback(async () => { + if (!selectedGitlabItem) return; + setImporting(true); + setError(null); + try { + const task = gitlabResource === "project_issue" + ? await apiImportGitLabProjectIssue(gitlabProject.trim(), selectedGitlabItem.iid, projectId) + : gitlabResource === "group_issue" + ? await apiImportGitLabGroupIssue(selectedGitlabItem, gitlabGroup.trim(), projectId) + : await apiImportGitLabMergeRequest(gitlabProject.trim(), selectedGitlabItem.iid, projectId); + onImport(task); + setSelectedGitlabKey(null); + if (isMobile && mobileView === "preview") setMobileView("list"); + } catch (err) { + setError(getErrorMessage(err) || t("git.failedToImportGitlab", "Failed to import GitLab resource")); + } finally { + setImporting(false); + } + }, [selectedGitlabItem, gitlabResource, gitlabProject, gitlabGroup, projectId, onImport, isMobile, mobileView, t]); + // Auto-load data when owner and repo are set and valid useEffect(() => { + if (provider !== "github") return; if (!isOpen) return; if (!owner.trim() || !repo.trim()) return; if (loading || importing) return; @@ -584,7 +663,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, } else { handleLoadPulls(); } - }, [owner, repo, labels, activeTab, isOpen, loading, importing, handleLoad, handleLoadPulls]); + }, [provider, owner, repo, labels, activeTab, isOpen, loading, importing, handleLoad, handleLoadPulls]); // Handle escape key // FNXC:RightDockEmbedding 2026-06-22-00:00: Escape-to-close is a modal-only affordance; embedded mode has no dismiss. @@ -996,6 +1075,12 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, )} <div className="modal-body github-import-modal__body"> + <div className="github-import-provider" role="group" aria-label={t("git.providerAriaLabel", "Import provider")}> + <button type="button" className={`github-import-tab ${provider === "github" ? "active" : ""}`} aria-pressed={provider === "github"} onClick={() => setProvider("github")} disabled={loading || importing}>GitHub</button> + <button type="button" className={`github-import-tab ${provider === "gitlab" ? "active" : ""}`} aria-pressed={provider === "gitlab"} onClick={() => setProvider("gitlab")} disabled={loading || importing}>GitLab</button> + </div> + {provider === "github" ? ( + <> {/* Tab Navigation */} <div className="github-import-tabs" role="tablist" aria-label={t("git.importTypeAriaLabel", "Import type")}> <button @@ -1527,6 +1612,56 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, </div> </section> </div> + </> + ) : ( + <div className="github-import-gitlab" data-testid="gitlab-import-panel"> + <div className="github-import-tabs" role="tablist" aria-label={t("git.gitlabResourceAriaLabel", "GitLab resource type")}> + {(["project_issue", "group_issue", "merge_request"] as GitLabResourceTab[]).map((resource) => ( + <button key={resource} type="button" role="tab" aria-selected={gitlabResource === resource} className={`github-import-tab ${gitlabResource === resource ? "active" : ""}`} onClick={() => { setGitlabResource(resource); setGitlabItems([]); setSelectedGitlabKey(null); }} disabled={loading || importing}> + {resource === "project_issue" ? t("git.gitlabProjectIssues", "Project issues") : resource === "group_issue" ? t("git.gitlabGroupIssues", "Group issues") : t("git.gitlabMergeRequests", "Merge requests")} + </button> + ))} + </div> + <div className="github-import-toolbar" role="toolbar" aria-label={t("git.gitlabToolbarAriaLabel", "GitLab import controls")}> + {gitlabResource !== "group_issue" ? ( + <input className="input" value={gitlabProject} onChange={(event) => setGitlabProject(event.target.value)} placeholder={t("git.gitlabProjectPlaceholder", "group/subgroup/project or numeric ID")} aria-label={t("git.gitlabProjectLabel", "GitLab project path or ID")} disabled={loading || importing} /> + ) : ( + <input className="input" value={gitlabGroup} onChange={(event) => setGitlabGroup(event.target.value)} placeholder={t("git.gitlabGroupPlaceholder", "group/subgroup or numeric ID")} aria-label={t("git.gitlabGroupLabel", "GitLab group path or ID")} disabled={loading || importing} /> + )} + <input className="input" value={labels} onChange={(event) => setLabels(event.target.value)} placeholder={t("git.filterByLabelsPlaceholder", "Filter: bug,enhancement…")} aria-label={t("git.filterGitLabByLabels", "Filter GitLab resources by labels")} disabled={loading || importing} /> + <button type="button" className="btn btn-primary" onClick={handleLoadGitLab} disabled={loading || importing || (gitlabResource === "group_issue" ? !gitlabGroup.trim() : !gitlabProject.trim())}> + {loading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />} + {t("git.load", "Load")} + </button> + </div> + {error && <div className="github-import-state github-import-state--error" data-testid="gitlab-import-error"><strong>{t("git.gitlabError", "GitLab import unavailable")}</strong><span>{error}</span></div>} + {gitlabItems.length === 0 && !loading && !error ? <div className="github-import-state github-import-state--idle" data-testid="gitlab-import-empty"><strong>{t("git.gitlabNoResources", "No GitLab resources loaded")}</strong><span>{t("git.gitlabLoadHint", "Enter a project or group and load resources from the configured GitLab instance.")}</span></div> : null} + <div className="github-import-gitlab__workspace"> + <div className="issues-list" aria-live="polite"> + {gitlabItems.map((item) => { + const key = `${item.resourceKind}:${item.projectId ?? item.projectPath ?? ""}:${item.iid}`; + const imported = importedUrls.has(item.webUrl); + return ( + <button key={key} type="button" className={`issue-item ${selectedGitlabKey === key ? "selected" : ""} ${imported ? "imported" : ""}`} onClick={() => { setSelectedGitlabKey(key); if (isMobile) setMobileView("preview"); }}> + <div className="issue-title">{item.resourceKind === "merge_request" ? "!" : "#"}{item.iid} {item.title}</div> + <div className="issue-meta"><span>{item.projectPath ?? item.projectId}</span><span>{item.state}</span>{imported && <span>{t("git.alreadyImported", "Imported")}</span>}</div> + </button> + ); + })} + </div> + <div className="github-import-preview-pane"> + {selectedGitlabItem ? ( + <div className="issue-preview" data-testid="gitlab-import-preview-card"> + <h4>{selectedGitlabItem.resourceKind === "merge_request" ? "!" : "#"}{selectedGitlabItem.iid} {selectedGitlabItem.title}</h4> + <div className="preview-meta-row"><span className={`preview-state-badge preview-state-badge--${selectedGitlabItem.state}`}>{selectedGitlabItem.state}</span><a href={selectedGitlabItem.webUrl} target="_blank" rel="noopener noreferrer">{t("git.openSource", "Open source")}</a></div> + <MailboxMessageContent className="preview-body preview-body--markdown" content={selectedGitlabItem.description?.trim() || t("git.noDescription", "(no description)")} testId="gitlab-import-preview-body" /> + <button type="button" className="btn btn-primary" onClick={handleImportGitLab} disabled={importing || importedUrls.has(selectedGitlabItem.webUrl)}>{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}</button> + </div> + ) : <div className="github-import-state github-import-state--idle" data-testid="gitlab-import-preview-empty"><strong>{t("git.gitlabNoSelection", "No GitLab resource selected")}</strong><span>{t("git.gitlabNoSelectionHint", "Choose a resource from the list to preview it.")}</span></div>} + </div> + </div> + </div> + )} </div> {/* @@ -1541,9 +1676,11 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, </button> <button className="btn btn-primary" - onClick={handleImport} + onClick={provider === "gitlab" ? handleImportGitLab : handleImport} disabled={ - (activeTab === "issues" ? selectedIssueNumber === null : selectedPullNumber === null) || importing + provider === "gitlab" + ? selectedGitlabItem === null || importing || (selectedGitlabItem ? importedUrls.has(selectedGitlabItem.webUrl) : false) + : (activeTab === "issues" ? selectedIssueNumber === null : selectedPullNumber === null) || importing } > {importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")} diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx index a39cc521b6..76cfd97202 100644 --- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx @@ -9,6 +9,8 @@ import { apiFetchGitHubIssueDetail, apiCloseGitHubIssue, apiImportGitHubPull, + apiFetchGitLabProjectIssues, + apiImportGitLabProjectIssue, fetchGitRemotes, } from "../../api"; import type { Task } from "@fusion/core"; @@ -28,6 +30,12 @@ vi.mock("../../api", async (importOriginal) => { apiFetchGitHubIssueDetail: vi.fn(), apiCloseGitHubIssue: vi.fn(), apiImportGitHubPull: vi.fn(), + apiFetchGitLabProjectIssues: vi.fn(), + apiFetchGitLabGroupIssues: vi.fn(), + apiFetchGitLabMergeRequests: vi.fn(), + apiImportGitLabProjectIssue: vi.fn(), + apiImportGitLabGroupIssue: vi.fn(), + apiImportGitLabMergeRequest: vi.fn(), fetchGitRemotes: vi.fn(), }; }); @@ -150,12 +158,16 @@ describe("GitHubImportModal", () => { vi.mocked(apiFetchGitHubIssueDetail).mockReset(); vi.mocked(apiCloseGitHubIssue).mockReset(); vi.mocked(apiImportGitHubPull).mockReset(); + vi.mocked(apiFetchGitLabProjectIssues).mockReset(); + vi.mocked(apiImportGitLabProjectIssue).mockReset(); // Set default mock for apiFetchGitHubIssues to return empty array (prevents undefined issues state) vi.mocked(apiFetchGitHubIssues).mockResolvedValue([]); vi.mocked(apiFetchGitHubPulls).mockResolvedValue([]); vi.mocked(apiFetchGitHubPullDetail).mockResolvedValue({ comments: [], checks: [] }); vi.mocked(apiFetchGitHubIssueDetail).mockResolvedValue({ comments: [] }); vi.mocked(apiCloseGitHubIssue).mockResolvedValue(undefined); + vi.mocked(apiFetchGitLabProjectIssues).mockResolvedValue([]); + vi.mocked(apiImportGitLabProjectIssue).mockResolvedValue(mockTask); onClose.mockReset(); onImport.mockReset(); }); @@ -169,6 +181,27 @@ describe("GitHubImportModal", () => { }); }); + it("fetches, previews, and imports GitLab project issues", async () => { + vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]); + vi.mocked(apiFetchGitLabProjectIssues).mockResolvedValueOnce([ + { resourceKind: "project_issue", id: 1, iid: 2, projectId: 3, projectPath: "group/project", title: "GitLab bug", description: "Body", webUrl: "https://gitlab.example.com/group/project/-/issues/2", state: "opened", labels: ["bug"] }, + ]); + vi.mocked(apiImportGitLabProjectIssue).mockResolvedValueOnce({ ...mockTask, id: "FN-099", title: "GitLab bug", description: "Body\n\nSource: https://gitlab.example.com/group/project/-/issues/2" }); + + render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />); + fireEvent.click(await screen.findByRole("button", { name: "GitLab" })); + fireEvent.change(screen.getByLabelText("GitLab project path or ID"), { target: { value: "group/project" } }); + fireEvent.click(screen.getByRole("button", { name: /Load/ })); + + expect(await screen.findByText(/#2 GitLab bug/)).toBeTruthy(); + fireEvent.click(screen.getByText(/#2 GitLab bug/)); + expect(await screen.findByTestId("gitlab-import-preview-body")).toHaveTextContent("Body"); + fireEvent.click(screen.getAllByRole("button", { name: "Import" })[0]); + + await waitFor(() => expect(apiImportGitLabProjectIssue).toHaveBeenCalledWith("group/project", 2, undefined)); + expect(onImport).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-099" })); + }); + it("does not render when isOpen is false", () => { render(<GitHubImportModal isOpen={false} onClose={onClose} onImport={onImport} tasks={[]} />); expect(screen.queryByText("Import from GitHub")).toBeNull(); diff --git a/packages/dashboard/src/__tests__/gitlab.test.ts b/packages/dashboard/src/__tests__/gitlab.test.ts new file mode 100644 index 0000000000..fb8fb2049b --- /dev/null +++ b/packages/dashboard/src/__tests__/gitlab.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import { GITLAB_AUTH_HEADER_NAME, type ResolvedGitlabAuth } from "../gitlab-auth.js"; +import { buildGitLabTaskDescription, buildGitLabTaskProvenance, encodeGitLabPathId, GitLabClient, isGitLabAlreadyImported } from "../gitlab.js"; + +const auth: ResolvedGitlabAuth = { + apiBaseUrl: "https://gitlab.example.com/api/v4", + webBaseUrl: "https://gitlab.example.com", + token: "SECRET_TOKEN", + tokenType: "personal", + headerName: GITLAB_AUTH_HEADER_NAME, +}; + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +describe("GitLabClient", () => { + it("encodes path identifiers with slashes but preserves numeric identifiers", () => { + expect(encodeGitLabPathId("group/sub/project")).toBe("group%2Fsub%2Fproject"); + expect(encodeGitLabPathId(123)).toBe("123"); + }); + + it("sends PRIVATE-TOKEN auth without putting tokens in errors", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse([{ iid: 1, title: "Issue", description: null, web_url: "https://gitlab.example.com/g/p/-/issues/1", state: "opened", labels: [] }])); + const client = new GitLabClient(auth, fetchImpl as any); + await client.listProjectIssues("g/p", { limit: 1 }); + expect(fetchImpl.mock.calls[0][0]).toBe("https://gitlab.example.com/api/v4/projects/g%2Fp/issues?per_page=1&page=1&state=opened"); + expect(fetchImpl.mock.calls[0][1].headers["PRIVATE-TOKEN"]).toBe("SECRET_TOKEN"); + + fetchImpl.mockResolvedValueOnce(jsonResponse({ message: "bad SECRET_TOKEN" }, 401)); + await expect(client.listProjectIssues("g/p", { limit: 1 })).rejects.toThrow("GitLab authentication failed"); + }); + + it("normalizes project issues, group issues, and merge requests", async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(jsonResponse([{ id: 10, iid: 2, project_id: 7, title: "Issue", description: "Body", web_url: "https://gitlab.example.com/g/p/-/issues/2", state: "opened", labels: ["bug"], author: { username: "ana" }, user_notes_count: 3 }])) + .mockResolvedValueOnce(jsonResponse([{ id: 11, iid: 3, project_id: 8, title: "Group issue", description: null, web_url: "https://gitlab.example.com/g/q/-/issues/3", state: "closed", labels: [] }])) + .mockResolvedValueOnce(jsonResponse([{ id: 12, iid: 4, project_id: 7, title: "MR", description: "Review", web_url: "https://gitlab.example.com/g/p/-/merge_requests/4", state: "opened", labels: ["backend"], source_branch: "feat", target_branch: "main", draft: false }])); + const client = new GitLabClient(auth, fetchImpl as any); + expect(await client.listProjectIssues(7, { limit: 1 })).toMatchObject([{ resourceKind: "project_issue", iid: 2, projectId: 7, projectPath: "g/p", commentsCount: 3 }]); + expect(await client.listGroupIssues("g", { limit: 1 })).toMatchObject([{ resourceKind: "group_issue", iid: 3, projectPath: "g/q", groupPath: "g" }]); + expect(await client.listMergeRequests("g/p", { limit: 1 })).toMatchObject([{ resourceKind: "merge_request", iid: 4, projectPath: "g/p", sourceBranch: "feat", targetBranch: "main" }]); + }); +}); + +describe("GitLab provenance helpers", () => { + it("build gitlab_import source metadata and duplicate keys", () => { + const item = { resourceKind: "merge_request" as const, id: 50, iid: 9, projectId: 7, projectPath: "g/p", title: "MR", description: "", webUrl: "https://gitlab.example.com/g/p/-/merge_requests/9", state: "opened", labels: [], sourceBranch: "feat", targetBranch: "main" }; + const provenance = buildGitLabTaskProvenance({ auth, resourceType: "merge_request", item, projectInput: "g/p" }); + expect(provenance.sourceIssue).toMatchObject({ provider: "gitlab", repository: "g/p", externalIssueId: "gitlab:mr:7:50", issueNumber: 9 }); + expect(provenance.sourceMetadata).toMatchObject({ provider: "gitlab", resourceType: "merge_request", mergeRequestIid: 9, sourceBranch: "feat" }); + expect(buildGitLabTaskDescription(item)).toBe("(no description)\n\nSource: https://gitlab.example.com/g/p/-/merge_requests/9"); + expect(isGitLabAlreadyImported({ description: "x", sourceIssue: provenance.sourceIssue, source: { sourceType: "gitlab_import", sourceMetadata: {} } }, provenance)).toBe(true); + expect(isGitLabAlreadyImported({ description: `Source: ${item.webUrl}`, source: undefined }, provenance)).toBe(true); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-gitlab.test.ts b/packages/dashboard/src/__tests__/routes-gitlab.test.ts new file mode 100644 index 0000000000..5c113cafe4 --- /dev/null +++ b/packages/dashboard/src/__tests__/routes-gitlab.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment node + +import express from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { registerGitLabRoutes } from "../routes/register-gitlab.js"; +import { GITLAB_AUTH_HEADER_NAME } from "../gitlab-auth.js"; +import type { ApiRoutesContext } from "../routes/types.js"; +import { request } from "../test-request.js"; + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +function buildApp(fetchImpl = vi.fn()) { + const tasks: any[] = []; + const store: any = { + getSettings: vi.fn().mockResolvedValue({ gitlabAuthToken: "token", gitlabInstanceUrl: "https://gitlab.example.com" }), + getGlobalSettingsStore: () => ({ getSettings: vi.fn().mockResolvedValue({}) }), + listTasks: vi.fn().mockResolvedValue(tasks), + createTask: vi.fn(async (input) => { + const task = { id: `FN-${String(tasks.length + 1).padStart(3, "0")}`, ...input, log: [] }; + tasks.push(task); + return task; + }), + logEntry: vi.fn(), + }; + const app = express(); + app.use(express.json()); + const ctx: ApiRoutesContext = { + router: express.Router(), + store, + runtimeLogger: {} as any, + planningLogger: {} as any, + chatLogger: {} as any, + getProjectIdFromRequest: () => undefined, + getScopedStore: async () => store, + getProjectContext: async () => ({ store, engine: undefined, projectId: undefined }), + prioritizeProjectsForCurrentDirectory: (projects) => projects, + emitRemoteRouteDiagnostic: () => {}, + emitAuthSyncAuditLog: () => {}, + parseScopeParam: () => undefined, + resolveAutomationStore: () => ({} as any), + resolveRoutineStore: () => ({} as any), + resolveRoutineRunner: () => ({} as any), + registerDispose: () => {}, + dispose: () => {}, + rethrowAsApiError: (error) => { throw error; }, + }; + vi.stubGlobal("fetch", fetchImpl); + registerGitLabRoutes(ctx); + app.use("/api", ctx.router); + app.use((err: any, _req: any, res: any, _next: any) => res.status(err.statusCode ?? err.status ?? 500).json({ error: err.message, ...err.details })); + return { app, store, tasks, fetchImpl }; +} + +describe("GitLab import routes", () => { + beforeEach(() => vi.unstubAllGlobals()); + + it("fetches project issues with encoded path IDs and token auth", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse([{ id: 1, iid: 2, project_id: 3, title: "Bug", description: null, web_url: "https://gitlab.example.com/g/p/-/issues/2", state: "opened", labels: [] }])); + const { app } = buildApp(fetchImpl); + const res = await request(app, "POST", "/api/gitlab/project/issues/fetch", JSON.stringify({ project: "g/p", limit: 1 }), { "Content-Type": "application/json" }); + expect(res.status).toBe(200); + expect((res.body as any[])[0]).toMatchObject({ resourceKind: "project_issue", iid: 2 }); + expect(fetchImpl.mock.calls[0][0]).toContain("/projects/g%2Fp/issues?"); + expect(fetchImpl.mock.calls[0][1].headers[GITLAB_AUTH_HEADER_NAME]).toBe("token"); + }); + + it("imports project issues with gitlab provenance and rejects duplicates", async () => { + const fetchImpl = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({ id: 1, iid: 2, project_id: 3, title: "Bug", description: "Body", web_url: "https://gitlab.example.com/g/p/-/issues/2", state: "opened", labels: [] }))); + const { app, store } = buildApp(fetchImpl); + const first = await request(app, "POST", "/api/gitlab/project/issues/import", JSON.stringify({ project: 3, iid: 2 }), { "Content-Type": "application/json" }); + expect(first.status).toBe(201); + const created = store.createTask.mock.calls[0][0]; + expect(created.source).toMatchObject({ sourceType: "gitlab_import", sourceMetadata: { provider: "gitlab", resourceType: "project_issue", iid: 2 } }); + expect(created.sourceIssue).toMatchObject({ provider: "gitlab", issueNumber: 2, url: "https://gitlab.example.com/g/p/-/issues/2" }); + const dup = await request(app, "POST", "/api/gitlab/project/issues/import", JSON.stringify({ project: 3, iid: 2 }), { "Content-Type": "application/json" }); + expect(dup.status).toBe(409); + expect((dup.body as any).existingTaskId).toBe("FN-001"); + }); + + it("imports group issues from selected row and merge requests", async () => { + const { app, store } = buildApp(vi.fn().mockResolvedValue(jsonResponse({ id: 9, iid: 5, project_id: 4, title: "MR", description: null, web_url: "https://gitlab.example.com/g/p/-/merge_requests/5", state: "opened", labels: [], source_branch: "feat", target_branch: "main" }))); + const group = await request(app, "POST", "/api/gitlab/group/issues/import", JSON.stringify({ group: "g", issue: { resourceKind: "group_issue", id: 2, iid: 7, projectId: 8, projectPath: "g/p", title: "Group", description: null, webUrl: "https://gitlab.example.com/g/p/-/issues/7", state: "opened", labels: [] } }), { "Content-Type": "application/json" }); + expect(group.status).toBe(201); + const mr = await request(app, "POST", "/api/gitlab/merge-requests/import", JSON.stringify({ project: "g/p", iid: 5 }), { "Content-Type": "application/json" }); + expect(mr.status).toBe(201); + expect(store.createTask.mock.calls[1][0].source.sourceMetadata).toMatchObject({ resourceType: "merge_request", mergeRequestIid: 5 }); + }); + + it("returns actionable auth/config errors without token values", async () => { + const { app, store } = buildApp(vi.fn()); + store.getSettings.mockResolvedValueOnce({ gitlabAuthToken: "" }); + const res = await request(app, "POST", "/api/gitlab/project/issues/fetch", JSON.stringify({ project: "g/p" }), { "Content-Type": "application/json" }); + expect(res.status).toBe(401); + expect(JSON.stringify(res.body)).toContain("GitLab auth requires"); + expect(JSON.stringify(res.body)).not.toContain("token123"); + }); +}); diff --git a/packages/dashboard/src/gitlab.ts b/packages/dashboard/src/gitlab.ts new file mode 100644 index 0000000000..94b26ea9c7 --- /dev/null +++ b/packages/dashboard/src/gitlab.ts @@ -0,0 +1,334 @@ +import type { Task, TaskSourceIssue } from "@fusion/core"; +import type { ResolvedGitlabAuth } from "./gitlab-auth.js"; + +export type GitLabResourceType = "project_issue" | "group_issue" | "merge_request"; + +export interface GitLabAuthor { + id?: number; + username?: string; + name?: string; + avatarUrl?: string; + webUrl?: string; +} + +export interface GitLabProjectIdentity { + id?: number; + pathWithNamespace?: string; + webUrl?: string; +} + +export interface GitLabIssue { + resourceKind: "project_issue" | "group_issue"; + id?: number; + iid: number; + projectId?: number; + projectPath?: string; + groupId?: number | string; + groupPath?: string; + title: string; + description: string | null; + webUrl: string; + state: string; + author?: GitLabAuthor | null; + labels: string[]; + createdAt?: string; + updatedAt?: string; + commentsCount?: number; +} + +export interface GitLabMergeRequest { + resourceKind: "merge_request"; + id?: number; + iid: number; + projectId?: number; + projectPath?: string; + title: string; + description: string | null; + webUrl: string; + state: string; + author?: GitLabAuthor | null; + labels: string[]; + createdAt?: string; + updatedAt?: string; + commentsCount?: number; + sourceBranch?: string; + targetBranch?: string; + draft?: boolean; +} + +export interface GitLabListOptions { + limit?: number; + labels?: string[]; + state?: string; +} + +export class GitLabApiError extends Error { + constructor(public readonly status: number, message: string) { + super(message); + this.name = "GitLabApiError"; + } +} + +const DEFAULT_LIMIT = 30; +const MAX_LIMIT = 100; +const PAGE_SIZE = 100; + +function clampLimit(limit: unknown): number { + if (typeof limit !== "number" || !Number.isFinite(limit)) return DEFAULT_LIMIT; + return Math.max(1, Math.min(MAX_LIMIT, Math.floor(limit))); +} + +export function encodeGitLabPathId(id: string | number): string { + return encodeURIComponent(String(id).trim()); +} + +function appendListParams(params: URLSearchParams, options: GitLabListOptions): void { + const state = typeof options.state === "string" && options.state.trim() ? options.state.trim() : "opened"; + params.set("state", state); + if (options.labels && options.labels.length > 0) { + params.set("labels", options.labels.map((label) => label.trim()).filter(Boolean).join(",")); + } +} + +type GitLabRaw = Record<string, unknown>; + +function asRecord(value: unknown): GitLabRaw { + return value && typeof value === "object" ? value as GitLabRaw : {}; +} + +function normalizeAuthor(author: unknown): GitLabAuthor | null { + const raw = asRecord(author); + if (Object.keys(raw).length === 0) return null; + return { + ...(typeof raw.id === "number" ? { id: raw.id } : {}), + ...(typeof raw.username === "string" ? { username: raw.username } : {}), + ...(typeof raw.name === "string" ? { name: raw.name } : {}), + ...(typeof raw.avatar_url === "string" ? { avatarUrl: raw.avatar_url } : {}), + ...(typeof raw.web_url === "string" ? { webUrl: raw.web_url } : {}), + }; +} + +function normalizeLabels(labels: unknown): string[] { + return Array.isArray(labels) ? labels.filter((label): label is string => typeof label === "string") : []; +} + +function readProjectPath(input: unknown): string | undefined { + const raw = asRecord(input); + if (typeof raw.project_path_with_namespace === "string") return raw.project_path_with_namespace; + const references = asRecord(raw.references); + if (typeof references.full === "string") { + const full = references.full; + const webUrl = typeof raw.web_url === "string" ? raw.web_url : ""; + const marker = webUrl.includes("/-/merge_requests/") ? "!" : "#"; + const idx = full.lastIndexOf(marker); + return idx > 0 ? full.slice(0, idx) : undefined; + } + if (typeof raw?.web_url === "string") { + const marker = raw.web_url.includes("/-/merge_requests/") ? "/-/merge_requests/" : "/-/issues/"; + try { + const url = new URL(raw.web_url); + const idx = url.pathname.indexOf(marker); + return idx > 0 ? decodeURIComponent(url.pathname.slice(1, idx)) : undefined; + } catch { + return undefined; + } + } + return undefined; +} + +function normalizeIssue(input: unknown, resourceKind: "project_issue" | "group_issue", extras: Partial<GitLabIssue> = {}): GitLabIssue { + const raw = asRecord(input); + return { + resourceKind, + ...(typeof raw.id === "number" ? { id: raw.id } : {}), + iid: Number(raw.iid), + ...(typeof raw.project_id === "number" ? { projectId: raw.project_id } : {}), + ...(readProjectPath(raw) ? { projectPath: readProjectPath(raw) } : {}), + title: String(raw.title ?? ""), + description: typeof raw.description === "string" ? raw.description : null, + webUrl: String(raw.web_url ?? ""), + state: String(raw.state ?? "unknown"), + author: normalizeAuthor(raw.author), + labels: normalizeLabels(raw.labels), + ...(typeof raw.created_at === "string" ? { createdAt: raw.created_at } : {}), + ...(typeof raw.updated_at === "string" ? { updatedAt: raw.updated_at } : {}), + ...(typeof raw.user_notes_count === "number" ? { commentsCount: raw.user_notes_count } : {}), + ...extras, + }; +} + +function normalizeMergeRequest(input: unknown, extras: Partial<GitLabMergeRequest> = {}): GitLabMergeRequest { + const raw = asRecord(input); + return { + resourceKind: "merge_request", + ...(typeof raw.id === "number" ? { id: raw.id } : {}), + iid: Number(raw.iid), + ...(typeof raw.project_id === "number" ? { projectId: raw.project_id } : {}), + ...(readProjectPath(raw) ? { projectPath: readProjectPath(raw) } : {}), + title: String(raw.title ?? ""), + description: typeof raw.description === "string" ? raw.description : null, + webUrl: String(raw.web_url ?? ""), + state: String(raw.state ?? "unknown"), + author: normalizeAuthor(raw.author), + labels: normalizeLabels(raw.labels), + ...(typeof raw.created_at === "string" ? { createdAt: raw.created_at } : {}), + ...(typeof raw.updated_at === "string" ? { updatedAt: raw.updated_at } : {}), + ...(typeof raw.user_notes_count === "number" ? { commentsCount: raw.user_notes_count } : {}), + ...(typeof raw.source_branch === "string" ? { sourceBranch: raw.source_branch } : {}), + ...(typeof raw.target_branch === "string" ? { targetBranch: raw.target_branch } : {}), + ...(typeof raw.draft === "boolean" ? { draft: raw.draft } : {}), + ...extras, + }; +} + +export class GitLabClient { + constructor(public readonly auth: ResolvedGitlabAuth, private readonly fetchImpl: typeof fetch = fetch) {} + + private async request<T>(path: string, init?: RequestInit): Promise<T> { + const url = `${this.auth.apiBaseUrl.replace(/\/+$/u, "")}/${path.replace(/^\/+/, "")}`; + const response = await this.fetchImpl(url, { + ...init, + headers: { + Accept: "application/json", + [this.auth.headerName]: this.auth.token, + ...(init?.headers ?? {}), + }, + }); + if (!response.ok) { + throw new GitLabApiError(response.status, mapGitLabError(response.status)); + } + return await response.json() as T; + } + + private async listPaginated<T>(path: string, options: GitLabListOptions, normalize: (raw: unknown) => T): Promise<T[]> { + const limit = clampLimit(options.limit); + const out: T[] = []; + let page = 1; + while (out.length < limit && page <= Math.ceil(limit / PAGE_SIZE) + 1) { + const params = new URLSearchParams(); + params.set("per_page", String(Math.min(PAGE_SIZE, limit - out.length))); + params.set("page", String(page)); + appendListParams(params, options); + const rows = await this.request<unknown[]>(`${path}?${params.toString()}`); + if (!Array.isArray(rows) || rows.length === 0) break; + out.push(...rows.map(normalize)); + if (rows.length < Number(params.get("per_page"))) break; + page += 1; + } + return out.slice(0, limit); + } + + listProjectIssues(project: string | number, options: GitLabListOptions = {}): Promise<GitLabIssue[]> { + return this.listPaginated(`projects/${encodeGitLabPathId(project)}/issues`, options, (raw) => normalizeIssue(raw, "project_issue")); + } + + listGroupIssues(group: string | number, options: GitLabListOptions = {}): Promise<GitLabIssue[]> { + return this.listPaginated(`groups/${encodeGitLabPathId(group)}/issues`, options, (raw) => normalizeIssue(raw, "group_issue", { + groupPath: typeof group === "string" ? group : undefined, + groupId: typeof group === "number" ? group : undefined, + })); + } + + listMergeRequests(project: string | number, options: GitLabListOptions = {}): Promise<GitLabMergeRequest[]> { + return this.listPaginated(`projects/${encodeGitLabPathId(project)}/merge_requests`, options, normalizeMergeRequest); + } + + async getProjectIssue(project: string | number, iid: number): Promise<GitLabIssue> { + return normalizeIssue(await this.request(`projects/${encodeGitLabPathId(project)}/issues/${iid}`), "project_issue"); + } + + async getMergeRequest(project: string | number, iid: number): Promise<GitLabMergeRequest> { + return normalizeMergeRequest(await this.request(`projects/${encodeGitLabPathId(project)}/merge_requests/${iid}`)); + } +} + +export function mapGitLabError(status: number): string { + switch (status) { + case 401: + return "GitLab authentication failed. Check gitlabAuthToken and required read_api scope."; + case 403: + return "GitLab authorization failed. Check token scope and project/group access."; + case 404: + return "GitLab project, group, issue, or merge request was not found."; + case 429: + return "GitLab rate limit exceeded. Try again later."; + default: + return status >= 500 ? "GitLab is unavailable. Try again later." : `GitLab API request failed with status ${status}.`; + } +} + +function projectIdentity(item: GitLabIssue | GitLabMergeRequest): string { + return item.projectPath ?? (item.projectId !== undefined ? String(item.projectId) : "unknown-project"); +} + +export function buildGitLabTaskProvenance(args: { + auth: Pick<ResolvedGitlabAuth, "apiBaseUrl" | "webBaseUrl">; + resourceType: GitLabResourceType; + item: GitLabIssue | GitLabMergeRequest; + projectInput?: string | number; + groupInput?: string | number; +}): { sourceIssue: TaskSourceIssue; sourceMetadata: Record<string, unknown> } { + const { auth, resourceType, item } = args; + const repository = projectIdentity(item); + const externalIssueId = resourceType === "merge_request" + ? `gitlab:mr:${item.projectId ?? repository}:${item.id ?? item.iid}` + : String(item.id ?? `${item.projectId ?? repository}:${item.iid}`); + return { + sourceIssue: { + provider: "gitlab", + repository, + externalIssueId, + issueNumber: item.iid, + url: item.webUrl, + }, + sourceMetadata: { + provider: "gitlab", + resourceType, + instanceUrl: auth.webBaseUrl, + apiBaseUrl: auth.apiBaseUrl, + projectId: item.projectId, + projectPath: item.projectPath, + groupId: "groupId" in item ? item.groupId : undefined, + groupPath: "groupPath" in item ? item.groupPath : undefined, + projectInput: args.projectInput, + groupInput: args.groupInput, + iid: item.iid, + webUrl: item.webUrl, + ...(resourceType === "merge_request" ? { + mergeRequestId: item.id, + mergeRequestIid: item.iid, + sourceBranch: (item as GitLabMergeRequest).sourceBranch, + targetBranch: (item as GitLabMergeRequest).targetBranch, + draft: (item as GitLabMergeRequest).draft, + } : { + issueId: item.id, + issueIid: item.iid, + }), + }, + }; +} + +export function isGitLabAlreadyImported(task: Pick<Task, "description" | "sourceIssue" | "source">, provenance: { sourceIssue: TaskSourceIssue; sourceMetadata: Record<string, unknown> }): boolean { + const sourceUrl = provenance.sourceIssue.url; + if (sourceUrl && task.description?.includes(sourceUrl)) return true; + if (task.sourceIssue?.provider === "gitlab" && task.sourceIssue.externalIssueId === provenance.sourceIssue.externalIssueId) return true; + const metadata = task.source?.sourceMetadata; + if (task.source?.sourceType === "gitlab_import" && metadata && typeof metadata === "object") { + const meta = metadata as Record<string, unknown>; + return meta.resourceType === provenance.sourceMetadata.resourceType + && meta.iid === provenance.sourceMetadata.iid + && (meta.projectId === provenance.sourceMetadata.projectId || meta.projectPath === provenance.sourceMetadata.projectPath) + && (meta.webUrl === sourceUrl || sourceUrl === undefined); + } + return false; +} + +export function buildGitLabTaskDescription(item: GitLabIssue | GitLabMergeRequest): string { + const body = item.description?.trim() || "(no description)"; + return `${body}\n\nSource: ${item.webUrl}`; +} + +/* +FNXC:GitLabImport 2026-07-02-00:00: +FN-7424 adds HTTP API-only GitLab imports for project issues, group issues, and merge requests. Keep this client token-based and provenance-focused: do not invoke `glab`, post comments, auto-close resources, add tracking UI, Command Center signals, research/search providers, or star prompts in this slice. +*/ diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index 295b168e14..1fd6635826 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -33,6 +33,21 @@ export { scoreCandidateIssue, } from "./github-tracking-dedup.js"; export { registerGithubTrackingHook } from "./github-tracking-hook.js"; +export { + GitLabClient, + GitLabApiError, + buildGitLabTaskDescription, + buildGitLabTaskProvenance, + isGitLabAlreadyImported, + type GitLabIssue, + type GitLabMergeRequest, + type GitLabResourceType, +} from "./gitlab.js"; +export { + resolveGitlabAuth, + type ResolvedGitlabAuth, + type GitlabAuthResolution, +} from "./gitlab-auth.js"; export { resolveGithubTrackingAuth, type GithubTrackingAuth, diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index f01da4f940..442ad265c5 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -149,6 +149,7 @@ import { registerSettingsMemoryRoutes } from "./routes/register-settings-memory- import { registerSecretsRoutes } from "./routes/register-secrets-routes.js"; import { registerMessagingScriptRoutes } from "./routes/register-messaging-scripts.js"; import { registerGitGitHubRoutes } from "./routes/register-git-github.js"; +import { registerGitLabRoutes } from "./routes/register-gitlab.js"; import { registerFilesTerminalWorkspaceRoutes } from "./routes/register-files-terminal-workspaces.js"; import { registerAgentsProjectsNodesRoutes } from "./routes/register-agents-projects-nodes.js"; import { registerProjectRoutes } from "./routes/register-project-routes.js"; @@ -1137,6 +1138,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout registerChatRoomRoutes(routeContext, { upload }); registerMessagingScriptRoutes(routeContext); registerGitGitHubRoutes(routeContext); + registerGitLabRoutes(routeContext); registerFilesTerminalWorkspaceRoutes(routeContext); registerAgentsProjectsNodesRoutes(routeContext); registerPluginsAutomationRoutes(routeContext); diff --git a/packages/dashboard/src/routes/register-gitlab.ts b/packages/dashboard/src/routes/register-gitlab.ts new file mode 100644 index 0000000000..4cee8a9e0b --- /dev/null +++ b/packages/dashboard/src/routes/register-gitlab.ts @@ -0,0 +1,192 @@ +import { badRequest, unauthorized, ApiError } from "../api-error.js"; +import { resolveGitlabAuth } from "../gitlab-auth.js"; +import { + buildGitLabTaskDescription, + buildGitLabTaskProvenance, + GitLabApiError, + GitLabClient, + isGitLabAlreadyImported, + type GitLabIssue, + type GitLabMergeRequest, + type GitLabResourceType, +} from "../gitlab.js"; +import type { ApiRoutesContext } from "./types.js"; + +function readRequiredString(body: Record<string, unknown>, key: string): string | number { + const value = body[key]; + if (typeof value === "number" && Number.isInteger(value) && value > 0) return value; + if (typeof value === "string" && value.trim().length > 0) return value.trim(); + throw badRequest(`${key} is required`); +} + +function readPositiveInteger(body: Record<string, unknown>, key: string): number { + const value = body[key]; + if (typeof value === "number" && Number.isInteger(value) && value > 0) return value; + throw badRequest(`${key} is required and must be a positive integer`); +} + +function readLabels(body: Record<string, unknown>): string[] | undefined { + const labels = body.labels; + if (Array.isArray(labels)) return labels.filter((label): label is string => typeof label === "string" && label.trim().length > 0); + if (typeof labels === "string" && labels.trim()) return labels.split(",").map((label) => label.trim()).filter(Boolean); + return undefined; +} + +function readLimit(body: Record<string, unknown>): number | undefined { + return typeof body.limit === "number" ? body.limit : undefined; +} + +function readState(body: Record<string, unknown>): string | undefined { + return typeof body.state === "string" && body.state.trim() ? body.state.trim() : undefined; +} + +async function createClient(ctx: ApiRoutesContext, req: Parameters<ApiRoutesContext["getProjectContext"]>[0]): Promise<GitLabClient> { + const { store } = await ctx.getProjectContext(req); + const projectSettings = await store.getSettings(); + const globalSettings = await store.getGlobalSettingsStore().getSettings(); + const auth = resolveGitlabAuth({ projectSettings, globalSettings }); + if (!auth.ok) { + throw auth.reason === "token_missing" ? unauthorized(auth.message) : badRequest(auth.message); + } + return new GitLabClient(auth.auth); +} + +function mapGitLabClientError(error: unknown): never { + if (error instanceof ApiError) throw error; + if (error instanceof GitLabApiError) throw new ApiError(error.status === 401 ? 401 : error.status, error.message); + throw error; +} + +async function findDuplicate(ctx: ApiRoutesContext, req: Parameters<ApiRoutesContext["getProjectContext"]>[0], provenance: ReturnType<typeof buildGitLabTaskProvenance>) { + const { store } = await ctx.getProjectContext(req); + const existingTasks = await store.listTasks({ slim: false, includeArchived: false }); + return existingTasks.find((task) => isGitLabAlreadyImported(task, provenance)); +} + +async function importItem(ctx: ApiRoutesContext, req: Parameters<ApiRoutesContext["getProjectContext"]>[0], args: { + resourceType: GitLabResourceType; + item: GitLabIssue | GitLabMergeRequest; + projectInput?: string | number; + groupInput?: string | number; +}) { + const { store } = await ctx.getProjectContext(req); + const client = await createClient(ctx, req); + const auth = (client as unknown as { auth: { apiBaseUrl: string; webBaseUrl: string } }).auth; + const provenance = buildGitLabTaskProvenance({ auth, ...args }); + const duplicate = await findDuplicate(ctx, req, provenance); + if (duplicate) { + throw new ApiError(409, `GitLab ${args.resourceType} #${args.item.iid} already imported as ${duplicate.id}`, { existingTaskId: duplicate.id }); + } + + const title = args.resourceType === "merge_request" + ? `Review MR !${args.item.iid}: ${args.item.title.slice(0, 180)}` + : args.item.title.slice(0, 200); + const task = await store.createTask({ + title: title || undefined, + description: buildGitLabTaskDescription(args.item), + column: "triage", + dependencies: [], + sourceIssue: provenance.sourceIssue, + source: { sourceType: "gitlab_import", sourceMetadata: provenance.sourceMetadata }, + }); + await store.logEntry(task.id, args.resourceType === "merge_request" ? "Imported merge request from GitLab" : "Imported from GitLab", args.item.webUrl); + return task; +} + +export function registerGitLabRoutes(ctx: ApiRoutesContext): void { + const { router, rethrowAsApiError } = ctx; + + router.post("/gitlab/project/issues/fetch", async (req, res) => { + try { + const project = readRequiredString(req.body, "project"); + const client = await createClient(ctx, req); + res.json(await client.listProjectIssues(project, { limit: readLimit(req.body), labels: readLabels(req.body), state: readState(req.body) })); + } catch (error) { + try { mapGitLabClientError(error); } catch (mapped) { rethrowAsApiError(mapped); } + } + }); + + router.post("/gitlab/group/issues/fetch", async (req, res) => { + try { + const group = readRequiredString(req.body, "group"); + const client = await createClient(ctx, req); + res.json(await client.listGroupIssues(group, { limit: readLimit(req.body), labels: readLabels(req.body), state: readState(req.body) })); + } catch (error) { + try { mapGitLabClientError(error); } catch (mapped) { rethrowAsApiError(mapped); } + } + }); + + router.post("/gitlab/merge-requests/fetch", async (req, res) => { + try { + const project = readRequiredString(req.body, "project"); + const client = await createClient(ctx, req); + res.json(await client.listMergeRequests(project, { limit: readLimit(req.body), labels: readLabels(req.body), state: readState(req.body) })); + } catch (error) { + try { mapGitLabClientError(error); } catch (mapped) { rethrowAsApiError(mapped); } + } + }); + + router.post("/gitlab/project/issues/import", async (req, res) => { + try { + const project = readRequiredString(req.body, "project"); + const iid = readPositiveInteger(req.body, "iid"); + const client = await createClient(ctx, req); + const item = await client.getProjectIssue(project, iid); + res.status(201).json(await importItem(ctx, req, { resourceType: "project_issue", item, projectInput: project })); + } catch (error) { + try { mapGitLabClientError(error); } catch (mapped) { rethrowAsApiError(mapped); } + } + }); + + router.post("/gitlab/group/issues/import", async (req, res) => { + try { + const item = req.body.issue as GitLabIssue | undefined; + const group = typeof req.body.group !== "undefined" ? readRequiredString(req.body, "group") : undefined; + if (!item || typeof item !== "object" || typeof item.iid !== "number" || typeof item.webUrl !== "string") { + throw badRequest("issue is required"); + } + res.status(201).json(await importItem(ctx, req, { resourceType: "group_issue", item: { ...item, resourceKind: "group_issue" }, groupInput: group })); + } catch (error) { + try { mapGitLabClientError(error); } catch (mapped) { rethrowAsApiError(mapped); } + } + }); + + router.post("/gitlab/merge-requests/import", async (req, res) => { + try { + const project = readRequiredString(req.body, "project"); + const iid = readPositiveInteger(req.body, "iid"); + const client = await createClient(ctx, req); + const item = await client.getMergeRequest(project, iid); + res.status(201).json(await importItem(ctx, req, { resourceType: "merge_request", item, projectInput: project })); + } catch (error) { + try { mapGitLabClientError(error); } catch (mapped) { rethrowAsApiError(mapped); } + } + }); + + router.post("/gitlab/batch-import", async (req, res) => { + try { + const items = Array.isArray(req.body.items) ? req.body.items : []; + if (items.length === 0 || items.length > 50) throw badRequest("items must contain 1 to 50 resources"); + const results = []; + for (const item of items) { + try { + const resourceType = item.resourceType as GitLabResourceType; + if (resourceType === "project_issue") { + results.push({ success: true, taskId: (await importItem(ctx, req, { resourceType, item: item.issue, projectInput: item.project })).id, iid: item.issue?.iid }); + } else if (resourceType === "group_issue") { + results.push({ success: true, taskId: (await importItem(ctx, req, { resourceType, item: item.issue, groupInput: item.group })).id, iid: item.issue?.iid }); + } else if (resourceType === "merge_request") { + results.push({ success: true, taskId: (await importItem(ctx, req, { resourceType, item: item.mergeRequest, projectInput: item.project })).id, iid: item.mergeRequest?.iid }); + } else { + results.push({ success: false, error: "Unsupported resourceType" }); + } + } catch (error) { + results.push({ success: false, error: error instanceof Error ? error.message : String(error) }); + } + } + res.json({ results }); + } catch (error) { + rethrowAsApiError(error); + } + }); +} diff --git a/packages/engine/src/__tests__/agent-action-gate.test.ts b/packages/engine/src/__tests__/agent-action-gate.test.ts index 40f90ae60f..10bcc12565 100644 --- a/packages/engine/src/__tests__/agent-action-gate.test.ts +++ b/packages/engine/src/__tests__/agent-action-gate.test.ts @@ -120,6 +120,7 @@ describe("agent-action-gate", () => { expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_agent_config", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_import_github", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_import_github_issue", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); + expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_import_gitlab_project_issues", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_ask_question", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("exempt"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_identity", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("exempt"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_spawn_agent", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); @@ -275,6 +276,9 @@ describe("agent-action-gate", () => { "fn_delegate_task", "fn_task_import_github", "fn_task_import_github_issue", + "fn_task_import_gitlab_project_issues", + "fn_task_import_gitlab_group_issues", + "fn_task_import_gitlab_merge_requests", ] as const)("governs task creation/import tool %s as task_agent_mutation", (toolName) => { for (const args of [{}, undefined]) { expect(evaluateAgentActionGate({ agentId: "a1", toolName, args, permissionPolicy: approvalPolicy })).toMatchObject({ diff --git a/packages/engine/src/__tests__/gating-classifications-provisioning.test.ts b/packages/engine/src/__tests__/gating-classifications-provisioning.test.ts index c14003a8db..0ffcc80406 100644 --- a/packages/engine/src/__tests__/gating-classifications-provisioning.test.ts +++ b/packages/engine/src/__tests__/gating-classifications-provisioning.test.ts @@ -18,12 +18,11 @@ describe("gating classifications provisioning split", () => { expect(TASK_AGENT_MUTATION_TOOLS.has("fn_agent_delete")).toBe(true); }); - it("classifies github task imports as action-gated task mutations only", () => { - expect(ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has("fn_task_import_github")).toBe(true); - expect(ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has("fn_task_import_github_issue")).toBe(true); - expect(PERMANENT_AGENT_TASK_MUTATION_TOOLS.has("fn_task_import_github")).toBe(false); - expect(PERMANENT_AGENT_TASK_MUTATION_TOOLS.has("fn_task_import_github_issue")).toBe(false); - expect(TASK_AGENT_MUTATION_TOOLS.has("fn_task_import_github")).toBe(true); - expect(TASK_AGENT_MUTATION_TOOLS.has("fn_task_import_github_issue")).toBe(true); + it("classifies provider task imports as action-gated task mutations only", () => { + for (const tool of ["fn_task_import_github", "fn_task_import_github_issue", "fn_task_import_gitlab_project_issues", "fn_task_import_gitlab_group_issues", "fn_task_import_gitlab_merge_requests"]) { + expect(ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has(tool)).toBe(true); + expect(PERMANENT_AGENT_TASK_MUTATION_TOOLS.has(tool)).toBe(false); + expect(TASK_AGENT_MUTATION_TOOLS.has(tool)).toBe(true); + } }); }); diff --git a/packages/engine/src/__tests__/gating-classifications.test.ts b/packages/engine/src/__tests__/gating-classifications.test.ts index 16f2703b51..f15257568d 100644 --- a/packages/engine/src/__tests__/gating-classifications.test.ts +++ b/packages/engine/src/__tests__/gating-classifications.test.ts @@ -98,6 +98,9 @@ const ACTION_MUTATION_PERMANENT_READONLY_TOOLS = new Set([ "fn_delegate_task", "fn_task_import_github", "fn_task_import_github_issue", + "fn_task_import_gitlab_project_issues", + "fn_task_import_gitlab_group_issues", + "fn_task_import_gitlab_merge_requests", ]); const policyMatrix = [ @@ -110,6 +113,9 @@ const permanentReadonlySiblingTaskCreationTools = [ "fn_delegate_task", "fn_task_import_github", "fn_task_import_github_issue", + "fn_task_import_gitlab_project_issues", + "fn_task_import_gitlab_group_issues", + "fn_task_import_gitlab_merge_requests", ] as const; describe("gating-classifications parity", () => { diff --git a/packages/engine/src/gating-classifications.ts b/packages/engine/src/gating-classifications.ts index 46c64726fb..b8d7e6a7f6 100644 --- a/packages/engine/src/gating-classifications.ts +++ b/packages/engine/src/gating-classifications.ts @@ -50,6 +50,9 @@ const ACTION_GATE_TASK_AGENT_ONLY_TOOLS = [ "fn_delegate_task", "fn_task_import_github", "fn_task_import_github_issue", + "fn_task_import_gitlab_project_issues", + "fn_task_import_gitlab_group_issues", + "fn_task_import_gitlab_merge_requests", ] as const; const ACTION_GATE_SHARED_TASK_AGENT_TOOLS = SHARED_TASK_AGENT_TOOLS.filter( (tool) => !(PROVISIONING_TOOLS as readonly string[]).includes(tool), @@ -132,6 +135,12 @@ export const READONLY_FN_TOOLS: ReadonlySet<string> = new Set([ "fn_delegate_task", "fn_task_import_github", "fn_task_import_github_issue", + "fn_task_import_gitlab_project_issues", + "fn_task_import_gitlab_group_issues", + "fn_task_import_gitlab_merge_requests", + "fn_task_browse_gitlab_project_issues", + "fn_task_browse_gitlab_group_issues", + "fn_task_browse_gitlab_merge_requests", "fn_research_list", "fn_research_get", "fn_insight_list",