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:
5
.changeset/FN-4142-github-tracking-done-comment.md
Normal file
5
.changeset/FN-4142-github-tracking-done-comment.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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">;
|
||||||
|
|||||||
Reference in New Issue
Block a user