From 71275abffe1bf80aebdc84d73b53609be85865fb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 19:07:51 -0700 Subject: [PATCH] fix(FN-7575): post release version lines on the surface that actually comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...n-7575-tracking-comment-release-version.md | 7 + .../github-tracking-comments.test.ts | 192 ++++++++++++++++++ .../__tests__/gitlab-issue-comment.test.ts | 29 +++ .../gitlab-tracking-comments.test.ts | 82 ++++++++ .../dashboard/src/fusion-release-version.ts | 84 ++++++++ .../dashboard/src/github-issue-comment.ts | 72 ++----- .../dashboard/src/github-tracking-comments.ts | 24 ++- .../dashboard/src/gitlab-issue-comment.ts | 24 ++- .../dashboard/src/gitlab-tracking-comments.ts | 30 ++- 9 files changed, 483 insertions(+), 61 deletions(-) create mode 100644 .changeset/fn-7575-tracking-comment-release-version.md create mode 100644 packages/dashboard/src/fusion-release-version.ts diff --git a/.changeset/fn-7575-tracking-comment-release-version.md b/.changeset/fn-7575-tracking-comment-release-version.md new file mode 100644 index 0000000000..a2c3faf8e2 --- /dev/null +++ b/.changeset/fn-7575-tracking-comment-release-version.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fusion self-repo issues now actually show the target release version when a task closes. +category: fix +dev: FN-7575 added the release lines to `GitHubIssueCommentService`, which is gated on the `githubCommentOnDone` setting (default false, no Settings UI) and so never fired. `GitHubTrackingCommentService` is the surface that posts the "✅ Done —" comments. The version logic moved to a shared `fusion-release-version.ts` and now applies to all four done-comment surfaces (GitHub/GitLab × tracking/issue). Lines join `optionalLines` so they count against `DONE_COMMENT_MAX_LENGTH`; GitLab self-repo matching uses `item.projectPath`, since the resolved target prefers the numeric `projectId`. diff --git a/packages/dashboard/src/__tests__/github-tracking-comments.test.ts b/packages/dashboard/src/__tests__/github-tracking-comments.test.ts index 4fd570070e..76cc1a7d43 100644 --- a/packages/dashboard/src/__tests__/github-tracking-comments.test.ts +++ b/packages/dashboard/src/__tests__/github-tracking-comments.test.ts @@ -24,6 +24,21 @@ vi.mock("../github-auth.js", () => ({ resolveGithubTrackingAuth: (...args: unknown[]) => mockResolveGithubTrackingAuth(...args), })); +const { mockGetCliPackageVersion } = vi.hoisted(() => ({ + mockGetCliPackageVersion: vi.fn(() => "0.57.0"), +})); + +/* + * FNXC:GitHubTrackingComments 2026-07-15-09:40: + * Pin the resolved CLI version so self-repo release-line assertions do not drift with each real + * release. `isUnresolvedCliPackageVersion` keeps its real behavior — fusion-release-version.ts + * depends on it for the 0.0.0 sentinel fallback. + */ +vi.mock("../cli-package-version.js", async (importOriginal) => ({ + ...(await importOriginal()), + getCliPackageVersion: () => mockGetCliPackageVersion(), +})); + class MockStore extends EventEmitter { logEntry: Mock; getSettings: Mock; @@ -237,6 +252,138 @@ describe("formatTrackingComment", () => { }); }); +/* + * FNXC:GitHubTrackingComments 2026-07-15-09:40: + * Regression coverage for issue #1916. Original symptom: done comments posted on runfusion/fusion + * issues carried no release version, because FN-7575 added the lines to GitHubIssueCommentService + * (off by default, no Settings UI) while GitHubTrackingCommentService is what actually posts. + * These assert the general invariant across every done-comment surface — the pure formatter AND + * the service that posts — not just the single reported repro. + */ +describe("formatTrackingComment release version lines (issue #1916)", () => { + const selfRepoTask = { + id: "FN-7575", + title: "GitHub comment with release version on Fusion task close", + branch: "fusion/fn-7575", + }; + + it("appends current and target release lines on a Fusion self-repo done comment", () => { + const comment = formatTrackingComment( + selfRepoTask, + "done", + { owner: "runfusion", repo: "fusion" }, + { currentVersion: "0.57.0" }, + ); + + expect(comment).toContain("Current version: v0.57.0"); + expect(comment).toContain("Target release: v0.58.0"); + }); + + it("matches the self-repo slug case-insensitively (issue #1916 uses Runfusion/Fusion)", () => { + const comment = formatTrackingComment( + selfRepoTask, + "done", + { owner: "Runfusion", repo: "Fusion" }, + { currentVersion: "0.57.0" }, + ); + + expect(comment).toContain("Target release: v0.58.0"); + }); + + it("bumps the minor and resets the patch", () => { + const comment = formatTrackingComment( + selfRepoTask, + "done", + { owner: "runfusion", repo: "fusion" }, + { currentVersion: "1.2.9" }, + ); + + expect(comment).toContain("Current version: v1.2.9"); + expect(comment).toContain("Target release: v1.3.0"); + }); + + it("leaves done comments on every other repo byte-for-byte unchanged", () => { + const withVersion = formatTrackingComment( + selfRepoTask, + "done", + { owner: "acme", repo: "widgets" }, + { currentVersion: "0.57.0" }, + ); + + expect(withVersion).not.toContain("Target release"); + expect(withVersion).not.toContain("Current version"); + // Byte-for-byte identical to the pre-fix output for non-self repos. + expect(withVersion).toBe( + formatTrackingComment(selfRepoTask, "done", { owner: "acme", repo: "widgets" }), + ); + }); + + it("omits release lines on the in-progress transition", () => { + const comment = formatTrackingComment(selfRepoTask, "in-progress", undefined, { + currentVersion: "0.57.0", + }); + + expect(comment).not.toContain("Target release"); + }); + + it("falls back silently when the version is the unresolved 0.0.0 sentinel", () => { + const comment = formatTrackingComment( + selfRepoTask, + "done", + { owner: "runfusion", repo: "fusion" }, + { currentVersion: "0.0.0" }, + ); + + expect(comment).not.toContain("Target release"); + expect(comment).toContain("✅ Done —"); + }); + + it("falls back silently when the version is unparseable", () => { + const comment = formatTrackingComment( + selfRepoTask, + "done", + { owner: "runfusion", repo: "fusion" }, + { currentVersion: "not-a-version" }, + ); + + expect(comment).not.toContain("Target release"); + expect(comment).toContain("✅ Done —"); + }); + + it("never resolves the package version for non-self repos", () => { + const resolveVersion = vi.fn(() => "0.57.0"); + formatTrackingComment(selfRepoTask, "done", { owner: "acme", repo: "widgets" }, { + currentVersion: resolveVersion, + }); + + expect(resolveVersion).not.toHaveBeenCalled(); + }); + + it("keeps release lines within the length cap when the title forces truncation", () => { + const comment = formatTrackingComment( + { + id: "FN-1", + title: "T".repeat(4000), + branch: "fusion/fn-1", + mergeDetails: { + commitSha: "abcdef1234567890", + mergeCommitMessage: `feat(FN-1): ${"subject ".repeat(200)}`, + prNumber: 7, + mergeTargetBranch: "main", + mergedAt: "2026-05-12T10:00:00.000Z", + filesChanged: 3, + }, + }, + "done", + { owner: "runfusion", repo: "fusion" }, + { currentVersion: "0.57.0" }, + ); + + expect(comment.length).toBeLessThanOrEqual(2000); + expect(comment).toContain("Target release: v0.58.0"); + }); +}); + describe("GitHubTrackingCommentService", () => { let store: MockStore; let service: GitHubTrackingCommentService; @@ -311,6 +458,51 @@ describe("GitHubTrackingCommentService", () => { expect(mockCommentOnIssue.mock.calls[1]?.[3]).toContain("Branch: fusion/fn-1"); }); + /* + * FNXC:GitHubTrackingComments 2026-07-15-09:40: + * Issue #1916 symptom verification at the surface that actually posts: a done comment on a + * runfusion/fusion issue must carry the release lines. The pure-formatter tests above cannot + * catch a service wired to a version resolver that never runs, so assert the posted body. + */ + it("posts release version lines on a Fusion self-repo done comment", async () => { + service.start(); + + store.emit("task:moved", { + task: createTask({ + githubTracking: { + enabled: true, + issue: { + owner: "Runfusion", + repo: "Fusion", + number: 1916, + url: "https://github.com/Runfusion/Fusion/issues/1916", + createdAt: "2026-07-05T15:30:13.000Z", + }, + }, + }), + from: "in-progress", + to: "done", + }); + await flushAsync(); + + const body = mockCommentOnIssue.mock.calls[0]?.[3] as string; + expect(body).toContain("✅ Done"); + expect(body).toContain("Current version: v0.57.0"); + expect(body).toContain("Target release: v0.58.0"); + }); + + it("posts no release version lines on a done comment for any other repo", async () => { + service.start(); + + store.emit("task:moved", { task: createTask(), from: "in-progress", to: "done" }); + await flushAsync(); + + const body = mockCommentOnIssue.mock.calls[0]?.[3] as string; + expect(body).toContain("✅ Done"); + expect(body).not.toContain("Target release"); + expect(body).not.toContain("Current version"); + }); + it("writes success logs", async () => { service.start(); diff --git a/packages/dashboard/src/__tests__/gitlab-issue-comment.test.ts b/packages/dashboard/src/__tests__/gitlab-issue-comment.test.ts index c1697c1d4d..f62383408e 100644 --- a/packages/dashboard/src/__tests__/gitlab-issue-comment.test.ts +++ b/packages/dashboard/src/__tests__/gitlab-issue-comment.test.ts @@ -25,6 +25,35 @@ describe("GitLabIssueCommentService", () => { expect(fetchImpl.mock.calls[0][0]).toBe("https://gitlab.example.com/api/v4/projects/g%2Fp/issues/2/notes"); expect(s.logEntry).toHaveBeenCalledWith("FN-1", "Posted GitLab issue completion comment", "g/p#2"); }); + /* + * FNXC:GitLabIssueComment 2026-07-15-10:05: + * Parity coverage for the issue #1916 release lines. Version is injected so assertions do not + * drift with each real release. + */ + it("appends release version lines for a Fusion self-repo source issue", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); + vi.stubGlobal("fetch", fetchImpl); + const s = store(); + new GitLabIssueCommentService(s as any, () => "0.60.0").start(); + const selfRepoTask = { ...task, gitlabTracking: { item: { ...task.gitlabTracking.item, projectPath: "runfusion/fusion" } } }; + s.emit("task:moved", { task: selfRepoTask, to: "done" }); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled()); + const body = JSON.parse(String((fetchImpl.mock.calls[0][1] as any).body)).body as string; + expect(body).toContain("Current version: v0.60.0"); + expect(body).toContain("Target release: v0.61.0"); + }); + + it("leaves completion comments for every other project unchanged", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); + vi.stubGlobal("fetch", fetchImpl); + const s = store(); + new GitLabIssueCommentService(s as any, () => "0.60.0").start(); + s.emit("task:moved", { task, to: "done" }); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled()); + const body = JSON.parse(String((fetchImpl.mock.calls[0][1] as any).body)).body as string; + expect(body).toBe("✅ Task FN-1 (Fix) has been completed and resolved."); + }); + it("skips non-GitLab and incomplete source metadata", async () => { const fetchImpl = vi.fn(); vi.stubGlobal("fetch", fetchImpl); const s = store(); new GitLabIssueCommentService(s as any).start(); diff --git a/packages/dashboard/src/__tests__/gitlab-tracking-comments.test.ts b/packages/dashboard/src/__tests__/gitlab-tracking-comments.test.ts index 8d52b0f760..75e255a5c1 100644 --- a/packages/dashboard/src/__tests__/gitlab-tracking-comments.test.ts +++ b/packages/dashboard/src/__tests__/gitlab-tracking-comments.test.ts @@ -29,3 +29,85 @@ describe("GitLabTrackingCommentService", () => { expect(fetchImpl).not.toHaveBeenCalled(); }); }); + +/* + * FNXC:GitLabTrackingComments 2026-07-15-10:05: + * Parity coverage for the issue #1916 release lines on the GitLab surface. + */ +describe("formatGitLabTrackingComment release version lines", () => { + function selfRepoTask(overrides: Record = {}): any { + return { ...task("project_issue"), branch: "fusion/fn-1", mergeDetails: { commitSha: "abcdef123", mergedAt: "today" }, ...overrides }; + } + + it("appends release lines when the linked project path is the Fusion self-repo", () => { + const comment = formatGitLabTrackingComment(selfRepoTask(), "done", undefined, { repository: "runfusion/fusion", currentVersion: "0.60.0" }); + expect(comment).toContain("Current version: v0.60.0"); + expect(comment).toContain("Target release: v0.61.0"); + }); + + it("matches the self-repo project path case-insensitively", () => { + const comment = formatGitLabTrackingComment(selfRepoTask(), "done", undefined, { repository: "Runfusion/Fusion", currentVersion: "0.60.0" }); + expect(comment).toContain("Target release: v0.61.0"); + }); + + it("leaves done comments on every other project byte-for-byte unchanged", () => { + const withVersion = formatGitLabTrackingComment(selfRepoTask(), "done", undefined, { repository: "g/p", currentVersion: "0.60.0" }); + expect(withVersion).not.toContain("Target release"); + expect(withVersion).toBe(formatGitLabTrackingComment(selfRepoTask(), "done")); + }); + + it("omits release lines on the in-progress transition", () => { + expect(formatGitLabTrackingComment(selfRepoTask(), "in-progress", undefined, { repository: "runfusion/fusion", currentVersion: "0.60.0" })).not.toContain("Target release"); + }); + + it("falls back silently for the unresolved sentinel and unparseable versions", () => { + for (const currentVersion of ["0.0.0", "not-a-version"]) { + const comment = formatGitLabTrackingComment(selfRepoTask(), "done", undefined, { repository: "runfusion/fusion", currentVersion }); + expect(comment).not.toContain("Target release"); + expect(comment).toContain("✅ Done —"); + } + }); + + it("never resolves the package version for non-self projects", () => { + const resolveVersion = vi.fn(() => "0.60.0"); + formatGitLabTrackingComment(selfRepoTask(), "done", undefined, { repository: "g/p", currentVersion: resolveVersion }); + expect(resolveVersion).not.toHaveBeenCalled(); + }); + + it("keeps release lines within the length cap when the title forces truncation", () => { + const comment = formatGitLabTrackingComment(selfRepoTask({ title: "T".repeat(4000) }), "done", "https://gitlab.example.com/g/p/-/issues/5", { repository: "runfusion/fusion", currentVersion: "0.60.0" }); + expect(comment.length).toBeLessThanOrEqual(2000); + expect(comment).toContain("Target release: v0.61.0"); + }); + + /* + * FNXC:GitLabTrackingComments 2026-07-15-10:05: + * resolveGitLabTargetFromItem() prefers the numeric projectId over projectPath, so passing the + * resolved target.project would stringify an id ("12345") and never match the self-repo slug. + * The service passes item.projectPath; assert the posted body proves that wiring. + */ + it("posts release lines using the project path even when a numeric projectId is present", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); vi.stubGlobal("fetch", fetchImpl); + const s = store(); + new GitLabTrackingCommentService(s as any).start(); + const tracked = task("project_issue"); + tracked.gitlabTracking.item.projectPath = "runfusion/fusion"; + tracked.gitlabTracking.item.projectId = 12345; + s.emit("task:moved", { task: tracked, from: "todo", to: "done" }); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled()); + const body = JSON.parse(String((fetchImpl.mock.calls[0][1] as any).body)).body as string; + expect(body).toContain("Target release: v"); + expect(body).toContain("Current version: v"); + }); + + it("posts no release lines for any other linked project", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); vi.stubGlobal("fetch", fetchImpl); + const s = store(); + new GitLabTrackingCommentService(s as any).start(); + s.emit("task:moved", { task: task("project_issue"), from: "todo", to: "done" }); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled()); + const body = JSON.parse(String((fetchImpl.mock.calls[0][1] as any).body)).body as string; + expect(body).toContain("✅ Done —"); + expect(body).not.toContain("Target release"); + }); +}); diff --git a/packages/dashboard/src/fusion-release-version.ts b/packages/dashboard/src/fusion-release-version.ts new file mode 100644 index 0000000000..586c7713b6 --- /dev/null +++ b/packages/dashboard/src/fusion-release-version.ts @@ -0,0 +1,84 @@ +import { isUnresolvedCliPackageVersion } from "./cli-package-version.js"; + +/* + * FNXC:GitHubIssueComment 2026-07-15-09:40: + * Requirement (FN-7575, issue #1916): when a Fusion task's linked source issue lives in the + * Fusion self-repo (`runfusion/fusion`, case-insensitive), the completion comment posted on + * `done` must ALSO report the current published `@runfusion/fusion` version and the targeted + * next-minor release that will ship the fix. Comments on every other linked repository stay + * byte-for-byte identical to the base template output. + * + * FNXC:GitHubIssueComment 2026-07-15-09:40: + * These helpers live in a shared module because TWO independent services can post a done + * comment on a linked GitHub issue, and the original FN-7575 fix only covered one of them: + * - GitHubIssueCommentService (github-issue-comment.ts) — gated on the `githubCommentOnDone` + * setting, which defaults to false and has no Settings UI, so it effectively never fires. + * - GitHubTrackingCommentService (github-tracking-comments.ts) — gated on per-task + * `githubTracking.enabled`, and the service that actually posts the "✅ Done —" comments. + * The version lines were invisible in production for ~10 days because they existed only on the + * first surface. Any NEW done-comment surface (e.g. the GitLab equivalents, which do not carry + * release lines today) must import from here rather than re-deriving the version logic. + */ +export const FUSION_SELF_REPO = "runfusion/fusion"; + +/** Case-insensitive, trimmed `owner/repo` slug comparison against the Fusion self-repo. */ +export function isFusionSelfRepo(repository: string): boolean { + return repository.trim().toLowerCase() === FUSION_SELF_REPO; +} + +/** `major.minor.patch` leading numeric semver shape; ignores any trailing prerelease/build metadata. */ +const SEMVER_PREFIX_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)/; + +/** + * Compute the next-minor release version (patch reset to 0) from a semver string, + * e.g. `"0.55.0"` -> `"0.56.0"`, `"1.2.9"` -> `"1.3.0"`, `"v0.55.0"` -> `"0.56.0"`. + * Returns `null` for the unresolved `"0.0.0"` sentinel or any unparseable input so + * callers can skip appending version lines rather than emit garbage. + */ +export function computeNextMinorVersion(current: string): string | null { + if (isUnresolvedCliPackageVersion(current)) { + return null; + } + + const match = SEMVER_PREFIX_PATTERN.exec(current.trim()); + if (!match) { + return null; + } + + const major = Number.parseInt(match[1] ?? "", 10); + const minor = Number.parseInt(match[2] ?? "", 10); + if (!Number.isFinite(major) || !Number.isFinite(minor)) { + return null; + } + + return `${major}.${minor + 1}.0`; +} + +/** + * Build the release-version lines for a done comment on a Fusion self-repo issue. + * + * Returns `[]` — never throws — when the repo is not the self-repo or the version is + * unresolvable, so callers can spread the result unconditionally and non-self-repo + * comments stay byte-for-byte unchanged. + * + * `currentVersion` accepts a resolver so the self-repo check short-circuits BEFORE + * `getCliPackageVersion()` walks the filesystem — every other repo's done comment pays nothing. + */ +export function formatReleaseVersionLines( + repository: string, + currentVersion: string | (() => string), +): string[] { + if (!isFusionSelfRepo(repository)) { + return []; + } + + const resolved = typeof currentVersion === "function" ? currentVersion() : currentVersion; + const nextMinorVersion = computeNextMinorVersion(resolved); + if (!nextMinorVersion) { + return []; + } + + const trimmed = resolved.trim(); + const currentLine = trimmed.startsWith("v") ? trimmed : `v${trimmed}`; + return [`Current version: ${currentLine}`, `Target release: v${nextMinorVersion}`]; +} diff --git a/packages/dashboard/src/github-issue-comment.ts b/packages/dashboard/src/github-issue-comment.ts index a3ec321b56..d54804befb 100644 --- a/packages/dashboard/src/github-issue-comment.ts +++ b/packages/dashboard/src/github-issue-comment.ts @@ -1,6 +1,12 @@ import type { TaskStore } from "@fusion/core"; import { GitHubClient } from "./github.js"; -import { getCliPackageVersion, isUnresolvedCliPackageVersion } from "./cli-package-version.js"; +import { getCliPackageVersion } from "./cli-package-version.js"; +import { + FUSION_SELF_REPO, + computeNextMinorVersion, + formatReleaseVersionLines, + isFusionSelfRepo, +} from "./fusion-release-version.js"; interface TaskMovedEvent { task: { @@ -18,51 +24,13 @@ interface TaskMovedEvent { const DEFAULT_COMMENT_TEMPLATE = "✅ Task {taskId} ({taskTitle}) has been completed and resolved."; /* - * FNXC:GitHubIssueComment 2026-07-05-01:30: - * Requirement: when a Fusion task's linked source GitHub issue lives in the - * Fusion self-repo (`runfusion/fusion`, case-insensitive), the completion - * comment posted on `done` must ALSO include both a "Current version:" line - * and a "Target release:" line (the next-minor bump of the currently - * published `@runfusion/fusion` version), so readers know which Fusion - * release ships the fix. Every other linked repository's completion comment - * must remain byte-for-byte identical to the pre-FN-7575 template output. - * If the resolved version is unparseable/unresolved (the `0.0.0` sentinel), - * fall back silently to the base comment with no version lines — never throw. + * FNXC:GitHubIssueComment 2026-07-15-09:40: + * The self-repo detection and next-minor computation moved to `fusion-release-version.ts` so the + * GitHubTrackingCommentService done comment — the surface that actually posts on linked issues — + * shares one implementation. See that module for the full requirement and the FN-7575 miss. + * NOTE: this service is gated on `settings.githubCommentOnDone`, which defaults to false and has + * no Settings UI, so it rarely fires; do not treat it as the primary done-comment surface. */ -const FUSION_SELF_REPO = "runfusion/fusion"; - -/** Case-insensitive, trimmed `owner/repo` slug comparison against the Fusion self-repo. */ -function isFusionSelfRepo(repository: string): boolean { - return repository.trim().toLowerCase() === FUSION_SELF_REPO; -} - -/** `major.minor.patch` leading numeric semver shape; ignores any trailing prerelease/build metadata. */ -const SEMVER_PREFIX_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)/; - -/** - * Compute the next-minor release version (patch reset to 0) from a semver string, - * e.g. `"0.55.0"` -> `"0.56.0"`, `"1.2.9"` -> `"1.3.0"`, `"v0.55.0"` -> `"0.56.0"`. - * Returns `null` for the unresolved `"0.0.0"` sentinel or any unparseable input so - * callers can skip appending version lines rather than emit garbage. - */ -function computeNextMinorVersion(current: string): string | null { - if (isUnresolvedCliPackageVersion(current)) { - return null; - } - - const match = SEMVER_PREFIX_PATTERN.exec(current.trim()); - if (!match) { - return null; - } - - const major = Number.parseInt(match[1] ?? "", 10); - const minor = Number.parseInt(match[2] ?? "", 10); - if (!Number.isFinite(major) || !Number.isFinite(minor)) { - return null; - } - - return `${major}.${minor + 1}.0`; -} export class GitHubIssueCommentService { private readonly store: TaskStore; @@ -126,13 +94,9 @@ export class GitHubIssueCommentService { .replaceAll("{taskId}", task.id) .replaceAll("{taskTitle}", task.title ?? ""); - if (isFusionSelfRepo(sourceIssue.repository)) { - const currentVersion = this.getCurrentVersion(); - const nextMinorVersion = computeNextMinorVersion(currentVersion); - if (nextMinorVersion) { - const currentLine = currentVersion.startsWith("v") ? currentVersion : `v${currentVersion}`; - commentBody += `\n\nCurrent version: ${currentLine}\nTarget release: v${nextMinorVersion}`; - } + const versionLines = formatReleaseVersionLines(sourceIssue.repository, () => this.getCurrentVersion()); + if (versionLines.length > 0) { + commentBody += `\n\n${versionLines.join("\n")}`; } try { @@ -154,4 +118,6 @@ export class GitHubIssueCommentService { } } -export { DEFAULT_COMMENT_TEMPLATE, FUSION_SELF_REPO, isFusionSelfRepo, computeNextMinorVersion }; +export { DEFAULT_COMMENT_TEMPLATE }; +// Re-exported from ./fusion-release-version.js for back-compat with existing importers/tests. +export { FUSION_SELF_REPO, isFusionSelfRepo, computeNextMinorVersion }; diff --git a/packages/dashboard/src/github-tracking-comments.ts b/packages/dashboard/src/github-tracking-comments.ts index 89cd7fc23c..14a7bdf951 100644 --- a/packages/dashboard/src/github-tracking-comments.ts +++ b/packages/dashboard/src/github-tracking-comments.ts @@ -2,6 +2,8 @@ import type { GlobalSettings, MergeDetails, ProjectSettings, Task, TaskStore } f import { deriveTitleFromDescription } from "./github-tracking.js"; import { GitHubClient } from "./github.js"; import { resolveGithubTrackingAuth } from "./github-auth.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; @@ -92,10 +94,16 @@ function formatFilesLine(mergeDetails: MergeDetails | undefined): string | null return line; } +/* + * FNXC:GitHubTrackingComments 2026-07-15-09:40: + * Release lines join `optionalLines` (rather than being appended to the finished string) so they + * are counted in `extraLength` and the title budget shrinks to accommodate them. Appending after + * the fact would silently push long-title comments past DONE_COMMENT_MAX_LENGTH. + */ function buildDoneComment( task: Pick, linkContext?: TrackingLinkContext, - options?: { includeCommitSubject?: boolean; includeFilesLine?: boolean }, + options?: { includeCommitSubject?: boolean; includeFilesLine?: boolean; currentVersion?: string | (() => string) }, ): string { const branch = sanitizeInlineText(task.branch ?? ""); const mergedAt = collapseWhitespace(task.mergeDetails?.mergedAt ?? ""); @@ -125,6 +133,12 @@ function buildDoneComment( if (mergedAt) { optionalLines.push(`Merged: ${mergedAt}`); } + if (linkContext) { + optionalLines.push(...formatReleaseVersionLines( + `${linkContext.owner}/${linkContext.repo}`, + options?.currentVersion ?? (() => getCliPackageVersion()), + )); + } const prefix = `Fusion task: ${task.id}\n\n`; const stem = "✅ Done — “"; @@ -143,19 +157,21 @@ export function formatTrackingComment( task: Pick, transition: "in-progress" | "done", linkContext?: TrackingLinkContext, + options?: { currentVersion?: string | (() => string) }, ): string { if (transition === "done") { - let comment = buildDoneComment(task, linkContext, { includeCommitSubject: true, includeFilesLine: true }); + const currentVersion = options?.currentVersion; + let comment = buildDoneComment(task, linkContext, { includeCommitSubject: true, includeFilesLine: true, currentVersion }); if (comment.length <= DONE_COMMENT_MAX_LENGTH) { return comment; } - comment = buildDoneComment(task, linkContext, { includeCommitSubject: false, includeFilesLine: true }); + comment = buildDoneComment(task, linkContext, { includeCommitSubject: false, includeFilesLine: true, currentVersion }); if (comment.length <= DONE_COMMENT_MAX_LENGTH) { return comment; } - return buildDoneComment(task, linkContext, { includeCommitSubject: false, includeFilesLine: false }); + return buildDoneComment(task, linkContext, { includeCommitSubject: false, includeFilesLine: false, currentVersion }); } const prefix = `Fusion task: ${task.id}\n\n`; diff --git a/packages/dashboard/src/gitlab-issue-comment.ts b/packages/dashboard/src/gitlab-issue-comment.ts index eb0f57ff17..521f6afa29 100644 --- a/packages/dashboard/src/gitlab-issue-comment.ts +++ b/packages/dashboard/src/gitlab-issue-comment.ts @@ -1,5 +1,7 @@ import type { ProjectSettings, Task, TaskStore } from "@fusion/core"; import { resolveGitLabClient, resolveGitLabTarget, safeLogGitLabEntry } from "./gitlab-lifecycle.js"; +import { getCliPackageVersion } from "./cli-package-version.js"; +import { formatReleaseVersionLines } from "./fusion-release-version.js"; interface TaskMovedEvent { task: Task; @@ -8,13 +10,22 @@ interface TaskMovedEvent { export const DEFAULT_GITLAB_COMMENT_TEMPLATE = "✅ Task {taskId} ({taskTitle}) has been completed and resolved."; +/* + * FNXC:GitLabIssueComment 2026-07-15-10:05: + * Mirrors the GitHub self-repo release lines (issue #1916) via the shared fusion-release-version + * helper. NOTE: gated on `settings.gitlabCommentOnDone` (default false, no Settings UI), so this is + * NOT the surface that normally posts — GitLabTrackingCommentService is. Kept in sync so the two + * cannot drift the way github-issue-comment.ts drifted from github-tracking-comments.ts. + */ export class GitLabIssueCommentService { private readonly store: TaskStore; + private readonly getCurrentVersion: () => string; private readonly onTaskMoved = (event: TaskMovedEvent): void => { void this.handleTaskMoved(event); }; private started = false; - constructor(store: TaskStore) { + constructor(store: TaskStore, getCurrentVersion?: () => string) { this.store = store; + this.getCurrentVersion = getCurrentVersion ?? (() => getCliPackageVersion()); } start(): void { @@ -41,7 +52,16 @@ export class GitLabIssueCommentService { } const template = settings.gitlabCommentTemplate || DEFAULT_GITLAB_COMMENT_TEMPLATE; - const body = template.replaceAll("{taskId}", event.task.id).replaceAll("{taskTitle}", event.task.title ?? ""); + let body = template.replaceAll("{taskId}", event.task.id).replaceAll("{taskTitle}", event.task.title ?? ""); + + // Project PATH only — resolveGitLabTarget() prefers the numeric projectId, which never matches the slug. + const repository = event.task.gitlabTracking?.item?.projectPath ?? event.task.sourceIssue?.repository; + if (repository) { + const versionLines = formatReleaseVersionLines(repository, () => this.getCurrentVersion()); + if (versionLines.length > 0) { + body += `\n\n${versionLines.join("\n")}`; + } + } try { const resolved = await resolveGitLabClient(this.store); diff --git a/packages/dashboard/src/gitlab-tracking-comments.ts b/packages/dashboard/src/gitlab-tracking-comments.ts index 5271c42a96..2e1ec946c6 100644 --- a/packages/dashboard/src/gitlab-tracking-comments.ts +++ b/packages/dashboard/src/gitlab-tracking-comments.ts @@ -1,5 +1,7 @@ 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; @@ -13,7 +15,20 @@ function title(task: Pick, max: number): string { return truncate(value, max); } -export function formatGitLabTrackingComment(task: Pick, transition: "in-progress" | "done", targetUrl?: string): string { +/* + * 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, + 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 “"; @@ -25,6 +40,12 @@ export function formatGitLabTrackingComment(task: Pick getCliPackageVersion()), + )); + } const prefix = `Fusion task: ${task.id}\n\n`; const stem = "✅ Done — “"; const suffix = "” is complete."; @@ -60,7 +81,12 @@ export class GitLabTrackingCommentService { 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); + 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) {