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>
79 lines
3.7 KiB
TypeScript
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;
|
|
}
|
|
}
|