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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 05:52:17 -07:00
parent f63818a6e9
commit 6675cdf696
9 changed files with 174 additions and 84 deletions

View File

@@ -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.

View File

@@ -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<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 };
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" },
});
});
});

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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}`,
);

View File

@@ -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<string> = 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),

View File

@@ -453,14 +453,10 @@ export async function listTasksForGitlabTrackingReconcileImpl(store: TaskStore,
.offset(deletedOffset);
const deletedTasks = deletedRowsRaw.map((row) => {
const raw = row as unknown as Record<string, unknown>;
// 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;

View File

@@ -203,6 +203,7 @@ export function rowToTask(row: TaskRow): Task {
})(),
issueInfo: fromJson<import("../types.js").IssueInfo>(row.issueInfo),
githubTracking: fromJson<import("../types.js").TaskGithubTracking>(row.githubTracking) ?? undefined,
gitlabTracking: fromJson<import("../types.js").TaskGitLabTracking>(row.gitlabTracking) ?? undefined,
sourceIssue: (() => {
if (
row.sourceIssueProvider === null

View File

@@ -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,