From 6675cdf696a02d9cfc9618aea113ba1f1b1f3276 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 05:52:17 -0700 Subject: [PATCH] FN-8094: persist GitLab tracking metadata Restore shared TaskStore persistence and hydration for GitLab tracking metadata. - Register GitLab tracking JSONB for task creation, updates, and row hydration. - Reuse the shared mapper during GitLab reconciliation and cover live/deleted reads. - Migrate GitLab extension tests to the PostgreSQL harness and add a patch changeset. Files changed: .changeset/fn-8094-gitlab-tracking-mapping.md | 7 ++ .../__tests__/extension-gitlab-tracking.test.ts | 129 ++++++++++----------- packages/cli/src/__tests__/pg-extension-harness.ts | 4 - .../store-gitlab-tracking-hydration.pg.test.ts | 85 ++++++++++++++ .../store-gitlab-tracking-reconcile.test.ts | 11 +- packages/core/src/task-store/persistence.ts | 9 +- packages/core/src/task-store/remaining-ops-2.ts | 10 +- packages/core/src/task-store/serialization.ts | 1 + packages/core/src/task-store/task-creation.ts | 2 + 9 files changed, 174 insertions(+), 84 deletions(-) Fusion-Task-Id: FN-8094 Fusion-Task-Lineage: 5e876856-cf42-4628-a5ef-ab562c1bf501 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8094-gitlab-tracking-mapping.md | 7 + .../extension-gitlab-tracking.test.ts | 129 +++++++++--------- .../cli/src/__tests__/pg-extension-harness.ts | 4 - ...store-gitlab-tracking-hydration.pg.test.ts | 85 ++++++++++++ .../store-gitlab-tracking-reconcile.test.ts | 11 +- packages/core/src/task-store/persistence.ts | 9 +- .../core/src/task-store/remaining-ops-2.ts | 10 +- packages/core/src/task-store/serialization.ts | 1 + packages/core/src/task-store/task-creation.ts | 2 + 9 files changed, 174 insertions(+), 84 deletions(-) create mode 100644 .changeset/fn-8094-gitlab-tracking-mapping.md create mode 100644 packages/core/src/__tests__/postgres/store-gitlab-tracking-hydration.pg.test.ts diff --git a/.changeset/fn-8094-gitlab-tracking-mapping.md b/.changeset/fn-8094-gitlab-tracking-mapping.md new file mode 100644 index 0000000000..0a9a02abc9 --- /dev/null +++ b/.changeset/fn-8094-gitlab-tracking-mapping.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Preserve GitLab import tracking metadata in normal task reads. +category: fix +dev: GitLab tracking now uses the shared TaskStore persistence and hydration registry. diff --git a/packages/cli/src/__tests__/extension-gitlab-tracking.test.ts b/packages/cli/src/__tests__/extension-gitlab-tracking.test.ts index 53d7021c22..cb028df620 100644 --- a/packages/cli/src/__tests__/extension-gitlab-tracking.test.ts +++ b/packages/cli/src/__tests__/extension-gitlab-tracking.test.ts @@ -1,8 +1,9 @@ -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"; +import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest"; +import { + createMockApi, + createPgExtensionHarness, + pgDescribe, +} from "./pg-extension-harness.js"; 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: [] }], @@ -77,86 +78,82 @@ async function loadExtension() { 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 h = createPgExtensionHarness("fn-8094-gitlab"); +async function setupTools() { + await h.store().updateSettings({ gitlabAuthToken: "glpat_test", gitlabInstanceUrl: "https://gitlab.example.com" }); const extension = await loadExtension(); - const tools = new Map(); - extension({ registerTool: (def: any) => tools.set(def.name, def), registerCommand: vi.fn(), registerShortcut: vi.fn(), registerFlag: vi.fn(), on: vi.fn() } as any); - return { repoRoot, cwd, tools }; + const api = createMockApi(); + extension({ + ...api, + registerShortcut: vi.fn(), + registerFlag: vi.fn(), + } as any); + return { cwd: h.rootDir(), tools: api.tools }; } /* +FNXC:PostgresCutover 2026-07-16-05:40: +This GitLab extension suite runs on the shared PostgreSQL harness instead of the +removed inMemoryDb runtime. Its preserved tracking assertions require FN-8094 +rowToTask hydration so imported GitLab provenance survives ordinary store reads. + 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()); +pgDescribe("extension GitLab import tools", () => { + beforeAll(h.beforeAll); + beforeEach(async () => { + await h.beforeEach(); + vi.clearAllMocks(); + }); + afterEach(async () => { + vi.restoreAllMocks(); + await h.afterEach(); + }); + afterAll(h.afterAll); 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 }); + const { tools } = await setupTools(); + 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/); } }); 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 }); + const { cwd, tools } = await setupTools(); + 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"); + 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 }); - } + const tasks = await h.store().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:/); }); 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(); + const { cwd, tools } = await setupTools(); + await h.store().updateSettings({ gitlabAuthToken: null as any }); - 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 }); - } + await expect(tools.get("fn_task_browse_gitlab_project_issues").execute("p", { project: "g/p" }, undefined, undefined, { cwd })) + .resolves.toMatchObject({ + isError: true, + details: { error: "GitLab auth requires a configured access token" }, + }); }); }); diff --git a/packages/cli/src/__tests__/pg-extension-harness.ts b/packages/cli/src/__tests__/pg-extension-harness.ts index 75ec940e95..f2a8335dcb 100644 --- a/packages/cli/src/__tests__/pg-extension-harness.ts +++ b/packages/cli/src/__tests__/pg-extension-harness.ts @@ -101,10 +101,6 @@ export function registerExtension(api: MockApi): void { kbExtension(api as unknown as ExtensionAPI); } -// `kbExtension` is imported lazily-free via the default export below; keep the -// import at module scope so `registerExtension` can call it. -import kbExtension from "../extension.js"; - export interface PgExtensionHarness { /** The project rootDir the PG-backed store is scoped to (also the tool-call cwd). */ readonly rootDir: () => string; diff --git a/packages/core/src/__tests__/postgres/store-gitlab-tracking-hydration.pg.test.ts b/packages/core/src/__tests__/postgres/store-gitlab-tracking-hydration.pg.test.ts new file mode 100644 index 0000000000..21b078d40b --- /dev/null +++ b/packages/core/src/__tests__/postgres/store-gitlab-tracking-hydration.pg.test.ts @@ -0,0 +1,85 @@ +import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import type { TaskGitLabTrackedItem } from "../../types.js"; +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; + +/* +FNXC:GitLabTracking 2026-07-16-05:38: +GitLab tracking must round-trip through the shared TaskStore registry on every +live and soft-deleted read surface. A persisted empty object means not filed +for analytics and must remain distinct from absent tracking, which hydrates as undefined. +*/ +pgDescribe("TaskStore GitLab tracking hydration (PostgreSQL)", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_gitlab_tracking_hydration", + }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + const item: TaskGitLabTrackedItem = { + kind: "project_issue", + id: 42, + projectId: 7, + iid: 2, + projectPath: "acme/app", + groupPath: "acme", + url: "https://gitlab.example.com/acme/app/-/issues/2", + instanceUrl: "https://gitlab.example.com", + host: "gitlab.example.com", + title: "Persisted GitLab issue", + state: "opened", + createdAt: "2026-07-16T00:00:00.000Z", + }; + + it("round-trips GitLab tracking across live and soft-deleted task reads", async () => { + const store = h.store(); + const tracked = await store.createTask({ + description: "Tracked GitLab task", + sourceIssue: { + provider: "gitlab", + repository: "acme/app", + externalIssueId: "42", + issueNumber: 2, + url: item.url, + }, + gitlabTracking: { item }, + }); + const empty = await store.createTask({ description: "Empty GitLab tracking", gitlabTracking: {} }); + const absent = await store.createTask({ description: "Absent GitLab tracking" }); + + expect((await store.getTask(tracked.id))?.gitlabTracking?.item).toEqual(item); + expect((await store.getTask(tracked.id))?.sourceIssue).toEqual({ + provider: "gitlab", + repository: "acme/app", + externalIssueId: "42", + issueNumber: 2, + url: item.url, + }); + + for (const slim of [false, true]) { + const listed = await store.listTasks({ slim }); + const listedTask = listed.find((task) => task.id === tracked.id); + expect(listedTask?.gitlabTracking?.item).toEqual(item); + if (slim) expect(listedTask?.log).toEqual([]); + } + + expect((await store.getTask(empty.id))?.gitlabTracking).toEqual({}); + expect((await store.listTasks({ slim: false })).find((task) => task.id === empty.id)?.gitlabTracking).toEqual({}); + expect((await store.getTask(absent.id))?.gitlabTracking).toBeUndefined(); + + await store.deleteTask(tracked.id); + + for (const slim of [false, true]) { + const deleted = await store.listTasks({ includeDeleted: true, slim }); + expect(deleted.find((task) => task.id === tracked.id)?.gitlabTracking?.item).toEqual(item); + } + expect((await store.getTask(tracked.id, { includeDeleted: true }))?.gitlabTracking?.item).toEqual(item); + expect((await store.listTasksForGitlabTrackingReconcile()).tasks.find((task) => task.id === tracked.id)?.gitlabTracking?.item).toEqual(item); + }); +}); diff --git a/packages/core/src/__tests__/store-gitlab-tracking-reconcile.test.ts b/packages/core/src/__tests__/store-gitlab-tracking-reconcile.test.ts index a77617e192..390c730f4d 100644 --- a/packages/core/src/__tests__/store-gitlab-tracking-reconcile.test.ts +++ b/packages/core/src/__tests__/store-gitlab-tracking-reconcile.test.ts @@ -23,11 +23,10 @@ const gitlabItem: TaskGitLabTrackedItem = { /* * FNXC:GitLabReconcile 2026-07-12-00:00: * listTasksForGitlabTrackingReconcile returns soft-deleted tasks with - * gitlab_tracking JSONB. The store read/write pipeline does not yet serialize - * gitlabTracking through createTask/updateTask (the feature is partial on this - * branch), so we seed the column directly via adminDb and assert the reconcile - * API returns the right tasks. Archived tasks are a separate async subsystem - * not surfaced through this API (same limitation as the GitHub reconcile). + * gitlab_tracking JSONB through the same shared row mapper as normal task reads. + * The test seeds the column directly via adminDb to exercise reconciliation of + * externally persisted tracking metadata. Archived tasks are a separate async + * subsystem not surfaced through this API (same limitation as the GitHub reconcile). */ pgTest("TaskStore.listTasksForGitlabTrackingReconcile", () => { const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ @@ -46,7 +45,7 @@ pgTest("TaskStore.listTasksForGitlabTrackingReconcile", () => { it("returns soft-deleted tasks with GitLab tracking, excludes active and non-tracked", async () => { const store = h.store(); const softDeleted = await store.createTask({ description: "soft deleted" }); - // Seed gitlab_tracking directly since updateTask does not persist it yet. + // Seed externally persisted gitlab_tracking directly for reconcile coverage. await h.adminDb().execute( sql`UPDATE project.tasks SET gitlab_tracking = ${JSON.stringify({ item: gitlabItem })}::jsonb WHERE id = ${softDeleted.id}`, ); diff --git a/packages/core/src/task-store/persistence.ts b/packages/core/src/task-store/persistence.ts index 74e13d9e81..4f73442a34 100644 --- a/packages/core/src/task-store/persistence.ts +++ b/packages/core/src/task-store/persistence.ts @@ -112,6 +112,7 @@ export interface TaskRow { prInfos: string | null; issueInfo: string | null; githubTracking: string | null; + gitlabTracking: string | null; sourceIssueProvider: string | null; sourceIssueRepository: string | null; sourceIssueExternalIssueId: string | null; @@ -169,7 +170,7 @@ PostgreSQL task JSONB conversion must use one registry for both descriptor write export const TASK_JSONB_COLUMNS: ReadonlySet = new Set([ "dependencies", "steps", "customFields", "log", "attachments", "steeringComments", "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", - "issueInfo", "githubTracking", "mergeDetails", "workspaceWorktrees", "enabledWorkflowSteps", + "issueInfo", "githubTracking", "gitlabTracking", "mergeDetails", "workspaceWorktrees", "enabledWorkflowSteps", "modifiedFiles", "scopeAutoWiden", "sourceMetadata", "tokenUsagePerModel", "tokenBudgetOverride", "columnDwellMs", "workflowTransitionNotification", ]); @@ -297,6 +298,12 @@ export const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("prInfos", (task) => toJson(task.prInfos || [])), defineTaskColumn("issueInfo", (task) => toJsonNullable(task.issueInfo)), defineTaskColumn("githubTracking", (task) => toJsonNullable(task.githubTracking)), + /* + FNXC:GitLabTracking 2026-07-16-05:34: + GitLab import provenance must use the shared task persistence registry like GitHub tracking. + This closes the partial-feature gap so create and update writes retain metadata for live reads. + */ + defineTaskColumn("gitlabTracking", (task) => toJsonNullable(task.gitlabTracking)), defineTaskColumn("sourceIssueProvider", (task) => task.sourceIssue?.provider ?? null), defineTaskColumn("sourceIssueRepository", (task) => task.sourceIssue?.repository ?? null), defineTaskColumn("sourceIssueExternalIssueId", (task) => task.sourceIssue?.externalIssueId ?? null), diff --git a/packages/core/src/task-store/remaining-ops-2.ts b/packages/core/src/task-store/remaining-ops-2.ts index 8a884d614c..5db5f02629 100644 --- a/packages/core/src/task-store/remaining-ops-2.ts +++ b/packages/core/src/task-store/remaining-ops-2.ts @@ -453,14 +453,10 @@ export async function listTasksForGitlabTrackingReconcileImpl(store: TaskStore, .offset(deletedOffset); const deletedTasks = deletedRowsRaw.map((row) => { const raw = row as unknown as Record; + // FNXC:GitLabTracking 2026-07-16-05:36: rowToTask now hydrates GitLab + // tracking through the shared persistence registry, so reconcile uses + // the same authoritative mapper as every other live-task read. const task = store.rowToTask(store.pgRowToTaskRow(raw)); - // FNXC:GitLabTracking 2026-07-12-00:00: the generic row mapper does not - // yet include gitlabTracking (the feature is partial on this branch). - // Manually attach it from the raw jsonb column so reconcile callers get - // the tracking item they need to reconcile. - if (raw.gitlabTracking != null) { - task.gitlabTracking = raw.gitlabTracking as Task["gitlabTracking"]; - } task.timedExecutionMs = store.computeTimedExecutionMs(task.log); task.log = []; return task; diff --git a/packages/core/src/task-store/serialization.ts b/packages/core/src/task-store/serialization.ts index fe945a1575..ea7bc1aaaa 100644 --- a/packages/core/src/task-store/serialization.ts +++ b/packages/core/src/task-store/serialization.ts @@ -203,6 +203,7 @@ export function rowToTask(row: TaskRow): Task { })(), issueInfo: fromJson(row.issueInfo), githubTracking: fromJson(row.githubTracking) ?? undefined, + gitlabTracking: fromJson(row.gitlabTracking) ?? undefined, sourceIssue: (() => { if ( row.sourceIssueProvider === null diff --git a/packages/core/src/task-store/task-creation.ts b/packages/core/src/task-store/task-creation.ts index c212e7ba3b..4c06e2a411 100644 --- a/packages/core/src/task-store/task-creation.ts +++ b/packages/core/src/task-store/task-creation.ts @@ -285,6 +285,7 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta tokenUsage: input.tokenUsage, sourceIssue: input.sourceIssue, githubTracking: input.githubTracking, + gitlabTracking: input.gitlabTracking, sourceType: input.source?.sourceType ?? "unknown", sourceAgentId: input.source?.sourceAgentId, sourceRunId: input.source?.sourceRunId, @@ -828,6 +829,7 @@ export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreat tokenUsage: input.tokenUsage, sourceIssue: input.sourceIssue, githubTracking: input.githubTracking, + gitlabTracking: input.gitlabTracking, sourceType: input.source?.sourceType ?? "unknown", sourceAgentId: input.source?.sourceAgentId, sourceRunId: input.source?.sourceRunId,