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:
7
.changeset/fn-8094-gitlab-tracking-mapping.md
Normal file
7
.changeset/fn-8094-gitlab-tracking-mapping.md
Normal 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.
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user