From c49a933fb04023c1de11dbd6a8c05cd24d4be57c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 2 Jul 2026 12:34:35 -0700 Subject: [PATCH] FN-7428: add GitLab tracking coverage and reconciliation support Add GitLab tracking persistence, analytics, and reconciliation coverage across core, dashboard, and CLI paths. - Store GitLab tracking and closed-at metadata when importing issues and merge requests from CLI, extension tools, and dashboard routes. - Add GitLab source-issue analytics, Command Center GitLab signal surfaces, CSV export fields, and a closed-at backfill route. - Cover GitLab tracking storage, reconciliation, CLI imports, dashboard import UI, route behavior, and Command Center signals with focused tests. Files changed: .changeset/fn-7428-gitlab-import-tracking.md | 7 + .../__tests__/extension-gitlab-tracking.test.ts | 140 ++++++++++++ .../__tests__/task-command-gitlab-import.test.ts | 18 +- packages/cli/src/commands/task.ts | 1 + packages/cli/src/extension.ts | 2 +- .../src/__tests__/gitlab-issue-analytics.test.ts | 243 +++++++++++++++++++++ .../__tests__/gitlab-source-issue-storage.test.ts | 128 +++++++++++ .../store-gitlab-tracking-reconcile.test.ts | 125 +++++++++++ packages/core/src/gitlab-issue-analytics.ts | 227 +++++++++++++++++++ packages/core/src/index.ts | 8 + packages/core/src/store.ts | 21 +- .../__tests__/GitHubImportModal.test.tsx | 46 ++++ .../components/__tests__/gitlabTracking.test.tsx | 43 ++++ .../components/command-center/CommandCenter.tsx | 5 + .../components/command-center/areas/GitlabArea.tsx | 126 +++++++++++ .../areas/__tests__/areas.gitlab-signals.test.tsx | 83 +++++++ .../gitlab-source-issue-reconciler.test.ts | 187 ++++++++++++++++ .../register-command-center-routes.test.ts | 52 +++++ .../__tests__/register-git-gitlab.backfill.test.ts | 116 ++++++++++ .../dashboard/src/__tests__/routes-gitlab.test.ts | 59 +++-- packages/dashboard/src/command-center-csv.ts | 21 ++ .../src/gitlab-source-issue-reconciler.ts | 104 +++++++++ packages/dashboard/src/gitlab.ts | 16 +- .../src/routes/register-command-center-routes.ts | 25 +++ packages/dashboard/src/routes/register-gitlab.ts | 29 +++ 25 files changed, 1800 insertions(+), 32 deletions(-) Fusion-Task-Id: FN-7428 Fusion-Task-Lineage: 7b0ebb5c-c6e7-42a9-a339-b2dd1d4e263a Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7428-gitlab-import-tracking.md | 7 + .../extension-gitlab-tracking.test.ts | 140 ++++++++++ .../task-command-gitlab-import.test.ts | 18 +- packages/cli/src/commands/task.ts | 1 + packages/cli/src/extension.ts | 2 +- .../__tests__/gitlab-issue-analytics.test.ts | 243 ++++++++++++++++++ .../gitlab-source-issue-storage.test.ts | 128 +++++++++ .../store-gitlab-tracking-reconcile.test.ts | 125 +++++++++ packages/core/src/gitlab-issue-analytics.ts | 227 ++++++++++++++++ packages/core/src/index.ts | 8 + packages/core/src/store.ts | 21 +- .../__tests__/GitHubImportModal.test.tsx | 46 ++++ .../__tests__/gitlabTracking.test.tsx | 43 ++++ .../command-center/CommandCenter.tsx | 5 + .../command-center/areas/GitlabArea.tsx | 126 +++++++++ .../__tests__/areas.gitlab-signals.test.tsx | 83 ++++++ .../gitlab-source-issue-reconciler.test.ts | 187 ++++++++++++++ .../register-command-center-routes.test.ts | 52 ++++ .../register-git-gitlab.backfill.test.ts | 116 +++++++++ .../src/__tests__/routes-gitlab.test.ts | 59 +++-- packages/dashboard/src/command-center-csv.ts | 21 ++ .../src/gitlab-source-issue-reconciler.ts | 104 ++++++++ packages/dashboard/src/gitlab.ts | 16 +- .../routes/register-command-center-routes.ts | 25 ++ .../dashboard/src/routes/register-gitlab.ts | 29 +++ 25 files changed, 1800 insertions(+), 32 deletions(-) create mode 100644 .changeset/fn-7428-gitlab-import-tracking.md create mode 100644 packages/cli/src/__tests__/extension-gitlab-tracking.test.ts create mode 100644 packages/core/src/__tests__/gitlab-issue-analytics.test.ts create mode 100644 packages/core/src/__tests__/gitlab-source-issue-storage.test.ts create mode 100644 packages/core/src/__tests__/store-gitlab-tracking-reconcile.test.ts create mode 100644 packages/core/src/gitlab-issue-analytics.ts create mode 100644 packages/dashboard/app/components/__tests__/gitlabTracking.test.tsx create mode 100644 packages/dashboard/app/components/command-center/areas/GitlabArea.tsx create mode 100644 packages/dashboard/app/components/command-center/areas/__tests__/areas.gitlab-signals.test.tsx create mode 100644 packages/dashboard/src/__tests__/gitlab-source-issue-reconciler.test.ts create mode 100644 packages/dashboard/src/__tests__/register-git-gitlab.backfill.test.ts create mode 100644 packages/dashboard/src/gitlab-source-issue-reconciler.ts diff --git a/.changeset/fn-7428-gitlab-import-tracking.md b/.changeset/fn-7428-gitlab-import-tracking.md new file mode 100644 index 0000000000..eaeacd785b --- /dev/null +++ b/.changeset/fn-7428-gitlab-import-tracking.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Preserve GitLab tracking metadata for CLI and extension imports. +category: fix +dev: GitLab project, group, and merge-request imports now carry gitlabTracking metadata alongside sourceIssue provenance. diff --git a/packages/cli/src/__tests__/extension-gitlab-tracking.test.ts b/packages/cli/src/__tests__/extension-gitlab-tracking.test.ts new file mode 100644 index 0000000000..1f66bdd6b8 --- /dev/null +++ b/packages/cli/src/__tests__/extension-gitlab-tracking.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TaskStore } from "@fusion/core"; + +const gitlabIssues = vi.hoisted(() => ({ + project: [{ resourceKind: "project_issue", id: 1, iid: 2, projectId: 3, projectPath: "g/p", title: "Project issue", description: "Body", webUrl: "https://gitlab.example.com/g/p/-/issues/2", state: "opened", labels: [] }], + group: [{ resourceKind: "group_issue", id: 4, iid: 7, projectId: 8, projectPath: "g/p", groupPath: "g", title: "Group issue", description: null, webUrl: "https://gitlab.example.com/g/p/-/issues/7", state: "opened", labels: [] }], + mrs: [{ resourceKind: "merge_request", id: 5, iid: 9, projectId: 8, projectPath: "g/p", title: "Merge request", description: "MR", webUrl: "https://gitlab.example.com/g/p/-/merge_requests/9", state: "opened", labels: [], sourceBranch: "feat", targetBranch: "main" }], +})); + +vi.mock("@fusion/dashboard", () => { + class GitLabClient { + auth: any; + constructor(auth: any) { this.auth = auth; } + listProjectIssues = vi.fn(async () => gitlabIssues.project); + listGroupIssues = vi.fn(async () => gitlabIssues.group); + listMergeRequests = vi.fn(async () => gitlabIssues.mrs); + } + return { + registerGithubTrackingHook: vi.fn(), + resolveGitlabAuth: vi.fn(({ projectSettings }: any) => projectSettings.gitlabAuthToken + ? { ok: true, auth: { apiBaseUrl: "https://gitlab.example.com/api/v4", webBaseUrl: "https://gitlab.example.com", token: projectSettings.gitlabAuthToken, tokenType: "personal", headerName: "PRIVATE-TOKEN" } } + : { ok: false, message: "GitLab auth requires a configured access token" }), + GitLabClient, + buildGitLabTaskDescription: (item: any) => `${item.description?.trim() || "(no description)"}\n\nSource: ${item.webUrl}`, + buildGitLabTaskProvenance: ({ resourceType, item, groupInput }: any) => ({ + sourceIssue: { provider: "gitlab", repository: item.projectPath ?? String(item.projectId), externalIssueId: String(item.id), issueNumber: item.iid, url: item.webUrl }, + gitlabTracking: { item: { kind: resourceType, iid: item.iid, id: item.id, projectId: item.projectId, projectPath: item.projectPath, groupPath: item.groupPath ?? groupInput, url: item.webUrl, host: "gitlab.example.com", instanceUrl: "https://gitlab.example.com", title: item.title, state: item.state } }, + sourceMetadata: { provider: "gitlab", resourceType, iid: item.iid, groupInput, projectPath: item.projectPath, mergeRequestIid: resourceType === "merge_request" ? item.iid : undefined }, + }), + isGitLabAlreadyImported: (task: any, provenance: any) => task.sourceIssue?.url === provenance.sourceIssue.url, + }; +}); + +vi.mock("@fusion/engine", () => ({ + createFnAgent: vi.fn(), + fetchWebContent: vi.fn(), + assertNoSecretPlaintext: vi.fn(), + emitGoalRetrievalAudit: vi.fn(), + createWorkflowAuthoringTools: vi.fn(() => ({})), + workflowListParams: {}, + workflowGetParams: {}, + workflowSelectParams: {}, + workflowCreateParams: {}, + workflowUpdateParams: {}, + workflowDeleteParams: {}, + workflowSettingsParams: {}, + traitListParams: {}, +})); + +async function loadExtension() { + const mod = await import("../extension.js"); + return mod.default; +} + +async function setupTools() { + const repoRoot = await mkdtemp(join(tmpdir(), "fn-7428-extension-gitlab-")); + const cwd = join(repoRoot, ".worktrees", "feature"); + await mkdir(join(repoRoot, ".fusion"), { recursive: true }); + const taskStore = new TaskStore(repoRoot, undefined, { inMemoryDb: false }); + await taskStore.init(); + await taskStore.updateSettings({ gitlabAuthToken: "glpat_test", gitlabInstanceUrl: "https://gitlab.example.com" }); + taskStore.close(); + + const extension = await loadExtension(); + const tools = new Map(); + extension({ registerTool: (def: any) => tools.set(def.name, def), registerCommand: vi.fn(), registerShortcut: vi.fn(), registerFlag: vi.fn(), on: vi.fn() } as any); + return { repoRoot, cwd, tools }; +} + +/* +FNXC:GitLabExtension 2026-07-02-00:00: +GitLab extension tools are HTTP API tools backed by configured GitLab token settings. They must expose project/group/MR schemas, create local GitLab source/tracking metadata, and never depend on a `glab` binary or real network. +*/ +describe("extension GitLab import tools", () => { + beforeEach(() => vi.clearAllMocks()); + + afterEach(() => vi.restoreAllMocks()); + + it("registers browse/import tools for project issues, group issues, and merge requests", async () => { + const { repoRoot, tools } = await setupTools(); + try { + for (const name of [ + "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", + ]) { + expect(tools.get(name), name).toBeTruthy(); + expect(JSON.stringify(tools.get(name).parameters)).toContain(name.includes("group") ? "group" : "project"); + expect(tools.get(name).description).toMatch(/GitLab/); + } + } finally { + await rm(repoRoot, { recursive: true, force: true }); + } + }); + + it("imports GitLab project, group, and MR resources with source and tracking metadata", async () => { + const { repoRoot, cwd, tools } = await setupTools(); + try { + const project = await tools.get("fn_task_import_gitlab_project_issues").execute("p", { project: "g/p", limit: 1 }, undefined, undefined, { cwd }); + const group = await tools.get("fn_task_import_gitlab_group_issues").execute("g", { group: "g", limit: 1 }, undefined, undefined, { cwd }); + const mr = await tools.get("fn_task_import_gitlab_merge_requests").execute("m", { project: "g/p", limit: 1 }, undefined, undefined, { cwd }); + + expect(project.content[0].text).toContain("Imported 1 GitLab project issue tasks"); + expect(group.content[0].text).toContain("Imported 1 GitLab group issue tasks"); + expect(mr.content[0].text).toContain("Imported 1 GitLab merge request tasks"); + + const taskStore = new TaskStore(repoRoot, undefined, { inMemoryDb: false }); + await taskStore.init(); + const tasks = await taskStore.listTasks({ slim: false }); + expect(tasks.map((task) => task.sourceIssue?.provider)).toEqual(["gitlab", "gitlab", "gitlab"]); + expect(tasks.map((task) => task.gitlabTracking?.item?.kind)).toEqual(["project_issue", "group_issue", "merge_request"]); + expect(tasks.find((task) => task.gitlabTracking?.item?.kind === "group_issue")?.gitlabTracking?.item?.groupPath).toBe("g"); + expect(tasks.find((task) => task.gitlabTracking?.item?.kind === "merge_request")?.title).toMatch(/^Review MR !9:/); + taskStore.close(); + } finally { + await rm(repoRoot, { recursive: true, force: true }); + } + }); + + it("returns human-readable auth errors without leaking token-like input", async () => { + const { repoRoot, cwd, tools } = await setupTools(); + try { + const taskStore = new TaskStore(repoRoot, undefined, { inMemoryDb: false }); + await taskStore.init(); + await taskStore.updateSettings({ gitlabAuthToken: null as any }); + taskStore.close(); + + await expect(tools.get("fn_task_browse_gitlab_project_issues").execute("p", { project: "g/p" }, undefined, undefined, { cwd })) + .rejects.toThrow("GitLab auth requires a configured access token"); + } finally { + await rm(repoRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/__tests__/task-command-gitlab-import.test.ts b/packages/cli/src/__tests__/task-command-gitlab-import.test.ts index 33d5aa6cc6..8126055f71 100644 --- a/packages/cli/src/__tests__/task-command-gitlab-import.test.ts +++ b/packages/cli/src/__tests__/task-command-gitlab-import.test.ts @@ -47,13 +47,17 @@ vi.mock("@fusion/dashboard", () => { 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 } }), + 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 }, gitlabTracking: { item: { kind: resourceType, iid: item.iid, url: item.webUrl ?? item.web_url, host: "gitlab.example.com", instanceUrl: "https://gitlab.example.com", projectId: item.project_id, projectPath: item.projectPath, title: item.title, state: item.state } }, 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"; +/* +FNXC:GitLabCLI 2026-07-02-00:00: +GitLab CLI import support is HTTP API based through configured tokens and must not require `glab`, a downloaded binary, or real GitLab network calls. These tests mock the GitLab client seam and assert source/tracking metadata is created locally. +*/ describe("fn task import-gitlab", () => { let fetchSpy: ReturnType; @@ -78,6 +82,7 @@ describe("fn task import-gitlab", () => { expect(fetchSpy.mock.calls[0][0]).toContain("/projects/g%2Fp/issues?"); expect(mocks.createTask).toHaveBeenCalledWith(expect.objectContaining({ sourceIssue: expect.objectContaining({ provider: "gitlab", issueNumber: 2 }), + gitlabTracking: expect.objectContaining({ item: expect.objectContaining({ kind: "project_issue", iid: 2 }) }), source: expect.objectContaining({ sourceType: "gitlab_import", sourceMetadata: expect.objectContaining({ resourceType: "project_issue" }) }), })); }); @@ -88,10 +93,15 @@ describe("fn task import-gitlab", () => { expect(mocks.createTask).not.toHaveBeenCalled(); }); - it("uses group issue and merge request endpoints", async () => { - await runTaskImportFromGitLab("g", { resource: "group-issues", limit: 1 }); + it("uses group issue and merge request endpoints with filters and review-task metadata", async () => { + await runTaskImportFromGitLab("g", { resource: "group-issues", limit: 1, labels: ["ops"] }); expect(fetchSpy.mock.calls.at(-1)?.[0]).toContain("/groups/g/issues?"); - await runTaskImportFromGitLab("g/p", { resource: "merge-requests", limit: 1 }); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify([{ id: 9, iid: 2, project_id: 3, title: "GitLab MR", description: "MR", webUrl: "https://gitlab.example.com/g/p/-/merge_requests/2", web_url: "https://gitlab.example.com/g/p/-/merge_requests/2", state: "opened", labels: [], source_branch: "feat", target_branch: "main" }]), { status: 200 })); + await runTaskImportFromGitLab("g/p", { resource: "merge-requests", limit: 1, labels: ["review"] }); expect(fetchSpy.mock.calls.at(-1)?.[0]).toContain("/projects/g%2Fp/merge_requests?"); + expect(mocks.createTask.mock.calls.at(-1)?.[0]).toEqual(expect.objectContaining({ + title: expect.stringMatching(/^Review MR !2:/), + gitlabTracking: expect.objectContaining({ item: expect.objectContaining({ kind: "merge_request" }) }), + })); }); }); diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 1e91f5793f..0f90ad81a4 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -1568,6 +1568,7 @@ export async function runTaskImportFromGitLab( column: "triage", dependencies: [], sourceIssue: provenance.sourceIssue, + gitlabTracking: provenance.gitlabTracking, 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); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 7f577ea74b..98f1ba9b1f 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -1875,7 +1875,7 @@ export default function kbExtension(pi: ExtensionAPI) { 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 } }); + const task = await store.createTask({ title: title || undefined, description: dashboard.buildGitLabTaskDescription(item), column: "triage", dependencies: [], sourceIssue: provenance.sourceIssue, gitlabTracking: provenance.gitlabTracking, 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 }); diff --git a/packages/core/src/__tests__/gitlab-issue-analytics.test.ts b/packages/core/src/__tests__/gitlab-issue-analytics.test.ts new file mode 100644 index 0000000000..5cb1aeb8a8 --- /dev/null +++ b/packages/core/src/__tests__/gitlab-issue-analytics.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { aggregateGitlabIssueAnalytics } from "../gitlab-issue-analytics.js"; + +function insertTrackedItem( + db: Database, + id: string, + item: Record, + updatedAt = "2026-07-01T00:00:00.000Z", +): void { + db.prepare( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt, gitlabTracking) + VALUES (?, 'desc', 'todo', ?, ?, ?)`, + ).run(id, updatedAt, updatedAt, JSON.stringify({ item })); +} + +function insertRawTracking(db: Database, id: string, gitlabTracking: string): void { + db.prepare( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt, gitlabTracking) + VALUES (?, 'desc', 'todo', '2026-07-01T00:00:00.000Z', '2026-07-01T00:00:00.000Z', ?)`, + ).run(id, gitlabTracking); +} + +function insertGithubTrackedIssue(db: Database, id: string): void { + db.prepare( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt, githubTracking) + VALUES (?, 'desc', 'todo', '2026-07-02T00:00:00.000Z', '2026-07-02T00:00:00.000Z', ?)`, + ).run(id, JSON.stringify({ issue: { owner: "octo", repo: "repo", number: 1, createdAt: "2026-07-02T00:00:00.000Z" } })); +} + +function insertSourceIssueTask( + db: Database, + id: string, + opts: { + provider: string; + repository: string | null; + column: string; + updatedAt: string; + closedAt?: string | null; + issueNumber?: number | null; + url?: string | null; + title?: string | null; + }, +): void { + db.prepare( + `INSERT INTO tasks ( + id, title, description, "column", createdAt, updatedAt, + sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, + sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt + ) VALUES (?, ?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + id, + opts.title ?? null, + opts.column, + opts.updatedAt, + opts.updatedAt, + opts.provider, + opts.repository, + String(opts.issueNumber ?? 1), + opts.issueNumber === undefined ? 1 : opts.issueNumber, + opts.url === undefined ? `https://gitlab.example.test/${id}` : opts.url, + opts.closedAt ?? null, + ); +} + +describe("gitlab-issue-analytics", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-gitlab-issue-analytics-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("aggregates filed/fixed GitLab totals, daily buckets, projects, and provider isolation", () => { + insertTrackedItem(db, "filed-project", { + kind: "project_issue", + iid: 10, + projectPath: "acme/alpha", + createdAt: "2026-07-01T12:00:00.000Z", + }); + insertTrackedItem(db, "filed-group", { + kind: "group_issue", + iid: 11, + groupPath: "platform/group", + createdAt: "2026-07-02T12:00:00.000Z", + }); + insertTrackedItem(db, "filed-mr", { + kind: "merge_request", + iid: 12, + projectPath: "acme/alpha", + createdAt: "2026-07-02T13:00:00.000Z", + }); + insertTrackedItem(db, "filed-old", { + kind: "project_issue", + iid: 9, + projectPath: "acme/old", + createdAt: "2026-06-01T00:00:00.000Z", + }); + insertGithubTrackedIssue(db, "github-filed"); + + insertSourceIssueTask(db, "fixed-project", { + provider: "gitlab", + repository: "acme/alpha", + column: "done", + updatedAt: "2026-07-02T20:00:00.000Z", + issueNumber: 20, + }); + insertSourceIssueTask(db, "fixed-mr", { + provider: "gitlab", + repository: "acme/beta", + column: "done", + updatedAt: "2026-07-03T20:00:00.000Z", + closedAt: "2026-07-03T21:00:00.000Z", + issueNumber: 21, + }); + insertSourceIssueTask(db, "not-done", { + provider: "gitlab", + repository: "acme/alpha", + column: "todo", + updatedAt: "2026-07-02T20:00:00.000Z", + issueNumber: 22, + }); + insertSourceIssueTask(db, "not-gitlab", { + provider: "github", + repository: "acme/alpha", + column: "done", + updatedAt: "2026-07-02T20:00:00.000Z", + issueNumber: 23, + }); + + const result = aggregateGitlabIssueAnalytics(db, { + from: "2026-07-01T00:00:00.000Z", + to: "2026-07-03T23:59:59.999Z", + }); + + expect(result.filed).toBe(3); + expect(result.fixed).toBe(2); + expect(result.net).toBe(1); + expect(result.daily).toEqual([ + { date: "2026-07-01", filed: 1, fixed: 0 }, + { date: "2026-07-02", filed: 2, fixed: 1 }, + { date: "2026-07-03", filed: 0, fixed: 1 }, + ]); + expect(result.byProject).toEqual([ + { project: "acme/alpha", filed: 2, fixed: 1 }, + { project: "acme/beta", filed: 0, fixed: 1 }, + { project: "platform/group", filed: 1, fixed: 0 }, + ]); + expect(result.resolved).toHaveLength(result.fixed); + }); + + it("returns resolved rows using exact closedAt before updatedAt fallback", () => { + insertSourceIssueTask(db, "resolved-exact-later", { + provider: "gitlab", + repository: "acme/alpha", + column: "done", + updatedAt: "2026-07-01T00:00:00.000Z", + closedAt: "2026-07-03T10:00:00.000Z", + issueNumber: 42, + url: "https://gitlab.example.test/acme/alpha/-/issues/42", + title: "Fix alpha crash", + }); + insertSourceIssueTask(db, "resolved-fallback", { + provider: "gitlab", + repository: null, + column: "done", + updatedAt: "2026-07-02T10:00:00.000Z", + closedAt: null, + issueNumber: null, + url: null, + title: "Resolve historical import", + }); + insertSourceIssueTask(db, "closed-out-of-range", { + provider: "gitlab", + repository: "acme/old", + column: "done", + updatedAt: "2026-07-02T10:00:00.000Z", + closedAt: "2026-06-30T23:59:59.999Z", + issueNumber: 44, + }); + insertSourceIssueTask(db, "not-gitlab-source", { + provider: "github", + repository: "acme/alpha", + column: "done", + updatedAt: "2026-07-03T10:00:00.000Z", + issueNumber: 46, + }); + + const result = aggregateGitlabIssueAnalytics(db, { + from: "2026-07-01T00:00:00.000Z", + to: "2026-07-03T23:59:59.999Z", + }); + + expect(result.fixed).toBe(2); + expect(result.resolved).toEqual([ + { + taskId: "resolved-exact-later", + taskTitle: "Fix alpha crash", + project: "acme/alpha", + issueNumber: 42, + url: "https://gitlab.example.test/acme/alpha/-/issues/42", + resolvedAt: "2026-07-03T10:00:00.000Z", + resolvedAtExact: true, + }, + { + taskId: "resolved-fallback", + taskTitle: "Resolve historical import", + project: "(unknown)", + issueNumber: null, + url: null, + resolvedAt: "2026-07-02T10:00:00.000Z", + resolvedAtExact: false, + }, + ]); + }); + + it("ignores malformed and non-item GitLab tracking rows without fabricating dates", () => { + insertRawTracking(db, "malformed", "{nope"); + insertRawTracking(db, "missing-item", JSON.stringify({ item: { projectPath: "acme/missing-iid" } })); + insertTrackedItem(db, "undated", { kind: "project_issue", iid: 77, projectPath: "acme/undated" }); + + const result = aggregateGitlabIssueAnalytics(db, { + from: "2026-07-01T00:00:00.000Z", + to: "2026-07-03T23:59:59.999Z", + }); + + expect(result.filed).toBe(1); + expect(result.daily).toEqual([]); + expect(result.byProject).toEqual([{ project: "acme/undated", filed: 1, fixed: 0 }]); + }); +}); diff --git a/packages/core/src/__tests__/gitlab-source-issue-storage.test.ts b/packages/core/src/__tests__/gitlab-source-issue-storage.test.ts new file mode 100644 index 0000000000..5892af348e --- /dev/null +++ b/packages/core/src/__tests__/gitlab-source-issue-storage.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import type { TaskSourceIssue } from "../types.js"; +import { TaskStore } from "../store.js"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "kb-gitlab-source-storage-test-")); +} + +const projectIssue: TaskSourceIssue = { + provider: "gitlab", + repository: "group/subgroup/project", + externalIssueId: "987654", + issueNumber: 42, + url: "https://gitlab.com/group/subgroup/project/-/issues/42", + closedAt: "2026-07-02T10:00:00.000Z", +}; + +const groupIssue: TaskSourceIssue = { + provider: "gitlab", + repository: "platform/team-service", + externalIssueId: "123456", + issueNumber: 7, + url: "https://gitlab.example.test/platform/team-service/-/issues/7", +}; + +const mergeRequest: TaskSourceIssue = { + provider: "gitlab", + repository: "backend/api", + externalIssueId: "555001", + issueNumber: 99, + url: "https://gitlab.example.test/backend/api/-/merge_requests/99", + closedAt: "2026-07-02T11:00:00.000Z", +}; + +/* +FNXC:GitLabStorage 2026-07-02-00:00: +GitLab imports share the generic sourceIssue columns with GitHub, but provider rows must stay isolated. These tests preserve GitLab project/group/MR identity, self-managed URLs, IID-vs-global-id fields, and optional close timestamps without rewriting GitHub metadata. +*/ +describe("TaskStore GitLab source issue storage", () => { + let rootDir: string; + let globalDir: string; + let store: TaskStore; + + beforeEach(async () => { + rootDir = makeTmpDir(); + globalDir = makeTmpDir(); + store = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); + await store.init(); + }); + + afterEach(async () => { + store.close(); + await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + + it("round-trips GitLab project issues, group-backed issues, and merge requests", async () => { + const projectTask = await store.createTask({ description: "Project import", sourceIssue: projectIssue }); + const groupTask = await store.createTask({ description: "Group import", sourceIssue: groupIssue }); + const mrTask = await store.createTask({ description: "MR review", sourceIssue: mergeRequest }); + + expect((await store.getTask(projectTask.id)).sourceIssue).toEqual(projectIssue); + expect((await store.getTask(groupTask.id)).sourceIssue).toEqual(groupIssue); + expect((await store.getTask(mrTask.id)).sourceIssue).toEqual(mergeRequest); + + const allTasks = await store.listTasks(); + expect(allTasks.find((task) => task.id === projectTask.id)?.sourceIssue).toEqual(projectIssue); + expect(allTasks.find((task) => task.id === groupTask.id)?.sourceIssue).toEqual(groupIssue); + expect(allTasks.find((task) => task.id === mrTask.id)?.sourceIssue).toEqual(mergeRequest); + }); + + it("preserves encoded project paths, IID/global id split, and optional closedAt through updates", async () => { + const task = await store.createTask({ + description: "Encoded GitLab import", + sourceIssue: { + provider: "gitlab", + repository: "group%2Fsubgroup%2Fencoded-project", + externalIssueId: "444555666", + issueNumber: 101, + url: "https://gitlab.example.test/group/subgroup/encoded-project/-/issues/101", + }, + }); + + await store.updateTask(task.id, { sourceIssue: { ...projectIssue, closedAt: undefined } }); + expect((await store.getTask(task.id)).sourceIssue).toEqual({ ...projectIssue, closedAt: undefined }); + + await store.updateTask(task.id, { sourceIssue: mergeRequest }); + expect((await store.getTask(task.id)).sourceIssue).toEqual(mergeRequest); + + await store.updateTask(task.id, { sourceIssue: null }); + expect((await store.getTask(task.id)).sourceIssue).toBeUndefined(); + }); + + it("persists GitLab source metadata across disk-backed reopen, done, reopen, archive, and restore flows", async () => { + const diskRoot = makeTmpDir(); + const diskGlobal = makeTmpDir(); + try { + const first = new TaskStore(diskRoot, diskGlobal); + await first.init(); + const created = await first.createTask({ description: "Disk GitLab", sourceIssue: groupIssue }); + await first.moveTask(created.id, "todo"); + await first.moveTask(created.id, "in-progress"); + await first.moveTask(created.id, "done"); + first.close(); + + const second = new TaskStore(diskRoot, diskGlobal); + await second.init(); + const reopened = (await second.listTasks()).find((task) => task.description === "Disk GitLab"); + expect(reopened?.sourceIssue).toEqual(groupIssue); + + await second.moveTask(reopened!.id, "todo"); + expect((await second.getTask(reopened!.id)).sourceIssue).toEqual(groupIssue); + + await second.archiveTask(reopened!.id, false); + const restored = await second.unarchiveTask(reopened!.id); + expect(restored.sourceIssue).toEqual(groupIssue); + second.close(); + } finally { + await rm(diskRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + await rm(diskGlobal, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); +}); diff --git a/packages/core/src/__tests__/store-gitlab-tracking-reconcile.test.ts b/packages/core/src/__tests__/store-gitlab-tracking-reconcile.test.ts new file mode 100644 index 0000000000..820c36fb73 --- /dev/null +++ b/packages/core/src/__tests__/store-gitlab-tracking-reconcile.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { TaskStore } from "../store.js"; +import type { TaskGitLabTrackedItem } from "../types.js"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "kb-store-gitlab-reconcile-test-")); +} + +const gitlabItem: TaskGitLabTrackedItem = { + kind: "project_issue", + url: "https://gitlab.com/acme/app/-/issues/42", + instanceUrl: "https://gitlab.com", + host: "gitlab.com", + iid: 42, + projectPath: "acme/app", + title: "Tracked GitLab issue", + state: "opened", +}; + +describe("TaskStore.listTasksForGitlabTrackingReconcile", () => { + let rootDir: string; + let globalDir: string; + let store: TaskStore; + + beforeEach(async () => { + rootDir = makeTmpDir(); + globalDir = makeTmpDir(); + store = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); + await store.init(); + }); + + afterEach(async () => { + store.close(); + await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + + it("returns soft-deleted and archived tasks with GitLab tracking only", async () => { + const softDeleted = await store.createTask({ description: "soft deleted", gitlabTracking: { item: gitlabItem } }); + await store.deleteTask(softDeleted.id); + + const archivedDone = await store.createTask({ description: "archived done", gitlabTracking: { item: gitlabItem } }); + await store.moveTask(archivedDone.id, "todo"); + await store.moveTask(archivedDone.id, "in-progress"); + await store.moveTask(archivedDone.id, "in-review"); + await store.moveTask(archivedDone.id, "done"); + await store.archiveTask(archivedDone.id); + + const archivedTodo = await store.createTask({ description: "archived todo", gitlabTracking: { item: gitlabItem } }); + await store.moveTask(archivedTodo.id, "todo"); + await store.moveTask(archivedTodo.id, "in-progress"); + await store.moveTask(archivedTodo.id, "in-review"); + await store.moveTask(archivedTodo.id, "done"); + await store.archiveTask(archivedTodo.id); + + const archivedTodoEntry = (store as unknown as { + archiveDb: { get: (id: string) => { executionCompletedAt?: string } | undefined; upsert: (entry: Record) => void }; + }).archiveDb.get(archivedTodo.id); + if (archivedTodoEntry) { + (store as unknown as { archiveDb: { upsert: (entry: Record) => void } }).archiveDb.upsert({ + ...archivedTodoEntry, + executionCompletedAt: undefined, + }); + } + + const activeTracked = await store.createTask({ description: "active tracked", gitlabTracking: { item: gitlabItem } }); + const githubOnly = await store.createTask({ + description: "github deleted", + githubTracking: { enabled: true, issue: { owner: "octo", repo: "repo", number: 1, url: "https://github.com/octo/repo/issues/1" } }, + }); + await store.deleteTask(githubOnly.id); + + const { tasks, hasMore } = await store.listTasksForGitlabTrackingReconcile(); + const byId = new Map(tasks.map((task) => [task.id, task])); + + expect(byId.has(softDeleted.id)).toBe(true); + expect(byId.has(archivedDone.id)).toBe(true); + expect(byId.has(archivedTodo.id)).toBe(true); + expect(byId.get(archivedDone.id)?.executionCompletedAt).toBeTruthy(); + expect(byId.get(archivedTodo.id)?.executionCompletedAt).toBeFalsy(); + expect(byId.has(activeTracked.id)).toBe(false); + expect(byId.has(githubOnly.id)).toBe(false); + expect(hasMore).toBe(false); + }); + + it("returns empty results when nothing matches", async () => { + const task = await store.createTask({ description: "no gitlab tracking" }); + await store.moveTask(task.id, "todo"); + + await expect(store.listTasksForGitlabTrackingReconcile()).resolves.toEqual({ tasks: [], hasMore: false }); + }); + + it("paginates across soft-deleted and archived GitLab tracked entries", async () => { + for (let i = 0; i < 3; i += 1) { + const task = await store.createTask({ description: `deleted ${i}`, gitlabTracking: { item: { ...gitlabItem, iid: 100 + i } } }); + await store.deleteTask(task.id); + } + + for (let i = 0; i < 3; i += 1) { + const task = await store.createTask({ description: `archived ${i}`, gitlabTracking: { item: { ...gitlabItem, iid: 200 + i } } }); + await store.moveTask(task.id, "todo"); + await store.moveTask(task.id, "in-progress"); + await store.moveTask(task.id, "in-review"); + await store.moveTask(task.id, "done"); + await store.archiveTask(task.id); + } + + const page1 = await store.listTasksForGitlabTrackingReconcile({ offset: 0, limit: 2 }); + const page2 = await store.listTasksForGitlabTrackingReconcile({ offset: 2, limit: 2 }); + const page3 = await store.listTasksForGitlabTrackingReconcile({ offset: 4, limit: 2 }); + + expect(page1.tasks).toHaveLength(2); + expect(page2.tasks).toHaveLength(2); + expect(page3.tasks).toHaveLength(2); + expect(new Set([...page1.tasks, ...page2.tasks, ...page3.tasks].map((task) => task.id)).size).toBe(6); + expect(page1.hasMore).toBe(true); + expect(page2.hasMore).toBe(true); + expect(page3.hasMore).toBe(false); + }); +}); diff --git a/packages/core/src/gitlab-issue-analytics.ts b/packages/core/src/gitlab-issue-analytics.ts new file mode 100644 index 0000000000..65f7554aa3 --- /dev/null +++ b/packages/core/src/gitlab-issue-analytics.ts @@ -0,0 +1,227 @@ +import type { Database } from "./db.js"; + +/** + * FNXC:CommandCenterGitLab 2026-07-02-00:00: + * GitLab analytics must be provider-isolated while sharing the generic sourceIssue storage columns with GitHub. Filed counts read only `gitlabTracking.item`; fixed counts read only `sourceIssueProvider = "gitlab"`, using exact `sourceIssueClosedAt` when present and `updatedAt` only as an approximation fallback. + */ +export interface GitlabIssueAnalyticsQuery { + /** ISO-8601 lower bound (inclusive). */ + from?: string; + /** ISO-8601 upper bound (inclusive). */ + to?: string; +} + +export interface GitlabIssueDailyPoint { + /** UTC date, `YYYY-MM-DD`. */ + date: string; + /** Fusion-created GitLab items filed on this date. */ + filed: number; + /** Imported GitLab issue/MR tasks completed on this date. */ + fixed: number; +} + +export interface GitlabIssueProjectBreakdown { + /** GitLab project/group key; `(unknown)` when historical data lacks it. */ + project: string; + filed: number; + fixed: number; +} + +export interface GitlabResolvedIssue { + /** Fusion task that resolved the imported GitLab source item. */ + taskId: string; + /** Fusion task title at aggregation time. */ + taskTitle: string; + /** GitLab project/group key; `(unknown)` when historical data lacks it. */ + project: string; + /** GitLab issue or merge request IID when stored. */ + issueNumber: number | null; + /** Source GitLab item URL when available. */ + url: string | null; + /** ISO timestamp used for range filtering and ordering. */ + resolvedAt: string; + /** True when `sourceIssueClosedAt` supplied `resolvedAt`; false when `updatedAt` was used. */ + resolvedAtExact: boolean; +} + +export interface GitlabIssueAnalytics { + from: string | null; + to: string | null; + /** Fusion-created GitLab tracked items in range. Undated tracked items are included because no date can be honestly inferred. */ + filed: number; + /** Imported GitLab source tasks currently in `done`, filtered by exact `sourceIssueClosedAt` when present with `updatedAt` fallback. */ + fixed: number; + /** Filed minus fixed. */ + net: number; + /** Filed/fixed counts grouped by UTC day, ascending. */ + daily: GitlabIssueDailyPoint[]; + /** Filed/fixed counts grouped by GitLab project/group, descending by total activity. */ + byProject: GitlabIssueProjectBreakdown[]; + /** Imported GitLab source items completed in range, most-recently resolved first. */ + resolved: GitlabResolvedIssue[]; +} + +interface GitlabTrackingRow { + gitlabTracking: string | null; +} + +interface FixedIssueRow { + id: string; + title: string | null; + sourceIssueRepository: string | null; + sourceIssueNumber: number | null; + sourceIssueUrl: string | null; + sourceIssueClosedAt: string | null; + updatedAt: string | null; +} + +interface GitlabTrackedItemLike { + iid?: unknown; + projectPath?: unknown; + groupPath?: unknown; + projectId?: unknown; + createdAt?: unknown; +} + +interface GitlabTrackingLike { + item?: GitlabTrackedItemLike; +} + +function isInRange(iso: string, query: GitlabIssueAnalyticsQuery): boolean { + const t = Date.parse(iso); + if (!Number.isFinite(t)) return false; + if (query.from !== undefined && t < Date.parse(query.from)) return false; + if (query.to !== undefined && t > Date.parse(query.to)) return false; + return true; +} + +function dayKey(iso: string): string | null { + const t = Date.parse(iso); + if (!Number.isFinite(t)) return null; + return new Date(t).toISOString().slice(0, 10); +} + +function projectFromItem(item: GitlabTrackedItemLike): string { + const projectPath = typeof item.projectPath === "string" ? item.projectPath.trim() : ""; + if (projectPath) return projectPath; + const groupPath = typeof item.groupPath === "string" ? item.groupPath.trim() : ""; + if (groupPath) return groupPath; + if (typeof item.projectId === "number" && Number.isFinite(item.projectId)) return String(item.projectId); + return "(unknown)"; +} + +function addDaily( + daily: Map, + date: string, + kind: "filed" | "fixed", +): void { + const current = daily.get(date) ?? { filed: 0, fixed: 0 }; + current[kind] += 1; + daily.set(date, current); +} + +function addProject( + byProject: Map, + project: string, + kind: "filed" | "fixed", +): void { + const current = byProject.get(project) ?? { filed: 0, fixed: 0 }; + current[kind] += 1; + byProject.set(project, current); +} + +/** + * Aggregate locally persisted GitLab issue and merge-request analytics for the Command Center. + * Bounds are inclusive. Malformed historical `gitlabTracking` JSON is ignored rather than + * failing the entire analytics request. + */ +export function aggregateGitlabIssueAnalytics( + db: Database, + query: GitlabIssueAnalyticsQuery = {}, +): GitlabIssueAnalytics { + const daily = new Map(); + const byProject = new Map(); + + const filedRows = db + .prepare( + "SELECT gitlabTracking FROM tasks WHERE gitlabTracking IS NOT NULL AND gitlabTracking NOT IN ('', '{}')", + ) + .all() as GitlabTrackingRow[]; + + let filed = 0; + for (const row of filedRows) { + if (!row.gitlabTracking) continue; + let parsed: unknown; + try { + parsed = JSON.parse(row.gitlabTracking); + } catch { + continue; + } + const item = (parsed as GitlabTrackingLike).item; + if (!item || typeof item.iid !== "number" || !Number.isFinite(item.iid)) continue; + + const createdAt = typeof item.createdAt === "string" ? item.createdAt : undefined; + const hasUsableDate = createdAt !== undefined && dayKey(createdAt) !== null; + if (hasUsableDate && !isInRange(createdAt, query)) continue; + + filed += 1; + const project = projectFromItem(item); + addProject(byProject, project, "filed"); + if (hasUsableDate && createdAt !== undefined) { + const day = dayKey(createdAt); + if (day !== null) addDaily(daily, day, "filed"); + } + } + + const fixedRows = db + .prepare( + `SELECT id, title, sourceIssueRepository, sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt, updatedAt FROM tasks WHERE sourceIssueProvider = 'gitlab' AND "column" = 'done'`, + ) + .all() as FixedIssueRow[]; + + let fixed = 0; + const resolved: GitlabResolvedIssue[] = []; + for (const row of fixedRows) { + const hasExactResolvedAt = row.sourceIssueClosedAt !== null; + const fixedDate = row.sourceIssueClosedAt ?? row.updatedAt; + if (fixedDate === null || !isInRange(fixedDate, query)) continue; + + fixed += 1; + const project = row.sourceIssueRepository?.trim() || "(unknown)"; + addProject(byProject, project, "fixed"); + const day = dayKey(fixedDate); + if (day !== null) addDaily(daily, day, "fixed"); + resolved.push({ + taskId: row.id, + taskTitle: row.title ?? "", + project, + issueNumber: typeof row.sourceIssueNumber === "number" ? row.sourceIssueNumber : null, + url: row.sourceIssueUrl?.trim() || null, + resolvedAt: fixedDate, + resolvedAtExact: hasExactResolvedAt, + }); + } + + resolved.sort((a, b) => { + const byDate = Date.parse(b.resolvedAt) - Date.parse(a.resolvedAt); + return byDate !== 0 ? byDate : a.taskId.localeCompare(b.taskId); + }); + + return { + from: query.from ?? null, + to: query.to ?? null, + filed, + fixed, + net: filed - fixed, + daily: [...daily.entries()] + .map(([date, counts]) => ({ date, filed: counts.filed, fixed: counts.fixed })) + .sort((a, b) => a.date.localeCompare(b.date)), + byProject: [...byProject.entries()] + .map(([project, counts]) => ({ project, filed: counts.filed, fixed: counts.fixed })) + .sort((a, b) => { + const total = b.filed + b.fixed - (a.filed + a.fixed); + return total !== 0 ? total : a.project.localeCompare(b.project); + }), + resolved, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 54d6addacb..640055ece1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -699,6 +699,14 @@ export type { GithubIssueRepoBreakdown, GithubResolvedIssue, } from "./github-issue-analytics.js"; +export { aggregateGitlabIssueAnalytics } from "./gitlab-issue-analytics.js"; +export type { + GitlabIssueAnalytics, + GitlabIssueAnalyticsQuery, + GitlabIssueDailyPoint, + GitlabIssueProjectBreakdown, + GitlabResolvedIssue, +} from "./gitlab-issue-analytics.js"; export { aggregateSignalsAnalytics } from "./activity-analytics.js"; export type { SignalSourceCount, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index d348593d60..bf543ed627 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -6326,21 +6326,32 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } async listTasksForGithubTrackingReconcile(options?: { offset?: number; limit?: number }): Promise<{ tasks: Task[]; hasMore: boolean }> { + return this.listTasksForProviderTrackingReconcile("githubTracking", options); + } + + async listTasksForGitlabTrackingReconcile(options?: { offset?: number; limit?: number }): Promise<{ tasks: Task[]; hasMore: boolean }> { + return this.listTasksForProviderTrackingReconcile("gitlabTracking", options); + } + + private listTasksForProviderTrackingReconcile( + trackingColumn: "githubTracking" | "gitlabTracking", + options?: { offset?: number; limit?: number }, + ): { tasks: Task[]; hasMore: boolean } { const reconcileScanLimit = 200; const offset = Math.max(0, options?.offset ?? 0); const limit = Math.max(0, options?.limit ?? reconcileScanLimit); const selectClause = this.getTaskSelectClause(true); - // FN-5577: GitHub tracking reconciliation must inspect soft-deleted rows, - // so this query intentionally bypasses ACTIVE_TASKS_WHERE. + // FN-5577/FN-7428: Provider tracking reconciliation must inspect soft-deleted rows, + // so this query intentionally bypasses ACTIVE_TASKS_WHERE while keeping GitHub and GitLab sweeps isolated. const deletedTotal = this.db.prepare( - "SELECT COUNT(*) as count FROM tasks WHERE \"deletedAt\" IS NOT NULL AND \"githubTracking\" IS NOT NULL", + `SELECT COUNT(*) as count FROM tasks WHERE "deletedAt" IS NOT NULL AND "${trackingColumn}" IS NOT NULL`, ).get() as { count: number } | undefined; const deletedCount = Number(deletedTotal?.count ?? 0); const deletedOffset = Math.min(offset, deletedCount); const deletedRows = this.db.prepare( - `SELECT ${selectClause} FROM tasks WHERE "deletedAt" IS NOT NULL AND "githubTracking" IS NOT NULL ORDER BY updatedAt ASC LIMIT ? OFFSET ?`, + `SELECT ${selectClause} FROM tasks WHERE "deletedAt" IS NOT NULL AND "${trackingColumn}" IS NOT NULL ORDER BY updatedAt ASC LIMIT ? OFFSET ?`, ).all(limit, deletedOffset) as unknown as TaskRow[]; const deletedTasks = deletedRows.map((row) => { @@ -6356,7 +6367,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} const archivedCandidates = this.archiveDb .list() .map((entry) => this.archiveEntryToTask(entry, true)) - .filter((task) => Boolean(task.githubTracking)); + .filter((task) => Boolean(task[trackingColumn])); archivedCount = archivedCandidates.length; const archivedOffset = Math.max(0, offset - deletedCount); diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx index 76cfd97202..2e31fe917c 100644 --- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx @@ -10,7 +10,11 @@ import { apiCloseGitHubIssue, apiImportGitHubPull, apiFetchGitLabProjectIssues, + apiFetchGitLabGroupIssues, + apiFetchGitLabMergeRequests, apiImportGitLabProjectIssue, + apiImportGitLabGroupIssue, + apiImportGitLabMergeRequest, fetchGitRemotes, } from "../../api"; import type { Task } from "@fusion/core"; @@ -159,7 +163,11 @@ describe("GitHubImportModal", () => { vi.mocked(apiCloseGitHubIssue).mockReset(); vi.mocked(apiImportGitHubPull).mockReset(); vi.mocked(apiFetchGitLabProjectIssues).mockReset(); + vi.mocked(apiFetchGitLabGroupIssues).mockReset(); + vi.mocked(apiFetchGitLabMergeRequests).mockReset(); vi.mocked(apiImportGitLabProjectIssue).mockReset(); + vi.mocked(apiImportGitLabGroupIssue).mockReset(); + vi.mocked(apiImportGitLabMergeRequest).mockReset(); // Set default mock for apiFetchGitHubIssues to return empty array (prevents undefined issues state) vi.mocked(apiFetchGitHubIssues).mockResolvedValue([]); vi.mocked(apiFetchGitHubPulls).mockResolvedValue([]); @@ -167,7 +175,11 @@ describe("GitHubImportModal", () => { vi.mocked(apiFetchGitHubIssueDetail).mockResolvedValue({ comments: [] }); vi.mocked(apiCloseGitHubIssue).mockResolvedValue(undefined); vi.mocked(apiFetchGitLabProjectIssues).mockResolvedValue([]); + vi.mocked(apiFetchGitLabGroupIssues).mockResolvedValue([]); + vi.mocked(apiFetchGitLabMergeRequests).mockResolvedValue([]); vi.mocked(apiImportGitLabProjectIssue).mockResolvedValue(mockTask); + vi.mocked(apiImportGitLabGroupIssue).mockResolvedValue(mockTask); + vi.mocked(apiImportGitLabMergeRequest).mockResolvedValue(mockTask); onClose.mockReset(); onImport.mockReset(); }); @@ -202,6 +214,40 @@ describe("GitHubImportModal", () => { expect(onImport).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-099" })); }); + it("fetches group issues and merge requests without GitHub-only copy", async () => { + vi.mocked(fetchGitRemotes).mockResolvedValue([]); + vi.mocked(apiFetchGitLabGroupIssues).mockResolvedValueOnce([ + { resourceKind: "group_issue", id: 3, iid: 7, projectId: 8, projectPath: "group/project", groupPath: "group", title: "Group issue", description: null, webUrl: "https://gitlab.example.com/group/project/-/issues/7", state: "opened", labels: [] }, + ]); + vi.mocked(apiFetchGitLabMergeRequests).mockResolvedValueOnce([ + { resourceKind: "merge_request", id: 4, iid: 5, projectId: 8, projectPath: "group/project", title: "Review me", description: "MR body", webUrl: "https://gitlab.example.com/group/project/-/merge_requests/5", state: "opened", labels: [], sourceBranch: "feat", targetBranch: "main" }, + ]); + vi.mocked(apiImportGitLabGroupIssue).mockResolvedValueOnce({ ...mockTask, id: "FN-100", title: "Group issue" }); + vi.mocked(apiImportGitLabMergeRequest).mockResolvedValueOnce({ ...mockTask, id: "FN-101", title: "Review MR !5: Review me" }); + + render(); + fireEvent.click(await screen.findByRole("button", { name: "GitLab" })); + + fireEvent.click(screen.getByRole("tab", { name: "Group issues" })); + fireEvent.change(screen.getByLabelText("GitLab group path or ID"), { target: { value: "group" } }); + fireEvent.click(screen.getByRole("button", { name: /Load/ })); + expect(await screen.findByText(/#7 Group issue/)).toBeInTheDocument(); + fireEvent.click(screen.getByText(/#7 Group issue/)); + expect(screen.getByTestId("gitlab-import-preview-body")).toHaveTextContent("(no description)"); + fireEvent.click(screen.getAllByRole("button", { name: "Import" })[0]); + await waitFor(() => expect(apiImportGitLabGroupIssue).toHaveBeenCalledWith(expect.objectContaining({ iid: 7 }), "group", undefined)); + + fireEvent.click(screen.getByRole("tab", { name: "Merge requests" })); + fireEvent.change(screen.getByLabelText("GitLab project path or ID"), { target: { value: "group/project" } }); + fireEvent.click(screen.getByRole("button", { name: /Load/ })); + expect(await screen.findByText(/!5 Review me/)).toBeInTheDocument(); + fireEvent.click(screen.getByText(/!5 Review me/)); + expect(screen.getByTestId("gitlab-import-preview-body")).toHaveTextContent("MR body"); + expect(screen.getByTestId("gitlab-import-panel").textContent).not.toContain("GitHub"); + fireEvent.click(screen.getAllByRole("button", { name: "Import" })[0]); + await waitFor(() => expect(apiImportGitLabMergeRequest).toHaveBeenCalledWith("group/project", 5, undefined)); + }); + it("does not render when isOpen is false", () => { render(); expect(screen.queryByText("Import from GitHub")).toBeNull(); diff --git a/packages/dashboard/app/components/__tests__/gitlabTracking.test.tsx b/packages/dashboard/app/components/__tests__/gitlabTracking.test.tsx new file mode 100644 index 0000000000..fc74a63d89 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/gitlabTracking.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { GitLabBadge, formatGitLabBadgeKind, formatGitLabBadgeMarker } from "../GitLabBadge"; +import type { TaskGitLabTrackedItem } from "@fusion/core"; + +const baseItem: TaskGitLabTrackedItem = { + kind: "project_issue", + url: "https://gitlab.example.test/group/project/-/issues/42", + instanceUrl: "https://gitlab.example.test", + host: "gitlab.example.test", + iid: 42, + projectPath: "group/project", + title: "Fix GitLab bug", + state: "opened", +}; + +describe("GitLab tracking UI helpers", () => { + it("formats provider-specific issue, group issue, and merge request badges", () => { + expect(formatGitLabBadgeKind(baseItem)).toBe("Issue"); + expect(formatGitLabBadgeMarker(baseItem)).toBe("#42"); + expect(formatGitLabBadgeKind({ kind: "group_issue" })).toBe("Group issue"); + expect(formatGitLabBadgeMarker({ kind: "group_issue", iid: 7 })).toBe("#7"); + expect(formatGitLabBadgeKind({ kind: "merge_request" })).toBe("MR"); + expect(formatGitLabBadgeMarker({ kind: "merge_request", iid: 5 })).toBe("!5"); + }); + + it("renders linked and stale GitLab badges without GitHub copy", () => { + const { rerender } = render(); + const badge = screen.getByTestId("card-gitlab-badge"); + expect(badge).toHaveAttribute("href", baseItem.url); + expect(badge).toHaveAttribute("aria-label", "GitLab Issue #42: Fix GitLab bug"); + expect(badge).toHaveTextContent("#42"); + expect(badge).not.toHaveTextContent("GitHub"); + + rerender(); + expect(screen.getByTestId("card-gitlab-badge")).toHaveAttribute("aria-label", "GitLab Issue #42: Fix GitLab bug — stale: GitLab sync failed"); + }); + + it("renders no empty shell when no GitLab metadata exists", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index 4a6c5b04c2..d8ed8a5318 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -13,6 +13,7 @@ import { TeamArea } from "./areas/TeamArea"; import { WorkflowArea } from "./areas/WorkflowArea"; import { EcosystemArea } from "./areas/EcosystemArea"; import { GithubArea } from "./areas/GithubArea"; +import { GitlabArea } from "./areas/GitlabArea"; import { SignalsArea } from "./areas/SignalsArea"; import { SystemStatsArea } from "./areas/SystemStatsArea"; import { MissionControlPanel } from "./MissionControlPanel"; @@ -40,6 +41,7 @@ type SubViewId = | "workflows" | "ecosystem" | "github" + | "gitlab" | "signals" | "system" | "nodes" @@ -82,6 +84,7 @@ function useSubViews(nodesEnabled: boolean): SubView[] { { id: "workflows", label: t("commandCenter.tabs.workflows", "Workflows") }, { id: "ecosystem", label: t("commandCenter.tabs.ecosystem", "Ecosystem") }, { id: "github", label: t("commandCenter.tabs.github", "GitHub") }, + { id: "gitlab", label: t("commandCenter.tabs.gitlab", "GitLab") }, { id: "signals", label: t("commandCenter.tabs.signals", "Signals") }, { id: "system", label: t("commandCenter.tabs.system", "System") }, ...(nodesEnabled ? [{ id: "nodes" as const, label: t("commandCenter.tabs.nodes", "Nodes") }] : []), @@ -574,6 +577,8 @@ export function CommandCenter({ return ; case "github": return ; + case "gitlab": + return ; case "signals": return ; case "system": diff --git a/packages/dashboard/app/components/command-center/areas/GitlabArea.tsx b/packages/dashboard/app/components/command-center/areas/GitlabArea.tsx new file mode 100644 index 0000000000..55ced4ac4c --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/GitlabArea.tsx @@ -0,0 +1,126 @@ +/* +FNXC:CommandCenterGitLab 2026-07-02-00:00: +The GitLab Command Center area mirrors GitHub issue-flow semantics but reads only the local `/command-center/gitlab` analytics endpoint. Rendering must not call GitLab.com, self-managed GitLab instances, `glab`, or any provider API; exact close-time repair remains an explicit backfill route outside render-time analytics. +*/ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { GitlabIssueAnalytics } from "@fusion/core"; +import type { DateRange } from "../DateRangePicker"; +import { Bar } from "../charts/Bar"; +import { Sparkline } from "../charts/Sparkline"; +import { AreaShell } from "./AreaShell"; +import { useAnalyticsArea } from "./useAnalyticsArea"; +import { formatCount } from "./areaShared"; + +function formatResolvedAt(value: string, fallback: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return fallback; + return date.toLocaleString(); +} + +export function GitlabArea({ range }: { range: DateRange }) { + const { t } = useTranslation("app"); + const { data, isLoading, error } = useAnalyticsArea( + "/command-center/gitlab", + range, + ); + + const daily = useMemo(() => data?.daily ?? [], [data?.daily]); + const byProject = useMemo(() => data?.byProject ?? [], [data?.byProject]); + const resolved = useMemo(() => data?.resolved ?? [], [data?.resolved]); + const filedValues = useMemo(() => daily.map((d) => d.filed), [daily]); + const fixedValues = useMemo(() => daily.map((d) => d.fixed), [daily]); + const maxDaily = useMemo(() => Math.max(0, ...filedValues, ...fixedValues), [filedValues, fixedValues]); + const projectBars = useMemo( + () => byProject.slice(0, 12).map((project) => ({ + label: project.project, + value: project.filed + project.fixed, + valueLabel: t("commandCenter.gitlab.projectValue", "{{filed}} filed / {{fixed}} fixed", { + filed: formatCount(project.filed), + fixed: formatCount(project.fixed), + }), + })), + [byProject, t], + ); + + const filed = data?.filed ?? 0; + const fixed = data?.fixed ?? 0; + const net = data?.net ?? filed - fixed; + const isEmpty = !data || (filed === 0 && fixed === 0); + + return ( + +
+

{t("commandCenter.gitlab.totalsTitle", "GitLab issue and MR flow")}

+ {isEmpty ? ( + {t("commandCenter.gitlab.empty", "No GitLab issue or merge request activity in the selected range.")} + ) : null} +
+
+
{t("commandCenter.gitlab.filed", "Filed by Fusion")}
+
{formatCount(filed)}
+
+
+
{t("commandCenter.gitlab.fixed", "Fixed by Fusion")}
+
{formatCount(fixed)}
+ {t("commandCenter.gitlab.fixedApproximation", "Uses persisted GitLab close times when available")} +
+
+
{t("commandCenter.gitlab.net", "Net")}
+
{formatCount(net)}
+
+
+
+ +
+

{t("commandCenter.gitlab.dailyTitle", "Daily GitLab flow")}

+ {daily.length > 0 ? ( +
+
+

{t("commandCenter.gitlab.filedTrend", "Filed")}

+ +
+
+

{t("commandCenter.gitlab.fixedTrend", "Fixed")}

+ +
+
+ ) : {t("commandCenter.gitlab.noDaily", "No daily GitLab trend data yet.")}} +
+ +
+

{t("commandCenter.gitlab.projectsTitle", "Projects and groups")}

+ {projectBars.length > 0 ? : {t("commandCenter.gitlab.noProjects", "No GitLab project activity yet.")}} +
+ +
+

{t("commandCenter.gitlab.resolvedTitle", "Resolved GitLab source items")}

+ {resolved.length > 0 ? ( +
+ {resolved.slice(0, 12).map((item) => { + const key = item.issueNumber === null ? item.project : `${item.project}#${item.issueNumber}`; + const label = `${key} · ${item.taskId}`; + return ( +
+
+ {item.url ? {label} : {label}} +
{item.taskTitle}
+
+ + {formatResolvedAt(item.resolvedAt, item.resolvedAt)}{item.resolvedAtExact ? "" : ` ${t("commandCenter.gitlab.approximate", "(approx.)")}`} + +
+ ); + })} +
+ ) : {t("commandCenter.gitlab.noResolved", "No resolved GitLab source items in range.")}} +
+
+ ); +} diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.gitlab-signals.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.gitlab-signals.test.tsx new file mode 100644 index 0000000000..8cb5a2d92f --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.gitlab-signals.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; + +const mocks = vi.hoisted(() => ({ api: vi.fn() })); +vi.mock("../../../../api/legacy", () => ({ + api: (path: string, opts?: RequestInit) => mocks.api(path, opts), +})); + +import { GitlabArea } from "../GitlabArea"; +import { range7d } from "./areas.test-harness"; + +function gitlabFixture() { + return { + from: "2026-06-08", + to: null, + filed: 4, + fixed: 2, + net: 2, + daily: [ + { date: "2026-06-08", filed: 1, fixed: 0 }, + { date: "2026-06-09", filed: 3, fixed: 2 }, + ], + byProject: [ + { project: "group/project", filed: 3, fixed: 1 }, + { project: "platform/api", filed: 1, fixed: 1 }, + ], + resolved: [ + { + taskId: "FN-200", + taskTitle: "Fix GitLab issue", + project: "group/project", + issueNumber: 42, + url: "https://gitlab.example.test/group/project/-/issues/42", + resolvedAt: "2026-06-09T12:00:00.000Z", + resolvedAtExact: true, + }, + { + taskId: "FN-201", + taskTitle: "Review GitLab MR", + project: "platform/api", + issueNumber: 7, + url: "https://gitlab.example.test/platform/api/-/merge_requests/7", + resolvedAt: "2026-06-09T13:00:00.000Z", + resolvedAtExact: false, + }, + ], + }; +} + +beforeEach(() => { + mocks.api.mockReset(); +}); + +describe("GitlabArea", () => { + it("renders GitLab analytics from local dashboard API data only", async () => { + mocks.api.mockResolvedValue(gitlabFixture()); + + render(); + + await screen.findByTestId("cc-area-gitlab"); + expect(mocks.api).toHaveBeenCalledWith(expect.stringContaining("/command-center/gitlab"), undefined); + expect(screen.getByTestId("cc-gitlab-filed").textContent).toContain("4"); + expect(screen.getByTestId("cc-gitlab-fixed").textContent).toContain("2"); + expect(screen.getByTestId("cc-gitlab-net").textContent).toContain("2"); + expect(screen.getByText("group/project")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "group/project: 3 filed / 1 fixed" })).toBeInTheDocument(); + + const resolved = screen.getByTestId("cc-gitlab-resolved-list"); + expect(resolved.textContent).toContain("group/project#42"); + expect(resolved.textContent).toContain("platform/api#7"); + expect(resolved.textContent).toContain("approx"); + expect(within(resolved).getByRole("link", { name: /group\/project#42/ })).toHaveAttribute("href", "https://gitlab.example.test/group/project/-/issues/42"); + }); + + it("renders empty state without provider network calls", async () => { + mocks.api.mockResolvedValueOnce({ ...gitlabFixture(), filed: 0, fixed: 0, net: 0, daily: [], byProject: [], resolved: [] }); + render(); + + await screen.findByText("No GitLab issue or merge request activity in the selected range."); + expect(screen.queryByTestId("cc-gitlab-resolved-list")).toBeNull(); + expect(mocks.api).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard/src/__tests__/gitlab-source-issue-reconciler.test.ts b/packages/dashboard/src/__tests__/gitlab-source-issue-reconciler.test.ts new file mode 100644 index 0000000000..04ee0c164f --- /dev/null +++ b/packages/dashboard/src/__tests__/gitlab-source-issue-reconciler.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TaskStore, type Task } from "@fusion/core"; +import { GitLabSourceIssueReconciler } from "../gitlab-source-issue-reconciler.js"; + +const { mockResolveGitLabClient, mockGetProjectIssue, mockGetMergeRequest } = vi.hoisted(() => ({ + mockResolveGitLabClient: vi.fn(), + mockGetProjectIssue: vi.fn(), + mockGetMergeRequest: vi.fn(), +})); + +vi.mock("../gitlab-lifecycle.js", async () => { + const actual = await vi.importActual("../gitlab-lifecycle.js"); + return { + ...actual, + resolveGitLabClient: (...args: unknown[]) => mockResolveGitLabClient(...args), + }; +}); + +function createStore(listTasks: Task[]): TaskStore { + return { + listTasks: vi.fn().mockResolvedValue(listTasks), + updateTask: vi.fn().mockResolvedValue(undefined), + logEntry: vi.fn().mockResolvedValue(undefined), + } as unknown as TaskStore; +} + +function gitlabTask(id: string, overrides: Partial = {}): Task { + return { + id, + title: id, + description: id, + column: "done", + createdAt: "2026-07-02T00:00:00.000Z", + updatedAt: "2026-07-02T00:00:00.000Z", + steps: [], + dependencies: [], + log: [], + sourceIssue: { + provider: "gitlab", + repository: "group/project", + externalIssueId: "123", + issueNumber: 42, + url: "https://gitlab.example.test/group/project/-/issues/42", + }, + source: { + sourceType: "gitlab_import", + sourceMetadata: { + provider: "gitlab", + resourceType: "project_issue", + projectPath: "group/project", + iid: 42, + }, + }, + ...overrides, + } as Task; +} + +describe("GitLabSourceIssueReconciler.backfillSourceIssueClosedAt", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockResolveGitLabClient.mockResolvedValue({ + ok: true, + client: { + getProjectIssue: (...args: unknown[]) => mockGetProjectIssue(...args), + getMergeRequest: (...args: unknown[]) => mockGetMergeRequest(...args), + }, + }); + mockGetProjectIssue.mockResolvedValue({ state: "opened" }); + mockGetMergeRequest.mockResolvedValue({ state: "opened" }); + }); + + it("persists real GitLab issue closedAt timestamps for done source issues", async () => { + const closedAt = "2026-07-02T12:34:56.000Z"; + mockGetProjectIssue.mockResolvedValueOnce({ state: "closed", closedAt }); + const task = gitlabTask("FN-1"); + const store = createStore([task]); + + const result = await new GitLabSourceIssueReconciler().backfillSourceIssueClosedAt(store); + + expect(result).toEqual({ scanned: 1, filled: 1, skipped: 0, errors: 0, hasMore: false }); + expect(mockGetProjectIssue).toHaveBeenCalledWith("group/project", 42); + expect(store.updateTask as any).toHaveBeenCalledWith("FN-1", { sourceIssue: { ...task.sourceIssue, closedAt } }); + }); + + it("uses merge request mergedAt/closedAt and skips already-filled rows", async () => { + const mergedAt = "2026-07-02T13:00:00.000Z"; + mockGetMergeRequest.mockResolvedValueOnce({ state: "merged", mergedAt }); + const mrTask = gitlabTask("FN-2", { + sourceIssue: { + provider: "gitlab", + repository: "group/project", + externalIssueId: "456", + issueNumber: 7, + url: "https://gitlab.example.test/group/project/-/merge_requests/7", + }, + source: { + sourceType: "gitlab_import", + sourceMetadata: { provider: "gitlab", resourceType: "merge_request", projectPath: "group/project", iid: 7 }, + }, + }); + const alreadyFilled = gitlabTask("FN-3", { sourceIssue: { ...gitlabTask("x").sourceIssue!, closedAt: "2026-01-01T00:00:00.000Z" } }); + const store = createStore([mrTask, alreadyFilled]); + + const result = await new GitLabSourceIssueReconciler().backfillSourceIssueClosedAt(store); + + expect(result).toEqual({ scanned: 1, filled: 1, skipped: 0, errors: 0, hasMore: false }); + expect(mockGetMergeRequest).toHaveBeenCalledWith("group/project", 7); + expect(store.updateTask as any).toHaveBeenCalledTimes(1); + expect(store.updateTask as any).toHaveBeenCalledWith("FN-2", { sourceIssue: { ...mrTask.sourceIssue, closedAt: mergedAt } }); + }); + + it("skips open/unavailable resources and never fabricates closedAt", async () => { + mockGetProjectIssue.mockResolvedValueOnce({ state: "closed" }); + mockGetMergeRequest.mockResolvedValueOnce({ state: "opened", updatedAt: "2026-07-02T14:00:00.000Z" }); + const store = createStore([ + gitlabTask("FN-4"), + gitlabTask("FN-5", { + sourceIssue: { provider: "gitlab", repository: "group/project", externalIssueId: "5", issueNumber: 5 }, + source: { sourceType: "gitlab_import", sourceMetadata: { provider: "gitlab", resourceType: "merge_request", projectPath: "group/project", iid: 5 } }, + }), + gitlabTask("FN-6", { + sourceIssue: { provider: "gitlab", repository: "", externalIssueId: "6", issueNumber: Number.NaN }, + source: { sourceType: "gitlab_import", sourceMetadata: { provider: "gitlab", resourceType: "project_issue" } }, + }), + ]); + + const result = await new GitLabSourceIssueReconciler().backfillSourceIssueClosedAt(store); + + expect(result).toEqual({ scanned: 3, filled: 0, skipped: 3, errors: 0, hasMore: false }); + expect(store.updateTask as any).not.toHaveBeenCalled(); + expect(store.logEntry as any).toHaveBeenCalledWith("FN-6", "Skipped GitLab source issue closed-at backfill", "Linked GitLab source metadata is incomplete"); + }); + + it("logs 404 or permission failures without corrupting local metadata", async () => { + mockGetProjectIssue.mockRejectedValueOnce(new Error("GitLab API 404: not found")); + const store = createStore([gitlabTask("FN-7")]); + + const result = await new GitLabSourceIssueReconciler().backfillSourceIssueClosedAt(store); + + expect(result).toEqual({ scanned: 1, filled: 0, skipped: 0, errors: 1, hasMore: false }); + expect(store.updateTask as any).not.toHaveBeenCalled(); + expect(store.logEntry as any).toHaveBeenCalledWith("FN-7", "Failed to backfill GitLab source issue closed-at", "GitLab API 404: not found"); + }); + + it("returns skipped rows when auth resolution fails and applies pagination", async () => { + mockResolveGitLabClient.mockResolvedValueOnce({ ok: false, message: "GitLab token missing" }); + const store = createStore([gitlabTask("FN-8"), gitlabTask("FN-9"), gitlabTask("FN-10")]); + + const result = await new GitLabSourceIssueReconciler().backfillSourceIssueClosedAt(store, { offset: 1, limit: 1 }); + + expect(result).toEqual({ scanned: 1, filled: 0, skipped: 1, errors: 0, hasMore: true }); + expect(mockGetProjectIssue).not.toHaveBeenCalled(); + expect(store.logEntry as any).toHaveBeenCalledWith("FN-9", "Skipped GitLab source issue closed-at backfill", "GitLab token missing"); + }); + + it("excludes real archived TaskStore rows instead of mutating archiveDb entries", async () => { + const rootDir = mkdtempSync(join(tmpdir(), "kb-gitlab-backfill-archive-test-")); + const globalDir = mkdtempSync(join(tmpdir(), "kb-gitlab-backfill-archive-global-")); + const store = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); + + try { + await store.init(); + const task = await store.createTask({ description: "Archived GitLab issue", sourceIssue: gitlabTask("template").sourceIssue }); + await store.moveTask(task.id, "todo"); + await store.moveTask(task.id, "in-progress"); + await store.moveTask(task.id, "in-review"); + await store.moveTask(task.id, "done"); + await store.archiveTask(task.id, false); + mockGetProjectIssue.mockResolvedValueOnce({ state: "closed", closedAt: "2026-07-02T15:00:00.000Z" }); + + const result = await new GitLabSourceIssueReconciler().backfillSourceIssueClosedAt(store); + const restored = await store.unarchiveTask(task.id); + + expect(result).toEqual({ scanned: 0, filled: 0, skipped: 0, errors: 0, hasMore: false }); + expect(mockGetProjectIssue).not.toHaveBeenCalled(); + expect(restored.sourceIssue?.closedAt).toBeUndefined(); + } finally { + store.close(); + await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); +}); diff --git a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts index 9ac587bca8..c62067d74e 100644 --- a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts +++ b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts @@ -197,6 +197,44 @@ function seedGithubIssueMetrics(db: Database, opts: { prefix: string; repo: stri } } +function seedGitlabIssueMetrics(db: Database, opts: { prefix: string; project: string; filed: number; fixed: number }): void { + for (let i = 0; i < opts.filed; i += 1) { + db.prepare( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt, gitlabTracking) + VALUES (?, 'desc', 'todo', '2026-03-02T00:00:00.000Z', '2026-03-02T00:00:00.000Z', ?)`, + ).run( + `${opts.prefix}-gitlab-filed-${i}`, + JSON.stringify({ + item: { + kind: "project_issue", + iid: i + 1, + projectPath: opts.project, + url: `https://gitlab.example.test/${opts.project}/-/issues/${i + 1}`, + createdAt: "2026-03-02T00:00:00.000Z", + }, + }), + ); + } + for (let i = 0; i < opts.fixed; i += 1) { + db.prepare( + `INSERT INTO tasks ( + id, title, description, "column", createdAt, updatedAt, + sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, + sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt + ) VALUES (?, ?, 'desc', 'done', '2026-03-03T00:00:00.000Z', '2026-03-03T00:00:00.000Z', + 'gitlab', ?, ?, ?, ?, ?)`, + ).run( + `${opts.prefix}-gitlab-fixed-${i}`, + `Resolve ${opts.project}#${i + 100}`, + opts.project, + String(i + 100), + i + 100, + `https://gitlab.example.test/${opts.project}/-/issues/${i + 100}`, + "2026-03-03T12:00:00.000Z", + ); + } +} + /** * Build an express app with the registrar mounted, backed by per-project real * DBs. The `getScopedStore` resolves the DB by the `projectId` query param, @@ -610,6 +648,18 @@ describe("register-command-center-routes", () => { }, ]); + seedGitlabIssueMetrics(dbA, { prefix: "FN-A", project: "platform/api", filed: 3, fixed: 2 }); + const gitlab = await request(app, "GET", `/api/command-center/gitlab?${range}&projectId=proj-a`); + expect(gitlab.status).toBe(200); + expect(gitlab.body).toMatchObject({ filed: 3, fixed: 2, net: 1 }); + expect(gitlab.body).toHaveProperty("daily"); + expect(gitlab.body).toHaveProperty("byProject"); + expect(gitlab.body).toHaveProperty("resolved"); + expect((gitlab.body as { resolved: unknown[] }).resolved).toEqual([ + expect.objectContaining({ taskId: "FN-A-gitlab-fixed-0", project: "platform/api", issueNumber: 100 }), + expect.objectContaining({ taskId: "FN-A-gitlab-fixed-1", project: "platform/api", issueNumber: 101 }), + ]); + process.env.FUSION_SIGNAL_SENTRY_SECRET = "configured-sentry"; process.env.FUSION_SIGNAL_WEBHOOK_SECRET = "configured-webhook"; process.env.FUSION_SIGNAL_GITLAB_SECRET = "configured-gitlab"; @@ -665,6 +715,7 @@ describe("register-command-center-routes", () => { "team", "workflows", "github", + "gitlab", "signals", "plugin-activations", ]; @@ -1229,6 +1280,7 @@ describe("vite /api proxy negative-lookahead (proxy verification)", () => { expect(PROXY_RE.test("/api/command-center/workflows")).toBe(true); expect(PROXY_RE.test("/api/command-center/live")).toBe(true); expect(PROXY_RE.test("/api/command-center/github")).toBe(true); + expect(PROXY_RE.test("/api/command-center/gitlab")).toBe(true); expect(PROXY_RE.test("/api/command-center/signals")).toBe(true); expect(PROXY_RE.test("/api/command-center/signals/connectors")).toBe(true); expect(PROXY_RE.test("/api/command-center/activity?from=x&to=y")).toBe(true); diff --git a/packages/dashboard/src/__tests__/register-git-gitlab.backfill.test.ts b/packages/dashboard/src/__tests__/register-git-gitlab.backfill.test.ts new file mode 100644 index 0000000000..c16d586cb0 --- /dev/null +++ b/packages/dashboard/src/__tests__/register-git-gitlab.backfill.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment node + +import express from "express"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TaskStore } from "@fusion/core"; +import { registerGitLabRoutes } from "../routes/register-gitlab.js"; +import { request as performRequest } from "../test-request.js"; +import { GitLabSourceIssueReconciler, GITLAB_RECONCILE_SCAN_LIMIT } from "../gitlab-source-issue-reconciler.js"; +import type { ApiRoutesContext } from "../routes/types.js"; + +function createStore(name: string): TaskStore { + return { + getRootDir: vi.fn().mockReturnValue(`/tmp/${name}`), + getFusionDir: vi.fn().mockReturnValue(`/tmp/${name}/.fusion`), + listTasks: vi.fn().mockResolvedValue([]), + getSettings: vi.fn().mockResolvedValue({}), + getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })), + logEntry: vi.fn().mockResolvedValue(undefined), + updateTask: vi.fn().mockResolvedValue(undefined), + } as unknown as TaskStore; +} + +function createApp(storeForProject: (projectId?: string) => TaskStore = () => createStore("default")) { + const app = express(); + app.use(express.json()); + const router = express.Router(); + const ctx = { + router, + getProjectContext: vi.fn(async (req: any) => ({ store: storeForProject(req.body?.projectId), projectId: req.body?.projectId ?? "default" })), + rethrowAsApiError(error: unknown): never { + throw error; + }, + } as unknown as ApiRoutesContext; + registerGitLabRoutes(ctx); + app.use("/api", router); + app.use((err: any, _req: any, res: any, _next: any) => res.status(err.statusCode ?? err.status ?? 500).json({ error: err.message })); + return { app, ctx }; +} + +describe("POST /api/git/gitlab/backfill-source-issue-closed-at", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns the GitLab reconciler backfill result", async () => { + const store = createStore("default"); + const result = { scanned: 2, filled: 1, skipped: 1, errors: 0, hasMore: false }; + const backfill = vi.spyOn(GitLabSourceIssueReconciler.prototype, "backfillSourceIssueClosedAt").mockResolvedValue(result); + const { app } = createApp(() => store); + + const response = await performRequest(app, "POST", "/api/git/gitlab/backfill-source-issue-closed-at", "{}", { "content-type": "application/json" }); + + expect(response.status).toBe(200); + expect(response.body).toEqual(result); + expect(backfill).toHaveBeenCalledWith(store, { offset: 0, limit: GITLAB_RECONCILE_SCAN_LIMIT }); + }); + + it("uses the scoped project store from projectId", async () => { + const storeA = createStore("proj-a"); + const storeB = createStore("proj-b"); + const backfill = vi.spyOn(GitLabSourceIssueReconciler.prototype, "backfillSourceIssueClosedAt") + .mockResolvedValue({ scanned: 0, filled: 0, skipped: 0, errors: 0, hasMore: false }); + const { app, ctx } = createApp((projectId) => projectId === "proj-a" ? storeA : storeB); + + const response = await performRequest( + app, + "POST", + "/api/git/gitlab/backfill-source-issue-closed-at", + JSON.stringify({ projectId: "proj-a" }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(200); + expect(ctx.getProjectContext).toHaveBeenCalled(); + expect(backfill).toHaveBeenCalledWith(storeA, { offset: 0, limit: GITLAB_RECONCILE_SCAN_LIMIT }); + expect(backfill).not.toHaveBeenCalledWith(storeB, expect.anything()); + }); + + it("validates offset and clamps limit to the reconcile scan limit", async () => { + const store = createStore("default"); + const backfill = vi.spyOn(GitLabSourceIssueReconciler.prototype, "backfillSourceIssueClosedAt") + .mockResolvedValue({ scanned: 0, filled: 0, skipped: 0, errors: 0, hasMore: false }); + const { app } = createApp(() => store); + + const clamped = await performRequest( + app, + "POST", + "/api/git/gitlab/backfill-source-issue-closed-at", + JSON.stringify({ offset: 5, limit: GITLAB_RECONCILE_SCAN_LIMIT + 99 }), + { "content-type": "application/json" }, + ); + const invalid = await performRequest( + app, + "POST", + "/api/git/gitlab/backfill-source-issue-closed-at", + JSON.stringify({ offset: -1 }), + { "content-type": "application/json" }, + ); + + expect(clamped.status).toBe(200); + expect(backfill).toHaveBeenCalledWith(store, { offset: 5, limit: GITLAB_RECONCILE_SCAN_LIMIT }); + expect(invalid.status).toBe(400); + expect(invalid.body.error).toContain("offset must be a non-negative integer"); + }); + + it("surfaces reconciler failures without routing to GitHub-only code", async () => { + const store = createStore("default"); + vi.spyOn(GitLabSourceIssueReconciler.prototype, "backfillSourceIssueClosedAt").mockRejectedValue(new Error("gitlab boom")); + const { app } = createApp(() => store); + + const response = await performRequest(app, "POST", "/api/git/gitlab/backfill-source-issue-closed-at", "{}", { "content-type": "application/json" }); + + expect(response.status).toBe(500); + expect(response.body.error).toBe("gitlab boom"); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-gitlab.test.ts b/packages/dashboard/src/__tests__/routes-gitlab.test.ts index 5c113cafe4..e4c0889f13 100644 --- a/packages/dashboard/src/__tests__/routes-gitlab.test.ts +++ b/packages/dashboard/src/__tests__/routes-gitlab.test.ts @@ -56,44 +56,67 @@ function buildApp(fetchImpl = vi.fn()) { 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: [] }])); + it("fetches project and group issues with encoded path IDs, labels, and token auth", async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(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: ["bug"] }])) + .mockResolvedValueOnce(jsonResponse([{ id: 4, iid: 5, project_id: 6, title: "Group Bug", description: null, web_url: "https://gitlab.example.com/g/p/-/issues/5", state: "opened", labels: ["ops"] }])); 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 }); + const project = await request(app, "POST", "/api/gitlab/project/issues/fetch", JSON.stringify({ project: "g/p", limit: 1, labels: ["bug"] }), { "Content-Type": "application/json" }); + const group = await request(app, "POST", "/api/gitlab/group/issues/fetch", JSON.stringify({ group: "g/sub", labels: "ops,urgent" }), { "Content-Type": "application/json" }); + expect(project.status).toBe(200); + expect(group.status).toBe(200); + expect((project.body as any[])[0]).toMatchObject({ resourceKind: "project_issue", iid: 2, labels: ["bug"] }); + expect((group.body as any[])[0]).toMatchObject({ resourceKind: "group_issue", iid: 5, groupPath: "g/sub" }); expect(fetchImpl.mock.calls[0][0]).toContain("/projects/g%2Fp/issues?"); + expect(fetchImpl.mock.calls[0][0]).toContain("labels=bug"); + expect(fetchImpl.mock.calls[1][0]).toContain("/groups/g%2Fsub/issues?"); + expect(fetchImpl.mock.calls[1][0]).toContain("labels=ops%2Curgent"); 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: [] }))); + it("imports project issues with gitlab provenance, tracking defaults, and duplicate protection", 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: ["bug"] }))); 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" }); + expect(created.source).toMatchObject({ sourceType: "gitlab_import", sourceMetadata: { provider: "gitlab", resourceType: "project_issue", iid: 2, projectId: 3 } }); + expect(created.sourceIssue).toMatchObject({ provider: "gitlab", repository: "g/p", externalIssueId: "1", issueNumber: 2, url: "https://gitlab.example.com/g/p/-/issues/2" }); + expect(created.gitlabTracking.item).toMatchObject({ kind: "project_issue", iid: 2, projectId: 3, projectPath: "g/p", host: "gitlab.example.com" }); 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" }))); + it("imports group issues from selected row and merge requests with IID/branch metadata", 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: ["review"], 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); + expect(store.createTask.mock.calls[0][0].source.sourceMetadata).toMatchObject({ resourceType: "group_issue", groupPath: "g", projectId: 8, issueIid: 7 }); 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 }); + expect(store.createTask.mock.calls[1][0].source.sourceMetadata).toMatchObject({ resourceType: "merge_request", mergeRequestIid: 5, sourceBranch: "feat", targetBranch: "main" }); + expect(store.createTask.mock.calls[1][0].sourceIssue).toMatchObject({ provider: "gitlab", issueNumber: 5, url: "https://gitlab.example.com/g/p/-/merge_requests/5" }); }); - it("returns actionable auth/config errors without token values", async () => { - const { app, store } = buildApp(vi.fn()); + it("normalizes self-managed settings and returns auth/config errors without token leakage", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse([])); + const { app, store } = buildApp(fetchImpl); + store.getSettings.mockResolvedValueOnce({ gitlabAuthToken: " project-token-value ", gitlabAuthTokenType: "project", gitlabInstanceUrl: "https://gitlab.example.com/gitlab/" }); + const ok = await request(app, "POST", "/api/gitlab/project/issues/fetch", JSON.stringify({ project: "g/p" }), { "Content-Type": "application/json" }); + expect(ok.status).toBe(200); + expect(fetchImpl.mock.calls[0][0]).toContain("https://gitlab.example.com/gitlab/api/v4/projects/g%2Fp/issues?"); + expect(fetchImpl.mock.calls[0][1].headers[GITLAB_AUTH_HEADER_NAME]).toBe("project-token-value"); + + store.getSettings.mockResolvedValueOnce({ gitlabAuthToken: "secret-token-value", gitlabInstanceUrl: "notaurl" }); + const invalidUrl = await request(app, "POST", "/api/gitlab/project/issues/fetch", JSON.stringify({ project: "g/p" }), { "Content-Type": "application/json" }); + expect(invalidUrl.status).toBe(400); + expect(JSON.stringify(invalidUrl.body)).not.toContain("secret-token-value"); + 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"); + const missing = await request(app, "POST", "/api/gitlab/project/issues/fetch", JSON.stringify({ project: "g/p" }), { "Content-Type": "application/json" }); + expect(missing.status).toBe(401); + expect(JSON.stringify(missing.body)).toContain("GitLab auth requires"); + expect(JSON.stringify(missing.body)).not.toContain("token"); }); }); diff --git a/packages/dashboard/src/command-center-csv.ts b/packages/dashboard/src/command-center-csv.ts index bb28471111..69d2ab5b36 100644 --- a/packages/dashboard/src/command-center-csv.ts +++ b/packages/dashboard/src/command-center-csv.ts @@ -4,6 +4,7 @@ import type { ActivityAnalytics, ProductivityAnalytics, GithubIssueAnalytics, + GitlabIssueAnalytics, WorkflowAnalytics, } from "@fusion/core"; @@ -288,3 +289,23 @@ export function githubIssueAnalyticsToTable( rows.push(["summary", "total", result.filed, result.fixed, result.net, "", "", "", "", ""]); return { header, rows }; } + +/** GitLab issue/MR analytics → CSV. Daily, project, resolved detail, and summary rows. */ +export function gitlabIssueAnalyticsToTable( + result: GitlabIssueAnalytics, +): CsvTable { + const githubShaped: GithubIssueAnalytics = { + ...result, + byRepo: result.byProject.map((entry) => ({ repo: entry.project, filed: entry.filed, fixed: entry.fixed })), + resolved: result.resolved.map((entry) => ({ + taskId: entry.taskId, + taskTitle: entry.taskTitle, + repo: entry.project, + issueNumber: entry.issueNumber, + url: entry.url, + resolvedAt: entry.resolvedAt, + resolvedAtExact: entry.resolvedAtExact, + })), + }; + return githubIssueAnalyticsToTable(githubShaped); +} diff --git a/packages/dashboard/src/gitlab-source-issue-reconciler.ts b/packages/dashboard/src/gitlab-source-issue-reconciler.ts new file mode 100644 index 0000000000..44940e96d3 --- /dev/null +++ b/packages/dashboard/src/gitlab-source-issue-reconciler.ts @@ -0,0 +1,104 @@ +import type { Task, TaskStore } from "@fusion/core"; +import { resolveGitLabClient, resolveGitLabTarget, safeLogGitLabEntry } from "./gitlab-lifecycle.js"; + +export const GITLAB_RECONCILE_SCAN_LIMIT = 200; + +type BackfillResult = { scanned: number; filled: number; skipped: number; errors: number; hasMore: boolean }; + +function hasDoneColumn(task: Pick): boolean { + return task.column === "done"; +} + +function isGitLabBackfillCandidate(task: Task): boolean { + return hasDoneColumn(task) + && task.sourceIssue?.provider === "gitlab" + && !task.sourceIssue.closedAt; +} + +function normalizeProviderTimestamp(value: string | undefined): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!trimmed || trimmed.startsWith("0001-01-01T00:00:00")) return undefined; + return trimmed; +} + +/** + * FNXC:CommandCenterGitLab 2026-07-02-00:00: + * GitLab closed-at backfill is an explicit operator action for local analytics accuracy. It reads real GitLab issue/MR terminal timestamps only, skips already-filled rows, and never fabricates timestamps from local task state or provider `updated_at` values. + * + * FNXC:CommandCenterGitLab 2026-07-02-00:00: + * Archived tasks live in archiveDb, so this active-task backfill intentionally excludes them instead of calling updateTask/logEntry on read-only archive rows. + */ +export class GitLabSourceIssueReconciler { + async backfillSourceIssueClosedAt( + store: TaskStore, + options?: { offset?: number; limit?: number }, + ): Promise { + const offset = Math.max(0, options?.offset ?? 0); + const limit = Math.max(0, options?.limit ?? GITLAB_RECONCILE_SCAN_LIMIT); + const listedTasks = await store.listTasks({ slim: false, includeArchived: false } as Parameters[0]); + const matchingTasks = (Array.isArray(listedTasks) ? listedTasks : []).filter(isGitLabBackfillCandidate); + const tasks = matchingTasks.slice(offset, offset + limit); + const hasMore = offset + limit < matchingTasks.length; + + const resolved = await resolveGitLabClient(store); + if (!resolved.ok) { + for (const task of tasks) { + await safeLogGitLabEntry(store, task.id, "Skipped GitLab source issue closed-at backfill", resolved.message); + } + return { scanned: tasks.length, filled: 0, skipped: tasks.length, errors: 0, hasMore }; + } + + let filled = 0; + let skipped = 0; + let errors = 0; + + for (const task of tasks) { + const sourceIssue = task.sourceIssue; + if (!sourceIssue) { + skipped += 1; + continue; + } + + const target = resolveGitLabTarget(task); + if (!target) { + skipped += 1; + await safeLogGitLabEntry(store, task.id, "Skipped GitLab source issue closed-at backfill", "Linked GitLab source metadata is incomplete"); + continue; + } + + try { + if (target.kind === "merge_request") { + const mergeRequest = await resolved.client.getMergeRequest(target.project, target.iid); + const closedAt = normalizeProviderTimestamp(mergeRequest.mergedAt) ?? normalizeProviderTimestamp(mergeRequest.closedAt); + if (!["closed", "merged"].includes(mergeRequest.state) || !closedAt) { + skipped += 1; + continue; + } + await store.updateTask(task.id, { sourceIssue: { ...sourceIssue, closedAt } }); + filled += 1; + continue; + } + + const issue = await resolved.client.getProjectIssue(target.project, target.iid); + const closedAt = normalizeProviderTimestamp(issue.closedAt); + if (issue.state !== "closed" || !closedAt) { + skipped += 1; + continue; + } + await store.updateTask(task.id, { sourceIssue: { ...sourceIssue, closedAt } }); + filled += 1; + } catch (error) { + errors += 1; + await safeLogGitLabEntry( + store, + task.id, + "Failed to backfill GitLab source issue closed-at", + error instanceof Error ? error.message : String(error), + ); + } + } + + return { scanned: tasks.length, filled, skipped, errors, hasMore }; + } +} diff --git a/packages/dashboard/src/gitlab.ts b/packages/dashboard/src/gitlab.ts index 41a886e570..0fd450d8b5 100644 --- a/packages/dashboard/src/gitlab.ts +++ b/packages/dashboard/src/gitlab.ts @@ -33,6 +33,7 @@ export interface GitLabIssue { labels: string[]; createdAt?: string; updatedAt?: string; + closedAt?: string; commentsCount?: number; } @@ -50,6 +51,8 @@ export interface GitLabMergeRequest { labels: string[]; createdAt?: string; updatedAt?: string; + closedAt?: string; + mergedAt?: string; commentsCount?: number; sourceBranch?: string; targetBranch?: string; @@ -152,6 +155,7 @@ function normalizeIssue(input: unknown, resourceKind: "project_issue" | "group_i labels: normalizeLabels(raw.labels), ...(typeof raw.created_at === "string" ? { createdAt: raw.created_at } : {}), ...(typeof raw.updated_at === "string" ? { updatedAt: raw.updated_at } : {}), + ...(typeof raw.closed_at === "string" ? { closedAt: raw.closed_at } : {}), ...(typeof raw.user_notes_count === "number" ? { commentsCount: raw.user_notes_count } : {}), ...extras, }; @@ -173,6 +177,8 @@ function normalizeMergeRequest(input: unknown, extras: Partial } { const { auth, resourceType, item } = args; + const groupPathFromInput = typeof args.groupInput === "string" ? args.groupInput : undefined; + const groupIdFromInput = typeof args.groupInput === "number" ? args.groupInput : undefined; const repository = projectIdentity(item); const externalIssueId = resourceType === "merge_request" ? `gitlab:mr:${item.projectId ?? repository}:${item.id ?? item.iid}` @@ -322,8 +330,8 @@ export function buildGitLabTaskProvenance(args: { ...(typeof item.id === "number" ? { id: item.id } : {}), ...(typeof item.projectId === "number" ? { projectId: item.projectId } : {}), ...(typeof item.projectPath === "string" ? { projectPath: item.projectPath } : {}), - ...("groupId" in item && item.groupId !== undefined ? { groupId: item.groupId } : {}), - ...("groupPath" in item && item.groupPath !== undefined ? { groupPath: item.groupPath } : {}), + ...("groupId" in item && item.groupId !== undefined ? { groupId: item.groupId } : groupIdFromInput !== undefined ? { groupId: groupIdFromInput } : {}), + ...("groupPath" in item && item.groupPath !== undefined ? { groupPath: item.groupPath } : groupPathFromInput !== undefined ? { groupPath: groupPathFromInput } : {}), title: item.title, state: item.state, createdAt: item.createdAt ?? new Date().toISOString(), @@ -338,8 +346,8 @@ export function buildGitLabTaskProvenance(args: { apiBaseUrl: auth.apiBaseUrl, projectId: item.projectId, projectPath: item.projectPath, - groupId: "groupId" in item ? item.groupId : undefined, - groupPath: "groupPath" in item ? item.groupPath : undefined, + groupId: "groupId" in item && item.groupId !== undefined ? item.groupId : groupIdFromInput, + groupPath: "groupPath" in item && item.groupPath !== undefined ? item.groupPath : groupPathFromInput, projectInput: args.projectInput, groupInput: args.groupInput, iid: item.iid, diff --git a/packages/dashboard/src/routes/register-command-center-routes.ts b/packages/dashboard/src/routes/register-command-center-routes.ts index 3087349418..5076d5ab9c 100644 --- a/packages/dashboard/src/routes/register-command-center-routes.ts +++ b/packages/dashboard/src/routes/register-command-center-routes.ts @@ -7,6 +7,7 @@ import { aggregateTeamAnalytics, aggregateWorkflowAnalytics, aggregateGithubIssueAnalytics, + aggregateGitlabIssueAnalytics, aggregateSignalsAnalytics, composeLiveSnapshot, LITELLM_PRICING_SOURCE_URL, @@ -24,6 +25,7 @@ import { productivityAnalyticsToTable, workflowAnalyticsToTable, githubIssueAnalyticsToTable, + gitlabIssueAnalyticsToTable, type CsvTable, } from "../command-center-csv.js"; import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js"; @@ -404,6 +406,29 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { } }); + /** + * GET /api/command-center/gitlab + * GitLab issues/MRs filed by Fusion and imported GitLab source items fixed by Fusion. + */ + router.get("/command-center/gitlab", async (req, res) => { + try { + const store = await getScopedStore(req); + const range = resolveRange(req.query); + const result = aggregateGitlabIssueAnalytics(store.getDatabase(), { + from: range.from, + to: range.to, + }); + if (wantsCsv(req.query)) { + sendCsv(res, "command-center-gitlab.csv", gitlabIssueAnalyticsToTable(result)); + return; + } + res.json(result); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err, "Failed to aggregate GitLab issue analytics"); + } + }); + /** * GET /api/command-center/signals/connectors * Per-provider signal connector configuration status without secret values. diff --git a/packages/dashboard/src/routes/register-gitlab.ts b/packages/dashboard/src/routes/register-gitlab.ts index 2233ee16ce..e5406deebb 100644 --- a/packages/dashboard/src/routes/register-gitlab.ts +++ b/packages/dashboard/src/routes/register-gitlab.ts @@ -10,6 +10,7 @@ import { type GitLabMergeRequest, type GitLabResourceType, } from "../gitlab.js"; +import { GitLabSourceIssueReconciler, GITLAB_RECONCILE_SCAN_LIMIT } from "../gitlab-source-issue-reconciler.js"; import type { ApiRoutesContext } from "./types.js"; function readRequiredString(body: Record, key: string): string | number { @@ -40,6 +41,22 @@ function readState(body: Record): string | undefined { return typeof body.state === "string" && body.state.trim() ? body.state.trim() : undefined; } +function readNonNegativeInteger(body: Record, key: string, fallback: number): number { + const value = body[key]; + if (value === undefined) return fallback; + if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value; + throw badRequest(`${key} must be a non-negative integer`); +} + +function readBackfillLimit(body: Record): number { + const value = body.limit; + if (value === undefined) return GITLAB_RECONCILE_SCAN_LIMIT; + if (typeof value === "number" && Number.isInteger(value) && value > 0) { + return Math.min(value, GITLAB_RECONCILE_SCAN_LIMIT); + } + throw badRequest("limit must be a positive integer"); +} + async function createClient(ctx: ApiRoutesContext, req: Parameters[0]): Promise { const { store } = await ctx.getProjectContext(req); const projectSettings = await store.getSettings(); @@ -97,6 +114,18 @@ async function importItem(ctx: ApiRoutesContext, req: Parameters { + try { + const { store } = await ctx.getProjectContext(req); + const offset = readNonNegativeInteger(req.body ?? {}, "offset", 0); + const limit = readBackfillLimit(req.body ?? {}); + const result = await new GitLabSourceIssueReconciler().backfillSourceIssueClosedAt(store, { offset, limit }); + res.json(result); + } catch (error) { + rethrowAsApiError(error); + } + }); + router.post("/gitlab/project/issues/fetch", async (req, res) => { try { const project = readRequiredString(req.body, "project");