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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7428-gitlab-import-tracking.md
Normal file
7
.changeset/fn-7428-gitlab-import-tracking.md
Normal file
@@ -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.
|
||||
140
packages/cli/src/__tests__/extension-gitlab-tracking.test.ts
Normal file
140
packages/cli/src/__tests__/extension-gitlab-tracking.test.ts
Normal file
@@ -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<string, any>();
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
|
||||
@@ -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" }) }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
243
packages/core/src/__tests__/gitlab-issue-analytics.test.ts
Normal file
243
packages/core/src/__tests__/gitlab-issue-analytics.test.ts
Normal file
@@ -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<string, unknown>,
|
||||
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 }]);
|
||||
});
|
||||
});
|
||||
128
packages/core/src/__tests__/gitlab-source-issue-storage.test.ts
Normal file
128
packages/core/src/__tests__/gitlab-source-issue-storage.test.ts
Normal file
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) => void };
|
||||
}).archiveDb.get(archivedTodo.id);
|
||||
if (archivedTodoEntry) {
|
||||
(store as unknown as { archiveDb: { upsert: (entry: Record<string, unknown>) => 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);
|
||||
});
|
||||
});
|
||||
227
packages/core/src/gitlab-issue-analytics.ts
Normal file
227
packages/core/src/gitlab-issue-analytics.ts
Normal file
@@ -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<string, { filed: number; fixed: number }>,
|
||||
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<string, { filed: number; fixed: number }>,
|
||||
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<string, { filed: number; fixed: number }>();
|
||||
const byProject = new Map<string, { filed: number; fixed: number }>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
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(<GitHubImportModal isOpen={false} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
expect(screen.queryByText("Import from GitHub")).toBeNull();
|
||||
|
||||
@@ -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(<GitLabBadge item={baseItem} />);
|
||||
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(<GitLabBadge item={{ ...baseItem, staleAt: "2026-07-02T00:00:00.000Z", staleReason: "GitLab sync failed" }} />);
|
||||
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(<GitLabBadge item={undefined} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -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 <EcosystemArea range={range} />;
|
||||
case "github":
|
||||
return <GithubArea range={range} />;
|
||||
case "gitlab":
|
||||
return <GitlabArea range={range} />;
|
||||
case "signals":
|
||||
return <SignalsArea range={range} />;
|
||||
case "system":
|
||||
|
||||
@@ -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<GitlabIssueAnalytics>(
|
||||
"/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 (
|
||||
<AreaShell
|
||||
testId="gitlab"
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
isEmpty={false}
|
||||
emptyMessage={t("commandCenter.gitlab.empty", "No GitLab issue or merge request activity in the selected range.")}
|
||||
>
|
||||
<div className="cc-area-section">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.gitlab.totalsTitle", "GitLab issue and MR flow")}</h3>
|
||||
{isEmpty ? (
|
||||
<span className="cc-stat-sub">{t("commandCenter.gitlab.empty", "No GitLab issue or merge request activity in the selected range.")}</span>
|
||||
) : null}
|
||||
<div className="cc-stat-grid">
|
||||
<div className="card cc-stat-card" data-testid="cc-gitlab-filed">
|
||||
<div className="cc-stat-label">{t("commandCenter.gitlab.filed", "Filed by Fusion")}</div>
|
||||
<div className="cc-stat-value">{formatCount(filed)}</div>
|
||||
</div>
|
||||
<div className="card cc-stat-card" data-testid="cc-gitlab-fixed">
|
||||
<div className="cc-stat-label">{t("commandCenter.gitlab.fixed", "Fixed by Fusion")}</div>
|
||||
<div className="cc-stat-value">{formatCount(fixed)}</div>
|
||||
<span className="cc-stat-sub">{t("commandCenter.gitlab.fixedApproximation", "Uses persisted GitLab close times when available")}</span>
|
||||
</div>
|
||||
<div className="card cc-stat-card" data-testid="cc-gitlab-net">
|
||||
<div className="cc-stat-label">{t("commandCenter.gitlab.net", "Net")}</div>
|
||||
<div className="cc-stat-value">{formatCount(net)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cc-area-section">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.gitlab.dailyTitle", "Daily GitLab flow")}</h3>
|
||||
{daily.length > 0 ? (
|
||||
<div className="cc-chart-grid">
|
||||
<div className="card cc-chart-card">
|
||||
<h4>{t("commandCenter.gitlab.filedTrend", "Filed")}</h4>
|
||||
<Sparkline values={filedValues} max={maxDaily} />
|
||||
</div>
|
||||
<div className="card cc-chart-card">
|
||||
<h4>{t("commandCenter.gitlab.fixedTrend", "Fixed")}</h4>
|
||||
<Sparkline values={fixedValues} max={maxDaily} />
|
||||
</div>
|
||||
</div>
|
||||
) : <span className="cc-stat-sub">{t("commandCenter.gitlab.noDaily", "No daily GitLab trend data yet.")}</span>}
|
||||
</div>
|
||||
|
||||
<div className="cc-area-section">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.gitlab.projectsTitle", "Projects and groups")}</h3>
|
||||
{projectBars.length > 0 ? <Bar data={projectBars} /> : <span className="cc-stat-sub">{t("commandCenter.gitlab.noProjects", "No GitLab project activity yet.")}</span>}
|
||||
</div>
|
||||
|
||||
<div className="cc-area-section">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.gitlab.resolvedTitle", "Resolved GitLab source items")}</h3>
|
||||
{resolved.length > 0 ? (
|
||||
<div className="cc-resolved-list" data-testid="cc-gitlab-resolved-list">
|
||||
{resolved.slice(0, 12).map((item) => {
|
||||
const key = item.issueNumber === null ? item.project : `${item.project}#${item.issueNumber}`;
|
||||
const label = `${key} · ${item.taskId}`;
|
||||
return (
|
||||
<div className="card cc-resolved-item" key={`${item.taskId}:${item.resolvedAt}`}>
|
||||
<div>
|
||||
{item.url ? <a href={item.url} target="_blank" rel="noopener noreferrer">{label}</a> : <strong>{label}</strong>}
|
||||
<div className="cc-stat-sub">{item.taskTitle}</div>
|
||||
</div>
|
||||
<span className="cc-stat-sub">
|
||||
{formatResolvedAt(item.resolvedAt, item.resolvedAt)}{item.resolvedAtExact ? "" : ` ${t("commandCenter.gitlab.approximate", "(approx.)")}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <span className="cc-stat-sub">{t("commandCenter.gitlab.noResolved", "No resolved GitLab source items in range.")}</span>}
|
||||
</div>
|
||||
</AreaShell>
|
||||
);
|
||||
}
|
||||
@@ -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(<GitlabArea range={range7d} />);
|
||||
|
||||
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(<GitlabArea range={range7d} />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("../gitlab-lifecycle.js")>("../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> = {}): 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
104
packages/dashboard/src/gitlab-source-issue-reconciler.ts
Normal file
104
packages/dashboard/src/gitlab-source-issue-reconciler.ts
Normal file
@@ -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<Task, "column">): 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<BackfillResult> {
|
||||
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<TaskStore["listTasks"]>[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 };
|
||||
}
|
||||
}
|
||||
@@ -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<GitLabMergeReques
|
||||
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.merged_at === "string" ? { mergedAt: raw.merged_at } : {}),
|
||||
...(typeof raw.user_notes_count === "number" ? { commentsCount: raw.user_notes_count } : {}),
|
||||
...(typeof raw.source_branch === "string" ? { sourceBranch: raw.source_branch } : {}),
|
||||
...(typeof raw.target_branch === "string" ? { targetBranch: raw.target_branch } : {}),
|
||||
@@ -299,6 +305,8 @@ export function buildGitLabTaskProvenance(args: {
|
||||
groupInput?: string | number;
|
||||
}): { sourceIssue: TaskSourceIssue; gitlabTracking: TaskGitLabTracking; sourceMetadata: Record<string, unknown> } {
|
||||
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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, unknown>, key: string): string | number {
|
||||
@@ -40,6 +41,22 @@ function readState(body: Record<string, unknown>): string | undefined {
|
||||
return typeof body.state === "string" && body.state.trim() ? body.state.trim() : undefined;
|
||||
}
|
||||
|
||||
function readNonNegativeInteger(body: Record<string, unknown>, 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<string, unknown>): 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<ApiRoutesContext["getProjectContext"]>[0]): Promise<GitLabClient> {
|
||||
const { store } = await ctx.getProjectContext(req);
|
||||
const projectSettings = await store.getSettings();
|
||||
@@ -97,6 +114,18 @@ async function importItem(ctx: ApiRoutesContext, req: Parameters<ApiRoutesContex
|
||||
export function registerGitLabRoutes(ctx: ApiRoutesContext): void {
|
||||
const { router, rethrowAsApiError } = ctx;
|
||||
|
||||
router.post("/git/gitlab/backfill-source-issue-closed-at", async (req, res) => {
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user