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>
This commit is contained in:
7
.changeset/fn-7575-tracking-comment-release-version.md
Normal file
7
.changeset/fn-7575-tracking-comment-release-version.md
Normal file
@@ -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`.
|
||||
@@ -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<typeof import("../cli-package-version.js")>()),
|
||||
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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, unknown> = {}): 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");
|
||||
});
|
||||
});
|
||||
|
||||
84
packages/dashboard/src/fusion-release-version.ts
Normal file
84
packages/dashboard/src/fusion-release-version.ts
Normal file
@@ -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}`];
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<Task, "id" | "title" | "description" | "branch" | "mergeDetails">,
|
||||
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<Task, "id" | "title" | "description" | "branch" | "mergeDetails">,
|
||||
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`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Task, "title" | "description">, max: number): string {
|
||||
return truncate(value, max);
|
||||
}
|
||||
|
||||
export function formatGitLabTrackingComment(task: Pick<Task, "id" | "title" | "description" | "branch" | "mergeDetails">, 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<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 “";
|
||||
@@ -25,6 +40,12 @@ export function formatGitLabTrackingComment(task: Pick<Task, "id" | "title" | "d
|
||||
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.";
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user