Files
fusion/packages/dashboard/src/gitlab-lifecycle.ts
gsxdsm c978cdbacf FN-8687: close imported GitLab issues on task deletion
Add GitLab close-on-delete behavior for imported source issues.

- Close linked GitLab issues by default while honoring leave and delete action semantics.
- Preserve split-close ownership, skip merge requests, and limit malformed tracking fallback to deletion.
- Document the lifecycle contract and add dashboard coverage.

Files changed:
 .changeset/fn-8687-gitlab-close-on-delete.md       |   7 ++
 docs/gitlab-parity-inventory.md                    |  20 +++-
 docs/settings-reference.md                         |   1 +
 docs/task-management.md                            |   5 +-
 .../src/__tests__/gitlab-delete-close.test.ts      | 101 +++++++++++++++++++++
 .../src/__tests__/gitlab-lifecycle.test.ts         |  35 +++++++
 .../gitlab-parity-inventory-documentation.test.ts  |   7 +-
 .../__tests__/gitlab-source-issue-close.test.ts    |   8 ++
 .../src/__tests__/gitlab-split-close.test.ts       |  12 +++
 packages/dashboard/src/gitlab-delete-close.ts      |  90 ++++++++++++++++++
 packages/dashboard/src/gitlab-lifecycle.ts         |  33 +++++--
 packages/dashboard/src/gitlab-split-close.ts       |   2 +-
 packages/dashboard/src/index.ts                    |   1 +
 .../dashboard/src/routes/register-git-github.ts    |   7 ++
 14 files changed, 313 insertions(+), 16 deletions(-)

Fusion-Task-Id: FN-8687

Fusion-Task-Lineage: 48bc9159-fc35-43cb-b72e-1b6260d9e3cb

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-01 04:08:57 -07:00

79 lines
3.7 KiB
TypeScript

import { createLogger } from "@fusion/core";
const severityAuditLog = createLogger("dashboard-gitlab-lifecycle");
import type { GlobalSettings, ProjectSettings, Task, TaskGitLabTrackedItem, TaskStore } from "@fusion/core";
import { GitLabClient } from "./gitlab.js";
import { resolveGitlabAuth } from "./gitlab-auth.js";
export type GitLabLifecycleTarget = {
kind: "project_issue" | "group_issue" | "merge_request";
project: string | number;
iid: number;
label: string;
url?: string;
};
export async function resolveGitLabClient(store: TaskStore): Promise<{ ok: true; client: GitLabClient } | { ok: false; message: string }> {
const projectSettings = await store.getSettings() as ProjectSettings;
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Partial<GlobalSettings>;
const resolution = resolveGitlabAuth({ projectSettings, globalSettings });
if (!resolution.ok) return { ok: false, message: resolution.message };
return { ok: true, client: new GitLabClient(resolution.auth) };
}
export type ResolveGitLabTargetOptions = {
fallbackToSourceOnInvalidTracking?: boolean;
};
/*
FNXC:GitLabCloseOnDelete 2026-08-01-10:46:
Malformed tracking data is inert by default. Only ordinary task deletion opts into source fallback,
mirroring FN-8673's GitHub early-return fix without causing done-close, comment, tracking-state, or
split-close consumers to mutate GitLab items they previously left untouched.
*/
export function resolveGitLabTarget(
task: Pick<Task, "gitlabTracking" | "sourceIssue" | "source">,
options: ResolveGitLabTargetOptions = {},
): GitLabLifecycleTarget | null {
const item = task.gitlabTracking?.item;
if (item) {
const trackingTarget = resolveGitLabTargetFromItem(item);
if (trackingTarget) return trackingTarget;
if (!options.fallbackToSourceOnInvalidTracking) return null;
}
const meta = task.source?.sourceMetadata && typeof task.source.sourceMetadata === "object"
? task.source.sourceMetadata as Record<string, unknown>
: undefined;
if (task.sourceIssue?.provider !== "gitlab") return null;
const kind = meta?.resourceType === "merge_request" ? "merge_request" : meta?.resourceType === "group_issue" ? "group_issue" : "project_issue";
const iid = typeof meta?.iid === "number" ? meta.iid : task.sourceIssue.issueNumber;
const project = typeof meta?.projectId === "number" ? meta.projectId : typeof meta?.projectPath === "string" ? meta.projectPath : task.sourceIssue.repository;
if (!Number.isInteger(iid) || !project) return null;
return { kind, project, iid, label: formatGitLabTargetLabel(kind, project, iid), url: task.sourceIssue.url };
}
export function resolveGitLabTargetFromItem(item: TaskGitLabTrackedItem): GitLabLifecycleTarget | null {
const project = item.projectId ?? item.projectPath;
if (!project || !Number.isInteger(item.iid)) return null;
return { kind: item.kind, project, iid: item.iid, label: formatGitLabTargetLabel(item.kind, project, item.iid), url: item.url };
}
export function formatGitLabTargetLabel(kind: GitLabLifecycleTarget["kind"], project: string | number, iid: number): string {
const marker = kind === "merge_request" ? "!" : "#";
return `${String(project)}${marker}${iid}`;
}
export async function safeLogGitLabEntry(store: TaskStore, taskId: string, message: string, details: string): Promise<void> {
try {
await store.logEntry(taskId, message, details);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes(`Task ${taskId} not found`)) {
severityAuditLog.warn(`[gitlab-lifecycle] Unable to write log entry for deleted task ${taskId}: ${message}`);
return;
}
throw error;
}
}