diff --git a/.changeset/FN-4142-github-tracking-done-comment.md b/.changeset/FN-4142-github-tracking-done-comment.md new file mode 100644 index 000000000..dfbdeac33 --- /dev/null +++ b/.changeset/FN-4142-github-tracking-done-comment.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +GitHub tracking 'done' comments now include the merge commit SHA, subject, branch, PR link, file-change stats, and merge timestamp when available. diff --git a/docs/architecture.md b/docs/architecture.md index 609b413b7..fb8b2354d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1327,7 +1327,7 @@ Fusion attempts GitHub issue creation when per-task tracking is explicitly enabl When Fusion does create a tracking issue, it formats the title as `[FN-XXXX] Task title` and sends a short plain-text body prefixed with `Fusion task: FN-XXXX`. The body is a bounded summary snippet (not full task prompt content), and Fusion does not include any hyperlink back to the local dashboard. Manual unlink requests (`githubTracking.issue: null`) do not recreate an issue in that same PATCH request, and disable updates do not create issues. Auth resolution remains strict-mode (`token` vs `gh-cli`) but now defensively accepts merged settings shapes where auth keys may appear in global-merged payloads. -When a tracked task later moves to `in-progress` or `done`, Fusion posts one short lifecycle comment on the linked tracking issue. These comments include the Fusion task ID as plain text (`Fusion task: FN-XXXX`) and never link back to the Fusion app. No comment is posted for any other transition. +When a tracked task later moves to `in-progress` or `done`, Fusion posts one short lifecycle comment on the linked tracking issue. These comments always include the Fusion task ID as plain text (`Fusion task: FN-XXXX`) and never link back to the Fusion app. The `in-progress` comment stays plain-text; the `done` comment can additionally include GitHub commit/PR markdown links plus branch, file-change, and merge-timestamp details when that merge context is available on the task. No comment is posted for any other transition. When a tracked task transitions into `done`, Fusion closes the linked GitHub issue with `state_reason: completed`. When a task transitions out of `done` into any active column (`triage`, `todo`, `in-progress`, `in-review`), Fusion reopens it with `state_reason: reopened`. Moves from `done` to `archived` leave the issue closed. Tasks without `githubTracking.enabled` or without a linked issue are unaffected, and GitHub failures are logged to task activity without blocking the move. diff --git a/docs/task-management.md b/docs/task-management.md index 7415bd830..ad0e4e598 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -445,6 +445,8 @@ When Fusion creates a tracking issue, it uses: - Body prefix: `Fusion task: FN-XXXX` - Body content: bounded plain-text task summary snippet (not full prompt content) +When tracked tasks later move to `in-progress` or `done`, Fusion also posts a short lifecycle comment on the linked tracking issue. The `in-progress` comment stays plain-text and capped, while the `done` comment can include the merge commit SHA/subject, task branch, PR link, file-change stats, and merge timestamp when those fields are available. + GitHub authentication/settings are configured in [Settings Reference](./settings-reference.md) via `githubAuthMode` (`gh-cli` or `token`) and `githubAuthToken`. ## Completion Modes (`mergeStrategy`) diff --git a/packages/dashboard/src/__tests__/github-tracking-comments.test.ts b/packages/dashboard/src/__tests__/github-tracking-comments.test.ts index 1757e4b14..972edd603 100644 --- a/packages/dashboard/src/__tests__/github-tracking-comments.test.ts +++ b/packages/dashboard/src/__tests__/github-tracking-comments.test.ts @@ -80,19 +80,128 @@ describe("formatTrackingComment", () => { expect(comment).toContain("Line 1 Line 2"); }); - it("truncates long titles and caps total length", () => { - const comment = formatTrackingComment({ id: "FN-1", title: "A".repeat(1000) }, "done"); + it("keeps in-progress comments capped at 500 characters", () => { + const comment = formatTrackingComment({ id: "FN-1", title: "A".repeat(1000) }, "in-progress"); expect(comment.length).toBeLessThanOrEqual(500); expect(comment).toContain("…"); }); - it("never includes urls or markdown links", () => { - const comment = formatTrackingComment({ id: "FN-1", title: "hello" }, "done"); + it("keeps urls and markdown links out of in-progress comments", () => { + const comment = formatTrackingComment({ id: "FN-1", title: "hello" }, "in-progress"); expect(comment).not.toContain("localhost"); expect(comment).not.toContain("http://"); expect(comment).not.toContain("https://"); expect(comment).not.toContain("]("); }); + + it("keeps the legacy done comment when merge details are absent", () => { + expect(formatTrackingComment({ id: "FN-1", title: "Build thing" }, "done")).toBe( + "Fusion task: FN-1\n\n✅ Done — “Build thing” is complete.", + ); + }); + + it("formats a done comment with merge details and links", () => { + const comment = formatTrackingComment( + { + id: "FN-1", + title: "Build thing", + branch: "fusion/fn-1", + mergeDetails: { + commitSha: "abcdef1234567890", + mergeCommitMessage: "feat(FN-1): ship thing\n\nbody", + prNumber: 7, + mergeTargetBranch: "main", + mergedAt: "2026-05-12T10:00:00.000Z", + filesChanged: 3, + insertions: 42, + deletions: 5, + }, + }, + "done", + { owner: "owner", repo: "repo" }, + ); + + expect(comment).toContain("abcdef1"); + expect(comment).toContain("featFN-1: ship thing"); + expect(comment).not.toContain("body"); + expect(comment).toContain("Branch: fusion/fn-1"); + expect(comment).toContain("PR: [owner/repo#7](https://github.com/owner/repo/pull/7)"); + expect(comment).toContain("https://github.com/owner/repo/commit/abcdef1234567890"); + expect(comment).toContain("Files: 3 changed (+42 / -5)"); + expect(comment).toContain("Merged: 2026-05-12T10:00:00.000Z"); + }); + + it("omits empty merge placeholders when only commit details are present", () => { + const comment = formatTrackingComment( + { + id: "FN-1", + title: "Build thing", + mergeDetails: { + commitSha: "abcdef1234567890", + mergeCommitMessage: "feat(FN-1): ship thing\n\nbody", + }, + }, + "done", + ); + + expect(comment).toContain("Commit: abcdef1 featFN-1: ship thing"); + expect(comment).not.toContain("Branch:"); + expect(comment).not.toContain("PR:"); + expect(comment).not.toContain("Files:"); + expect(comment).not.toContain("Merged:"); + expect(comment).not.toContain("undefined"); + expect(comment).not.toContain(": \n"); + }); + + it("keeps done comments plaintext when link context is missing", () => { + const comment = formatTrackingComment( + { + id: "FN-1", + title: "Build thing", + mergeDetails: { + commitSha: "abcdef1234567890", + mergeCommitMessage: "feat(FN-1): ship thing", + prNumber: 7, + }, + }, + "done", + ); + + expect(comment).toContain("Commit: abcdef1 featFN-1: ship thing"); + expect(comment).toContain("PR: #7"); + expect(comment).not.toContain("]("); + expect(comment).not.toContain("https://"); + }); + + it("caps enriched done comments at 2000 characters and drops the commit subject before required lines", () => { + const comment = formatTrackingComment( + { + id: "FN-1", + title: "Title ".repeat(300), + branch: "fusion/fn-1", + mergeDetails: { + commitSha: "abcdef1234567890", + mergeCommitMessage: `feat(FN-1): ${"subject ".repeat(220)}\n\nbody`, + prNumber: 7, + mergedAt: "2026-05-12T10:00:00.000Z", + filesChanged: 3, + insertions: 42, + deletions: 5, + }, + }, + "done", + { owner: "owner", repo: "repo" }, + ); + + expect(comment.length).toBeLessThanOrEqual(2000); + expect(comment).toContain("Fusion task: FN-1"); + expect(comment).toContain("✅ Done —"); + expect(comment).toContain("Branch: fusion/fn-1"); + expect(comment).toContain("PR: [owner/repo#7](https://github.com/owner/repo/pull/7)"); + expect(comment).toContain("Merged: 2026-05-12T10:00:00.000Z"); + expect(comment).toContain("Commit: [abcdef1](https://github.com/owner/repo/commit/abcdef1234567890)"); + expect(comment).not.toContain("subject subject subject"); + }); }); describe("GitHubTrackingCommentService", () => { @@ -137,7 +246,17 @@ describe("GitHubTrackingCommentService", () => { service.start(); store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" }); - store.emit("task:moved", { task: createTask(), from: "in-progress", to: "done" }); + store.emit("task:moved", { + task: createTask({ + branch: "fusion/fn-1", + mergeDetails: { + commitSha: "abcdef1234567890", + mergeCommitMessage: "feat(FN-1): ship thing", + }, + }), + from: "in-progress", + to: "done", + }); await flushAsync(); expect(mockCommentOnIssue).toHaveBeenCalledTimes(2); @@ -155,6 +274,8 @@ describe("GitHubTrackingCommentService", () => { 42, expect.stringContaining("✅ Done"), ); + expect(mockCommentOnIssue.mock.calls[1]?.[3]).toContain("abcdef1"); + expect(mockCommentOnIssue.mock.calls[1]?.[3]).toContain("Branch: fusion/fn-1"); }); it("writes success logs", async () => { diff --git a/packages/dashboard/src/github-tracking-comments.ts b/packages/dashboard/src/github-tracking-comments.ts index 40bf0bc92..6c1e304ef 100644 --- a/packages/dashboard/src/github-tracking-comments.ts +++ b/packages/dashboard/src/github-tracking-comments.ts @@ -1,8 +1,9 @@ -import type { GlobalSettings, ProjectSettings, Task, TaskStore } from "@fusion/core"; +import type { GlobalSettings, MergeDetails, ProjectSettings, Task, TaskStore } from "@fusion/core"; import { GitHubClient } from "./github.js"; import { resolveGithubTrackingAuth } from "./github-auth.js"; const COMMENT_MAX_LENGTH = 500; +const DONE_COMMENT_MAX_LENGTH = 2000; interface TaskMovedEvent { task: Task; @@ -10,19 +11,147 @@ interface TaskMovedEvent { to: string; } +interface TrackingLinkContext { + owner: string; + repo: string; +} + function collapseWhitespace(value: string): string { return value.replace(/\s+/g, " ").trim(); } -export function formatTrackingComment( - task: Pick, - transition: "in-progress" | "done", +function sanitizeInlineText(value: string): string { + return collapseWhitespace(value).replace(/[[\]()]/g, "").trim(); +} + +function truncateText(value: string, maxLength: number): string { + if (maxLength <= 0) { + return ""; + } + + if (value.length <= maxLength) { + return value; + } + + if (maxLength === 1) { + return "…"; + } + + return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`; +} + +function formatTitleSegment(title: string, maxLength: number): string { + return truncateText(title, maxLength); +} + +function formatCommitLine( + mergeDetails: MergeDetails | undefined, + linkContext: TrackingLinkContext | undefined, + includeSubject: boolean, +): string | null { + const commitSha = collapseWhitespace(mergeDetails?.commitSha ?? ""); + if (!commitSha) { + return null; + } + + const shortSha = commitSha.slice(0, 7); + const subject = includeSubject + ? sanitizeInlineText((mergeDetails?.mergeCommitMessage ?? "").split("\n", 1)[0] ?? "") + : ""; + const label = subject ? `${shortSha} ${subject}` : shortSha; + + if (!linkContext) { + return `Commit: ${label}`; + } + + const url = `https://github.com/${linkContext.owner}/${linkContext.repo}/commit/${commitSha}`; + return `Commit: [${label}](${url})`; +} + +function formatFilesLine(mergeDetails: MergeDetails | undefined): string | null { + if (typeof mergeDetails?.filesChanged !== "number") { + return null; + } + + let line = `Files: ${mergeDetails.filesChanged} changed`; + if (typeof mergeDetails.insertions === "number" || typeof mergeDetails.deletions === "number") { + const insertions = typeof mergeDetails.insertions === "number" ? `+${mergeDetails.insertions}` : "+0"; + const deletions = typeof mergeDetails.deletions === "number" ? `-${mergeDetails.deletions}` : "-0"; + line += ` (${insertions} / ${deletions})`; + } + return line; +} + +function buildDoneComment( + task: Pick, + linkContext?: TrackingLinkContext, + options?: { includeCommitSubject?: boolean; includeFilesLine?: boolean }, ): string { + const rawTitle = sanitizeInlineText(task.title ?? "") || "Untitled task"; + const branch = sanitizeInlineText(task.branch ?? ""); + const mergedAt = collapseWhitespace(task.mergeDetails?.mergedAt ?? ""); + const prNumber = task.mergeDetails?.prNumber; + const includeCommitSubject = options?.includeCommitSubject ?? true; + const includeFilesLine = options?.includeFilesLine ?? true; + + const optionalLines: string[] = []; + const commitLine = formatCommitLine(task.mergeDetails, linkContext, includeCommitSubject); + if (commitLine) { + optionalLines.push(commitLine); + } + if (branch) { + optionalLines.push(`Branch: ${branch}`); + } + if (typeof prNumber === "number") { + optionalLines.push(linkContext + ? `PR: [${linkContext.owner}/${linkContext.repo}#${prNumber}](https://github.com/${linkContext.owner}/${linkContext.repo}/pull/${prNumber})` + : `PR: #${prNumber}`); + } + if (includeFilesLine) { + const filesLine = formatFilesLine(task.mergeDetails); + if (filesLine) { + optionalLines.push(filesLine); + } + } + if (mergedAt) { + optionalLines.push(`Merged: ${mergedAt}`); + } + const prefix = `Fusion task: ${task.id}\n\n`; - const stem = transition === "in-progress" - ? "🚧 In progress — work has started on “" - : "✅ Done — “"; - const suffix = transition === "in-progress" ? "”." : "” is complete."; + const stem = "✅ Done — “"; + const suffix = "” is complete."; + const extraLength = optionalLines.length === 0 ? 0 : `\n${optionalLines.join("\n")}`.length; + const available = DONE_COMMENT_MAX_LENGTH - prefix.length - stem.length - suffix.length - extraLength; + const title = formatTitleSegment(rawTitle, available); + const statusLine = `${stem}${title}${suffix}`; + + return optionalLines.length === 0 + ? `${prefix}${statusLine}` + : `${prefix}${statusLine}\n${optionalLines.join("\n")}`; +} + +export function formatTrackingComment( + task: Pick, + transition: "in-progress" | "done", + linkContext?: TrackingLinkContext, +): string { + if (transition === "done") { + let comment = buildDoneComment(task, linkContext, { includeCommitSubject: true, includeFilesLine: true }); + if (comment.length <= DONE_COMMENT_MAX_LENGTH) { + return comment; + } + + comment = buildDoneComment(task, linkContext, { includeCommitSubject: false, includeFilesLine: true }); + if (comment.length <= DONE_COMMENT_MAX_LENGTH) { + return comment; + } + + return buildDoneComment(task, linkContext, { includeCommitSubject: false, includeFilesLine: false }); + } + + const prefix = `Fusion task: ${task.id}\n\n`; + const stem = "🚧 In progress — work has started on “"; + const suffix = "”."; const rawTitle = collapseWhitespace(task.title ?? "") || "Untitled task"; const available = COMMENT_MAX_LENGTH - prefix.length - stem.length - suffix.length; @@ -84,7 +213,9 @@ export class GitHubTrackingCommentService { return; } - const body = formatTrackingComment(event.task, event.to); + const body = event.to === "done" + ? formatTrackingComment(event.task, event.to, { owner, repo }) + : formatTrackingComment(event.task, event.to); try { const projectSettings = await this.store.getSettings() as Pick;