feat(FN-4142): enrich GitHub tracking comments with done status

Implements GitHub tracking comments for done tasks (FN-4142), extending the existing tracking comment system in the dashboard to emit comments when tasks complete, with corresponding tests and documentation.

Fusion-Task-Id: FN-4142
This commit is contained in:
Fusion
2026-05-12 09:28:59 -07:00
committed by gsxdsm
parent 9a3da76aaf
commit 84ccc470b6
5 changed files with 274 additions and 15 deletions

View File

@@ -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.

View File

@@ -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 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. 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.

View File

@@ -445,6 +445,8 @@ When Fusion creates a tracking issue, it uses:
- Body prefix: `Fusion task: FN-XXXX` - Body prefix: `Fusion task: FN-XXXX`
- Body content: bounded plain-text task summary snippet (not full prompt content) - 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`. GitHub authentication/settings are configured in [Settings Reference](./settings-reference.md) via `githubAuthMode` (`gh-cli` or `token`) and `githubAuthToken`.
## Completion Modes (`mergeStrategy`) ## Completion Modes (`mergeStrategy`)

View File

@@ -80,19 +80,128 @@ describe("formatTrackingComment", () => {
expect(comment).toContain("Line 1 Line 2"); expect(comment).toContain("Line 1 Line 2");
}); });
it("truncates long titles and caps total length", () => { it("keeps in-progress comments capped at 500 characters", () => {
const comment = formatTrackingComment({ id: "FN-1", title: "A".repeat(1000) }, "done"); const comment = formatTrackingComment({ id: "FN-1", title: "A".repeat(1000) }, "in-progress");
expect(comment.length).toBeLessThanOrEqual(500); expect(comment.length).toBeLessThanOrEqual(500);
expect(comment).toContain("…"); expect(comment).toContain("…");
}); });
it("never includes urls or markdown links", () => { it("keeps urls and markdown links out of in-progress comments", () => {
const comment = formatTrackingComment({ id: "FN-1", title: "hello" }, "done"); const comment = formatTrackingComment({ id: "FN-1", title: "hello" }, "in-progress");
expect(comment).not.toContain("localhost"); expect(comment).not.toContain("localhost");
expect(comment).not.toContain("http://"); expect(comment).not.toContain("http://");
expect(comment).not.toContain("https://"); expect(comment).not.toContain("https://");
expect(comment).not.toContain("]("); 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", () => { describe("GitHubTrackingCommentService", () => {
@@ -137,7 +246,17 @@ describe("GitHubTrackingCommentService", () => {
service.start(); service.start();
store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" }); 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(); await flushAsync();
expect(mockCommentOnIssue).toHaveBeenCalledTimes(2); expect(mockCommentOnIssue).toHaveBeenCalledTimes(2);
@@ -155,6 +274,8 @@ describe("GitHubTrackingCommentService", () => {
42, 42,
expect.stringContaining("✅ Done"), 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 () => { it("writes success logs", async () => {

View File

@@ -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 { GitHubClient } from "./github.js";
import { resolveGithubTrackingAuth } from "./github-auth.js"; import { resolveGithubTrackingAuth } from "./github-auth.js";
const COMMENT_MAX_LENGTH = 500; const COMMENT_MAX_LENGTH = 500;
const DONE_COMMENT_MAX_LENGTH = 2000;
interface TaskMovedEvent { interface TaskMovedEvent {
task: Task; task: Task;
@@ -10,19 +11,147 @@ interface TaskMovedEvent {
to: string; to: string;
} }
interface TrackingLinkContext {
owner: string;
repo: string;
}
function collapseWhitespace(value: string): string { function collapseWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim(); return value.replace(/\s+/g, " ").trim();
} }
export function formatTrackingComment( function sanitizeInlineText(value: string): string {
task: Pick<Task, "id" | "title">, return collapseWhitespace(value).replace(/[[\]()]/g, "").trim();
transition: "in-progress" | "done", }
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<Task, "id" | "title" | "branch" | "mergeDetails">,
linkContext?: TrackingLinkContext,
options?: { includeCommitSubject?: boolean; includeFilesLine?: boolean },
): string { ): 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 prefix = `Fusion task: ${task.id}\n\n`;
const stem = transition === "in-progress" const stem = "✅ Done — “";
? "🚧 In progress — work has started on “" const suffix = "” is complete.";
: "✅ Done — “"; const extraLength = optionalLines.length === 0 ? 0 : `\n${optionalLines.join("\n")}`.length;
const suffix = transition === "in-progress" ? "”." : "” is complete."; 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<Task, "id" | "title" | "branch" | "mergeDetails">,
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 rawTitle = collapseWhitespace(task.title ?? "") || "Untitled task";
const available = COMMENT_MAX_LENGTH - prefix.length - stem.length - suffix.length; const available = COMMENT_MAX_LENGTH - prefix.length - stem.length - suffix.length;
@@ -84,7 +213,9 @@ export class GitHubTrackingCommentService {
return; 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 { try {
const projectSettings = await this.store.getSettings() as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">; const projectSettings = await this.store.getSettings() as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;