diff --git a/.changeset/fn-8682-gitlab-split-close-comment.md b/.changeset/fn-8682-gitlab-split-close-comment.md new file mode 100644 index 0000000000..19c66b2e83 --- /dev/null +++ b/.changeset/fn-8682-gitlab-split-close-comment.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Explain and close GitLab source issues when triage splits imported work. +category: feature +dev: Adds ordered split-close notes for resolved GitLab project issues. diff --git a/docs/gitlab-parity-inventory.md b/docs/gitlab-parity-inventory.md index 712b0f1e5d..8773dd3cdb 100644 --- a/docs/gitlab-parity-inventory.md +++ b/docs/gitlab-parity-inventory.md @@ -42,7 +42,7 @@ Required included surfaces: issue import, linked issue tracking, completion comm | Tracking title/body format | GitHub tracking issues use title `[FN-XXXX] Task title` and body prefix `Fusion task: FN-XXXX` with bounded plain-text summary. | `github-tracking.ts`; docs contract test | GitLab tracking issues should use the same title/body invariant unless later UX explicitly changes it. Keep body bounded and do not include local dashboard links. | | Tracking lifecycle comments | GitHub posts lifecycle comments on tracked issues for in-progress/done, with bounded plain text and optional merge metadata. | `github-tracking-comments.ts` | GitLab should post issue notes for the same lifecycle moments to linked GitLab tracking issues. Merge-request notes are only relevant for imported MR review tasks and must be designed separately. | | Completion comments on source issues | `githubCommentOnDone` and optional `githubCommentTemplate` post a completion comment to imported GitHub source issues when tasks enter done. On GitHub, when the linked source issue's repository is the Fusion self-repo (`runfusion/fusion`, case-insensitive), the comment additionally appends `Current version:` and `Target release:` lines (next-minor bump of the resolved `@runfusion/fusion` CLI package version); this enrichment is GitHub/Fusion-specific and intentionally out of scope for this GitLab parity pass. | `github-issue-comment.ts`; settings | Add GitLab equivalents using project issue notes. The template placeholders should remain provider-neutral (`{taskId}`, `{taskTitle}`) or be explicitly duplicated with `gitlabCommentOnDone`/`gitlabCommentTemplate`. Group issue imports still comment through owning project issue notes. A GitLab release-version enrichment equivalent is not implemented here — file a follow-up if needed. | -| Auto-close imported source issues | `githubCloseSourceIssueOnDone` closes/reopens source-imported GitHub issues on task state transitions and startup reconciliation closes missed done tasks. | `github-source-issue-close.ts`; `github-tracking-reconciler.ts` | Add GitLab source issue close/reopen on imported project issues. Reconciliation must use stored GitLab project identity plus issue IID/global ID. Group issue rows cannot be closed through group endpoint; resolve owning project first. Do not auto-close or merge merge requests unless a later task explicitly adds an MR policy. | +| Auto-close imported source issues | `githubCloseSourceIssueOnDone` closes/reopens source-imported GitHub issues on task state transitions and startup reconciliation closes missed done tasks. | `github-source-issue-close.ts`; `github-tracking-reconciler.ts` | `gitlabCloseSourceIssueOnDone` closes/reopens imported project issues on completion. Split-close deletion also posts one parent/child-task handoff note before closing the resolved project issue; empty child lists are ignored and duplicate IDs are de-duplicated. This is not general GitLab close-on-delete parity: ordinary deletes remain unchanged. Do not auto-close or merge merge requests, including on a split, unless a later task explicitly adds an MR policy. | | Source issue close timestamp backfill | Command Center GitHub fixed counts can be made exact by `POST /api/git/github/backfill-source-issue-closed-at` in manual batches. | `GithubArea.tsx`; `register-git-github.ts`; `TaskSourceIssue.closedAt` | Add a GitLab manual backfill for imported GitLab source issues/MRs if analytics need exact close/merged timestamps. It must be an explicit operator action and never run during render-time analytics. | | Webhooks for linked state | `POST /api/github/webhooks` verifies GitHub App signatures and updates issue/PR/task badges from pull_request/issues/issue_comment events. | `github-webhooks.ts`; `register-git-github.ts` | GitLab webhook support should use GitLab project/group/system hooks as provider-specific signed signal ingestion. Badge/state update hooks for GitLab-linked issues/MRs are separate from Command Center Signals but can reuse signature-validation patterns. Document exact GitLab headers/secrets in the implementation task. | | Batch status refresh and stale linked state | `POST /api/github/batch/status` refreshes issue/PR/tracking statuses. Cards render persisted state and stale badges without fetching on board load. | `register-git-github.ts`; `TaskDetailModal.tsx`; `ListView.tsx` | Add GitLab batch status refresh for linked GitLab issues/MRs/tracking issues. Board/list cards should render persisted provider-specific state and expose stale/unavailable status without blocking on network. | diff --git a/docs/task-management.md b/docs/task-management.md index 7bee1f97f6..9fad3e2bfb 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -737,6 +737,7 @@ Import issues: - GitHub-imported tasks retain typed source issue metadata (`sourceIssue.provider/repository/externalIssueId/issueNumber/url`), which executor and merger flows use to include `Ref: owner/repo#N` in commit bodies. - When `githubCloseSourceIssueOnDone` is enabled (default: `false`), Fusion also closes linked source-imported GitHub issues with `state_reason: completed` when the task moves into `done`. On startup, a bounded reconciliation sweep checks done tasks and closes any still-open source issue links that were missed due to transient failures. - When triage splits an imported GitHub task into subtasks and closes the parent, Fusion comments on each affected source/tracking issue with the parent and child task IDs immediately before closing it. A source and tracking link to the same issue have one deterministic owner, so that issue receives exactly one comment and one close; different issues each receive one of both. The `task:deleted` run-audit row records only `closureKind` and `closureChildTaskIds` (no comment prose). This notification is delivered by the deleting store's in-process event; PostgreSQL does not support cross-process observation of `task:deleted` through the legacy SQLite polling path. +- GitLab mirrors this split-close handoff for resolved project issues: it posts one note naming the parent and unique child task IDs, then closes that same issue only after the note succeeds. Empty child lists do nothing, duplicate IDs retain their first occurrence, and merge requests are never auto-closed. Ordinary GitLab task deletes intentionally do not yet close their source issue. ```bash fn task import owner/repo --labels bug --limit 20 diff --git a/packages/dashboard/src/__tests__/gitlab-parity-inventory-documentation.test.ts b/packages/dashboard/src/__tests__/gitlab-parity-inventory-documentation.test.ts index 2152c8b3ca..22293febd4 100644 --- a/packages/dashboard/src/__tests__/gitlab-parity-inventory-documentation.test.ts +++ b/packages/dashboard/src/__tests__/gitlab-parity-inventory-documentation.test.ts @@ -35,6 +35,8 @@ describe("gitlab parity inventory documentation contract", () => { "project issues", "group issues", "merge requests", + "Split-close deletion also posts one parent/child-task handoff note", + "This is not general GitLab close-on-delete parity", ]) { expect(inventory).toContain(required); } diff --git a/packages/dashboard/src/__tests__/gitlab-split-close.test.ts b/packages/dashboard/src/__tests__/gitlab-split-close.test.ts new file mode 100644 index 0000000000..64397db672 --- /dev/null +++ b/packages/dashboard/src/__tests__/gitlab-split-close.test.ts @@ -0,0 +1,113 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + client: { + commentOnProjectIssue: vi.fn(), + getProjectIssue: vi.fn(), + }, + resolveClient: vi.fn(), + resolveTarget: vi.fn(), + log: vi.fn(), + updateState: vi.fn(), +})); + +vi.mock("../gitlab-lifecycle.js", () => ({ + resolveGitLabClient: mocks.resolveClient, + resolveGitLabTarget: mocks.resolveTarget, + safeLogGitLabEntry: mocks.log, +})); +vi.mock("../gitlab-tracking-state.js", async (importOriginal) => ({ + ...await importOriginal(), + updateGitLabTargetState: mocks.updateState, +})); + +import { + GitLabSplitCloseService, + buildGitLabSplitCloseNote, + postGitLabSplitNoteBeforeClose, +} from "../gitlab-split-close.js"; + +const target = { kind: "project_issue" as const, project: "group/project", iid: 24, label: "group/project#24" }; +const task = { id: "FN-parent" } as any; +const split = { closureContext: { kind: "split-into-subtasks" as const, childTaskIds: ["FN-A", "FN-B"] } }; + +function store() { + return Object.assign(new EventEmitter(), { logEntry: vi.fn(), getSettings: vi.fn(), getGlobalSettingsStore: vi.fn() }); +} + +async function flush(): Promise { await new Promise((resolve) => setImmediate(resolve)); } + +describe("GitLab split-close", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveTarget.mockReturnValue(target); + mocks.resolveClient.mockResolvedValue({ ok: true, client: mocks.client }); + mocks.client.getProjectIssue.mockResolvedValue({ state: "opened" }); + mocks.client.commentOnProjectIssue.mockResolvedValue(undefined); + mocks.updateState.mockResolvedValue(undefined); + }); + + it("posts the parent and every child before closing the split source issue", async () => { + const s = store(); + new GitLabSplitCloseService(s as any).start(); + s.emit("task:deleted", task, split); + await vi.waitFor(() => expect(mocks.updateState).toHaveBeenCalledTimes(1)); + + expect(mocks.client.commentOnProjectIssue).toHaveBeenCalledTimes(1); + expect(mocks.client.commentOnProjectIssue).toHaveBeenCalledWith("group/project", 24, expect.stringContaining("FN-parent")); + const body = mocks.client.commentOnProjectIssue.mock.calls[0][2]; + expect(body).toContain("FN-A"); + expect(body).toContain("FN-B"); + expect(mocks.client.commentOnProjectIssue.mock.invocationCallOrder[0]).toBeLessThan(mocks.updateState.mock.invocationCallOrder[0]); + expect(mocks.updateState).toHaveBeenCalledWith(s, "FN-parent", target, "closed", "split-close"); + }); + + it("normalizes duplicate child IDs and declines an empty handoff", () => { + const note = buildGitLabSplitCloseNote("FN-parent", ["FN-A", "FN-A", "FN-B", ""]); + expect(note).toContain("FN-A, FN-B"); + expect(note!.match(/FN-A/g)).toHaveLength(1); + expect(buildGitLabSplitCloseNote("FN-parent", ["", ""])).toBeNull(); + }); + + it.each([ + [undefined, "no-closure-context"], + [{ closureContext: { kind: "other", childTaskIds: ["FN-A"] } }, "not-split"], + [{ closureContext: { kind: "split-into-subtasks", childTaskIds: [] } }, "empty-child-ids"], + ])("returns the typed no-op result for closure context %o", async (meta, reason) => { + await expect(postGitLabSplitNoteBeforeClose(store() as any, task, meta as any)).resolves.toEqual({ status: "no-op", reason }); + expect(mocks.client.commentOnProjectIssue).not.toHaveBeenCalled(); + }); + + it("returns typed no-ops for target, MR, closed issue, and unresolved auth", async () => { + mocks.resolveTarget.mockReturnValueOnce(null); + await expect(postGitLabSplitNoteBeforeClose(store() as any, task, split)).resolves.toEqual({ status: "no-op", reason: "no-target" }); + mocks.resolveTarget.mockReturnValueOnce({ ...target, kind: "merge_request" }); + await expect(postGitLabSplitNoteBeforeClose(store() as any, task, split)).resolves.toEqual({ status: "no-op", reason: "merge-request-target" }); + mocks.client.getProjectIssue.mockResolvedValueOnce({ state: "closed" }); + await expect(postGitLabSplitNoteBeforeClose(store() as any, task, split)).resolves.toEqual({ status: "no-op", reason: "already-closed" }); + mocks.resolveClient.mockResolvedValueOnce({ ok: false, message: "missing token" }); + await expect(postGitLabSplitNoteBeforeClose(store() as any, task, split)).resolves.toEqual({ status: "no-op", reason: "auth-unresolved" }); + }); + + it("returns failed when its note cannot post and never closes for no-op or failed results", async () => { + const error = new Error("note unavailable"); + mocks.client.commentOnProjectIssue.mockRejectedValue(error); + await expect(postGitLabSplitNoteBeforeClose(store() as any, task, split)).resolves.toEqual({ status: "failed", reason: "comment-post-failed", error }); + + const s = store(); + new GitLabSplitCloseService(s as any).start(); + s.emit("task:deleted", task, { closureContext: { kind: "split-into-subtasks", childTaskIds: [] } }); + s.emit("task:deleted", task, split); + await flush(); + expect(mocks.updateState).not.toHaveBeenCalled(); + }); + + it("uses the resolved tracking owner once, including when source metadata is also present", async () => { + const s = store(); + new GitLabSplitCloseService(s as any).start(); + s.emit("task:deleted", { ...task, sourceIssue: { provider: "gitlab" }, gitlabTracking: { item: {} } }, split); + await vi.waitFor(() => expect(mocks.updateState).toHaveBeenCalledTimes(1)); + expect(mocks.client.commentOnProjectIssue).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard/src/gitlab-lifecycle.ts b/packages/dashboard/src/gitlab-lifecycle.ts index e2073b314b..56d570ff96 100644 --- a/packages/dashboard/src/gitlab-lifecycle.ts +++ b/packages/dashboard/src/gitlab-lifecycle.ts @@ -23,7 +23,11 @@ export async function resolveGitLabClient(store: TaskStore): Promise<{ ok: true; export function resolveGitLabTarget(task: Pick): GitLabLifecycleTarget | null { const item = task.gitlabTracking?.item; - if (item) return resolveGitLabTargetFromItem(item); + const trackingTarget = item ? resolveGitLabTargetFromItem(item) : null; + if (trackingTarget) return trackingTarget; + + // FNXC:GitLabSplitClose 2026-08-01-09:58: malformed tracking metadata must not strand a valid imported source issue; the resolved tracking item remains the preferred owner. + const meta = task.source?.sourceMetadata && typeof task.source.sourceMetadata === "object" ? task.source.sourceMetadata as Record diff --git a/packages/dashboard/src/gitlab-split-close.ts b/packages/dashboard/src/gitlab-split-close.ts new file mode 100644 index 0000000000..53bd911ef0 --- /dev/null +++ b/packages/dashboard/src/gitlab-split-close.ts @@ -0,0 +1,98 @@ +import type { Task, TaskDeleteClosureContext, TaskStore } from "@fusion/core"; +import { resolveGitLabClient, resolveGitLabTarget, safeLogGitLabEntry, type GitLabLifecycleTarget } from "./gitlab-lifecycle.js"; +import { retryTransient, updateGitLabTargetState } from "./gitlab-tracking-state.js"; + +type TaskDeletedMeta = { closureContext?: TaskDeleteClosureContext | { kind?: string; childTaskIds?: unknown } }; + +export type GitLabSplitNoteResult = + | { status: "posted"; target: GitLabLifecycleTarget } + | { status: "no-op"; reason: "no-closure-context" | "not-split" | "empty-child-ids" | "no-target" | "merge-request-target" | "already-closed" | "auth-unresolved" } + | { status: "failed"; reason: "comment-post-failed"; error: unknown }; + +/* +FNXC:GitLabSplitClose 2026-08-01-09:58: +Split-close gives the source issue a durable handoff: one note names the parent and normalized child task IDs immediately before one close. resolveGitLabTarget() makes tracking the owner when it resolves and otherwise falls back to sourceIssue, so identity is by construction and one item cannot receive duplicate notes. + +Empty or blank child IDs leave no self-explanatory handoff and are a no-op; duplicate IDs retain their first occurrence only. The typed posted | no-op | failed result deliberately gates the caller: only posted may close the returned target, so a note failure or benign non-owner state can never close an issue without its explanatory note. +*/ +export function normalizeGitLabSplitChildTaskIds(childTaskIds: readonly string[]): string[] { + const seen = new Set(); + return childTaskIds.filter((childTaskId) => { + if (!childTaskId.trim() || seen.has(childTaskId)) return false; + seen.add(childTaskId); + return true; + }); +} + +export function buildGitLabSplitCloseNote(taskId: string, childTaskIds: readonly string[]): string | null { + const childIds = normalizeGitLabSplitChildTaskIds(childTaskIds); + if (childIds.length === 0) return null; + return `This issue was imported as Fusion task ${taskId}, which has been broken down into subtasks: ${childIds.join(", ")}. Closing this issue; work continues in those tasks.`; +} + +export async function postGitLabSplitNoteBeforeClose(store: TaskStore, task: Task, meta?: TaskDeletedMeta): Promise { + const closureContext = meta?.closureContext; + if (!closureContext) return { status: "no-op", reason: "no-closure-context" }; + if (closureContext.kind !== "split-into-subtasks") return { status: "no-op", reason: "not-split" }; + + const body = buildGitLabSplitCloseNote(task.id, Array.isArray(closureContext.childTaskIds) ? closureContext.childTaskIds : []); + if (!body) return { status: "no-op", reason: "empty-child-ids" }; + + const target = resolveGitLabTarget(task); + if (!target) return { status: "no-op", reason: "no-target" }; + if (target.kind === "merge_request") { + await safeLogGitLabEntry(store, task.id, "Skipped GitLab split-close note", `${target.label} is a merge request and is not auto-closed on task split`); + return { status: "no-op", reason: "merge-request-target" }; + } + + const resolved = await resolveGitLabClient(store); + if (!resolved.ok) return { status: "no-op", reason: "auth-unresolved" }; + + try { + const existing = await resolved.client.getProjectIssue(target.project, target.iid); + if (existing.state === "closed") return { status: "no-op", reason: "already-closed" }; + await retryTransient(() => resolved.client.commentOnProjectIssue(target.project, target.iid, body)); + await safeLogGitLabEntry(store, task.id, "Posted GitLab split-close note", target.label); + return { status: "posted", target }; + } catch (error) { + return { status: "failed", reason: "comment-post-failed", error }; + } +} + +export class GitLabSplitCloseService { + private readonly defaultStore: TaskStore; + private readonly listeners = new Map void }>(); + private started = false; + + constructor(store: TaskStore) { this.defaultStore = store; } + + start(): void { if (this.started) return; this.started = true; this.attach(this.defaultStore); } + stop(): void { if (!this.started) return; this.started = false; for (const store of [...this.listeners.keys()]) this.detach(store); } + + attach(store: TaskStore): void { + if (this.listeners.has(store)) return; + const onTaskDeleted = (task: Task, meta?: TaskDeletedMeta): void => { void this.handleTaskDeleted(store, task, meta); }; + this.listeners.set(store, { onTaskDeleted }); + if (this.started) store.on("task:deleted", onTaskDeleted); + } + + detach(store: TaskStore): void { + const handlers = this.listeners.get(store); + if (!handlers) return; + store.off("task:deleted", handlers.onTaskDeleted); + this.listeners.delete(store); + } + + private async handleTaskDeleted(store: TaskStore, task: Task, meta?: TaskDeletedMeta): Promise { + try { + const result = await postGitLabSplitNoteBeforeClose(store, task, meta); + if (result.status === "posted") { + await updateGitLabTargetState(store, task.id, result.target, "closed", "split-close"); + } else if (result.status === "failed") { + await safeLogGitLabEntry(store, task.id, "Failed to post GitLab split-close note", result.error instanceof Error ? result.error.message : String(result.error)); + } + } catch (error) { + await safeLogGitLabEntry(store, task.id, "Failed to process GitLab split-close", error instanceof Error ? error.message : String(error)); + } + } +} diff --git a/packages/dashboard/src/gitlab-tracking-state.ts b/packages/dashboard/src/gitlab-tracking-state.ts index adddbf373e..8c2480435a 100644 --- a/packages/dashboard/src/gitlab-tracking-state.ts +++ b/packages/dashboard/src/gitlab-tracking-state.ts @@ -3,7 +3,7 @@ import { GitLabApiError } from "./gitlab.js"; import { resolveGitLabClient, resolveGitLabTargetFromItem, safeLogGitLabEntry, type GitLabLifecycleTarget } from "./gitlab-lifecycle.js"; import { decideIssueAction, delay } from "./github-tracking-state.js"; -const TRANSIENT_RETRY_DELAY_MS = 25; +export const TRANSIENT_RETRY_DELAY_MS = 25; interface TaskMovedEvent { task: Task; from: string; to: string; } @@ -14,7 +14,7 @@ export function isTransientGitLabError(error: unknown): boolean { return message.includes("econn") || message.includes("timed out") || message.includes("socket hang up"); } -async function retryTransient(fn: () => Promise): Promise { +export async function retryTransient(fn: () => Promise): Promise { try { return await fn(); } catch (error) { if (!isTransientGitLabError(error)) throw error; await delay(TRANSIENT_RETRY_DELAY_MS); @@ -60,7 +60,7 @@ export class GitLabTrackingStateService { } } -export async function updateGitLabTargetState(store: TaskStore, taskId: string, target: GitLabLifecycleTarget, state: "opened" | "closed", source: "tracking" | "source"): Promise { +export async function updateGitLabTargetState(store: TaskStore, taskId: string, target: GitLabLifecycleTarget, state: "opened" | "closed", source: "tracking" | "source" | "split-close"): Promise { const action = state === "closed" ? "close" : "reopen"; try { const resolved = await resolveGitLabClient(store); diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index 7fbcfbdf0f..4fb359b889 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -58,6 +58,7 @@ export { rateLimit, RATE_LIMITS, type RateLimitOptions } from "./rate-limit.js"; export { GitHubPollingService, type GitHubPollingServiceOptions, type TaskWatchInput, type WatchedBadgeType } from "./github-poll.js"; export { GitHubIssueCommentService, DEFAULT_COMMENT_TEMPLATE } from "./github-issue-comment.js"; export { GitHubSourceIssueCloseService } from "./github-source-issue-close.js"; +export { GitLabSplitCloseService, buildGitLabSplitCloseNote, postGitLabSplitNoteBeforeClose, type GitLabSplitNoteResult } from "./gitlab-split-close.js"; export { upsertKnowledgePageAsync, queryKnowledgePagesAsync, diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index d7de3b8301..a0f36460ab 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -53,6 +53,7 @@ import { GitLabIssueCommentService } from "../gitlab-issue-comment.js"; import { GitLabTrackingCommentService } from "../gitlab-tracking-comments.js"; import { GitLabTrackingStateService } from "../gitlab-tracking-state.js"; import { GitLabSourceIssueCloseService } from "../gitlab-source-issue-close.js"; +import { GitLabSplitCloseService } from "../gitlab-split-close.js"; import { KnowledgeIndexRefreshService } from "../knowledge-index-refresh.js"; import { githubRateLimiter } from "../github-poll.js"; import * as projectStoreResolver from "../project-store-resolver.js"; @@ -2670,6 +2671,10 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { gitlabSourceIssueCloseService.start(); ctx.registerDispose(() => gitlabSourceIssueCloseService.stop()); + const gitlabSplitCloseService = new GitLabSplitCloseService(store); + gitlabSplitCloseService.start(); + ctx.registerDispose(() => gitlabSplitCloseService.stop()); + // U14 — incremental knowledge-index refresh on task completion. Listens for // task:moved → done and re-indexes just that task as a knowledge page. const knowledgeIndexRefreshService = new KnowledgeIndexRefreshService(store); @@ -2730,6 +2735,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { githubSourceIssueCloseService.attach(projectStore); gitlabTrackingStateService.attach(projectStore); gitlabSourceIssueCloseService.attach(projectStore); + gitlabSplitCloseService.attach(projectStore); // FNXC:Knowledge 2026-06-16-14:32: // Knowledge index refresh on task:moved→done must run for every registered project store, not just the primary. // Mirror the GitHubTrackingStateService/GitHubSourceIssueCloseService attach/detach lifecycle so non-primary