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>
78 lines
3.8 KiB
TypeScript
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));
|
|
}
|
|
}
|
|
}
|