Files
fusion/packages/dashboard/src/gitlab-tracking-comments.ts
gsxdsm 71275abffe fix(FN-7575): post release version lines on the surface that actually comments
FN-7575 (issue #1916) added "Current version:" / "Target release:" lines to
GitHubIssueCommentService, but that service is gated on `githubCommentOnDone`
— default false, with no Settings UI — so it effectively never fires. The
"✅ Done —" comments on linked issues are posted by GitHubTrackingCommentService,
which had no version logic. The lines were invisible in production for ~10 days;
issue #1916's own close comment is the proof.

Extract the self-repo check and next-minor computation into a shared
fusion-release-version.ts and apply it across all four done-comment surfaces
(GitHub/GitLab x tracking/issue) so they cannot drift again.

- Release lines join `optionalLines` rather than being appended to the finished
  string, so they count against DONE_COMMENT_MAX_LENGTH and shrink the title
  budget; appending would silently blow the cap on long titles.
- Version resolution is a lazy resolver, so getCliPackageVersion()'s filesystem
  walk only runs for self-repo comments.
- GitLab self-repo matching uses item.projectPath: resolveGitLabTargetFromItem()
  prefers the numeric projectId, which never matches the slug.
- Non-self repos stay byte-for-byte unchanged (asserted).

Per the Surface Enumeration rule, regression tests assert the invariant across
every done-comment surface — both the pure formatters and the services that
post — plus case-insensitive slug matching (issue #1916 is "Runfusion/Fusion"),
in-progress transitions, the 0.0.0 sentinel, unparseable versions, the
lazy-resolution guarantee, and the truncation ladder under the length cap.
Verified non-vacuous: 10 of the new tests fail against the pre-fix source.

Fusion-Task-Id: FN-7575

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:07:51 -07:00

104 lines
4.8 KiB
TypeScript

import type { Task, TaskStore } from "@fusion/core";
import { resolveGitLabClient, resolveGitLabTargetFromItem, safeLogGitLabEntry } from "./gitlab-lifecycle.js";
import { getCliPackageVersion } from "./cli-package-version.js";
import { formatReleaseVersionLines } from "./fusion-release-version.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);
}
/*
* FNXC:GitLabTrackingComments 2026-07-15-10:05:
* Requirement (issue #1916 follow-up): GitLab done comments carry the same Fusion self-repo release
* lines as the GitHub surface, via the shared fusion-release-version helper. `repository` must be the
* project PATH — resolveGitLabTargetFromItem() prefers the numeric projectId, which never matches the
* self-repo slug — so callers pass item.projectPath explicitly rather than the resolved target.project.
* Release lines join `lines` so they count against DONE_COMMENT_MAX_LENGTH and shrink the title budget.
*/
export function formatGitLabTrackingComment(
task: Pick<Task, "id" | "title" | "description" | "branch" | "mergeDetails">,
transition: "in-progress" | "done",
targetUrl?: string,
options?: { repository?: string; currentVersion?: string | (() => 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}`);
if (options?.repository) {
lines.push(...formatReleaseVersionLines(
options.repository,
options.currentVersion ?? (() => getCliPackageVersion()),
));
}
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,
{ repository: item.projectPath },
);
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));
}
}
}