Files
fusion/packages/dashboard/src/gitlab-tracking-comments.ts
gsxdsm 34be333a2a FN-7426: add GitLab lifecycle automation
Add GitLab lifecycle automation for comments and source item state changes.

- Add GitLab REST helpers for posting notes and closing or reopening issues and merge requests.
- Register GitLab source-comment, tracking-comment, tracking-state, and source-close services with multi-project lifecycle wiring.
- Add project settings, documentation, tests, and a changeset for GitLab completion comments and auto-close behavior.

Files changed:
 .changeset/fn-7426-gitlab-lifecycle.md             |  7 ++
 docs/cli-reference.md                              |  2 +-
 docs/dashboard-guide.md                            |  3 +-
 docs/settings-reference.md                         |  5 +-
 packages/core/src/settings-schema.ts               |  3 +
 packages/core/src/types.ts                         | 10 +++
 .../src/__tests__/gitlab-issue-comment.test.ts     | 37 +++++++++
 .../__tests__/gitlab-source-issue-close.test.ts    | 29 +++++++
 .../src/__tests__/gitlab-tracking-comments.test.ts | 31 +++++++
 .../src/__tests__/gitlab-tracking-state.test.ts    | 42 ++++++++++
 packages/dashboard/src/__tests__/gitlab.test.ts    | 18 ++++
 .../register-git-github.gitlab-lifecycle.test.ts   | 54 ++++++++++++
 packages/dashboard/src/gitlab-issue-comment.ts     | 62 ++++++++++++++
 packages/dashboard/src/gitlab-lifecycle.ts         | 58 +++++++++++++
 .../dashboard/src/gitlab-source-issue-close.ts     | 45 ++++++++++
 packages/dashboard/src/gitlab-tracking-comments.ts | 77 +++++++++++++++++
 packages/dashboard/src/gitlab-tracking-state.ts    | 97 ++++++++++++++++++++++
 packages/dashboard/src/gitlab.ts                   | 30 +++++++
 .../dashboard/src/routes/register-git-github.ts    | 23 +++++
 19 files changed, 630 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-7426

Fusion-Task-Lineage: a46bd11f-96ef-4291-ab41-829007fa057a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-02 11:38:34 -07:00

78 lines
3.8 KiB
TypeScript

import type { Task, TaskStore } from "@fusion/core";
import { resolveGitLabClient, resolveGitLabTargetFromItem, safeLogGitLabEntry } from "./gitlab-lifecycle.js";
const COMMENT_MAX_LENGTH = 500;
const DONE_COMMENT_MAX_LENGTH = 2000;
interface TaskMovedEvent { task: Task; from: string; to: string; }
function clean(value: string): string { return value.replace(/\s+/g, " ").replace(/[[\]()]/g, "").trim(); }
function truncate(value: string, max: number): string { return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1)).trimEnd()}…`; }
function title(task: Pick<Task, "title" | "description">, max: number): string {
const value = clean(task.title ?? "") || clean((task.description ?? "").split("\n", 1)[0] ?? "") || "Untitled task";
return truncate(value, max);
}
export function formatGitLabTrackingComment(task: Pick<Task, "id" | "title" | "description" | "branch" | "mergeDetails">, transition: "in-progress" | "done", targetUrl?: string): string {
if (transition === "in-progress") {
const prefix = `Fusion task: ${task.id}\n\n`;
const stem = "🚧 In progress — work has started on “";
const suffix = "”.";
return `${prefix}${stem}${title(task, COMMENT_MAX_LENGTH - prefix.length - stem.length - suffix.length)}${suffix}`;
}
const lines: string[] = [];
if (task.mergeDetails?.commitSha) lines.push(`Commit: ${task.mergeDetails.commitSha.slice(0, 7)}`);
if (task.branch) lines.push(`Branch: ${clean(task.branch)}`);
if (task.mergeDetails?.mergedAt) lines.push(`Merged: ${task.mergeDetails.mergedAt}`);
if (targetUrl) lines.push(`GitLab: ${targetUrl}`);
const prefix = `Fusion task: ${task.id}\n\n`;
const stem = "✅ Done — “";
const suffix = "” is complete.";
const extra = lines.length ? `\n${lines.join("\n")}` : "";
return `${prefix}${stem}${title(task, DONE_COMMENT_MAX_LENGTH - prefix.length - stem.length - suffix.length - extra.length)}${suffix}${extra}`;
}
export class GitLabTrackingCommentService {
private readonly store: TaskStore;
private readonly onTaskMoved = (event: TaskMovedEvent): void => { void this.handleTaskMoved(event); };
private started = false;
constructor(store: TaskStore) { this.store = store; }
start(): void {
if (this.started) return;
this.started = true;
this.store.on("task:moved", this.onTaskMoved);
}
stop(): void {
if (!this.started) return;
this.started = false;
this.store.off("task:moved", this.onTaskMoved);
}
private async handleTaskMoved(event: TaskMovedEvent): Promise<void> {
if (event.from === event.to || (event.to !== "in-progress" && event.to !== "done")) return;
const item = event.task.gitlabTracking?.item;
if (!item) return;
const target = resolveGitLabTargetFromItem(item);
if (!target) {
await safeLogGitLabEntry(this.store, event.task.id, "Failed to post GitLab tracking comment", "Linked GitLab metadata is incomplete");
return;
}
const body = formatGitLabTrackingComment(event.task, event.to, event.to === "done" ? target.url : undefined);
try {
const resolved = await resolveGitLabClient(this.store);
if (!resolved.ok) {
await safeLogGitLabEntry(this.store, event.task.id, "Skipped GitLab tracking comment", resolved.message);
return;
}
if (target.kind === "merge_request") await resolved.client.commentOnMergeRequest(target.project, target.iid, body);
else await resolved.client.commentOnProjectIssue(target.project, target.iid, body);
await safeLogGitLabEntry(this.store, event.task.id, "Posted GitLab tracking comment", `${target.label} (${event.to})`);
} catch (error) {
await safeLogGitLabEntry(this.store, event.task.id, "Failed to post GitLab tracking comment", error instanceof Error ? error.message : String(error));
}
}
}