feat(FN-3872): add GitHub tracking comments service

Added a GitHub tracking comments service that posts automated comments to GitHub PRs, with a full test suite and documentation. The service is wired into the GitHub registration flow and ships as a changeset patch.

Fusion-Task-Id: FN-3872
This commit is contained in:
Fusion
2026-05-10 02:00:28 -07:00
committed by gsxdsm
parent 9f3dc77840
commit ebab75ec53
6 changed files with 391 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fusion now posts a short comment on the linked GitHub tracking issue when a tracked task moves to in-progress or done. Comments include the Fusion task ID as plain text and never link back to the Fusion app.

View File

@@ -1242,6 +1242,8 @@ When a task is created, Fusion only attempts GitHub issue creation if per-task t
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.
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.
### Worktree model
- Each active task runs in isolated worktree under `.worktrees/*`
- Executor creates branches like `fusion/{task-id}` (`executor.ts`)

View File

@@ -0,0 +1,268 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import type { TaskStore } from "@fusion/core";
import {
formatTrackingComment,
GitHubTrackingCommentService,
} from "../github-tracking-comments.js";
const { mockCommentOnIssue } = vi.hoisted(() => ({
mockCommentOnIssue: vi.fn(),
}));
vi.mock("../github.js", () => ({
GitHubClient: vi.fn().mockImplementation(() => ({
commentOnIssue: (...args: unknown[]) => mockCommentOnIssue(...args),
})),
}));
class MockStore extends EventEmitter {
logEntry: Mock;
constructor() {
super();
this.logEntry = vi.fn().mockResolvedValue(undefined);
}
}
function createTask(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: "FN-1",
title: "Tracked task",
githubTracking: {
enabled: true,
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-01-01T00:00:00.000Z",
},
},
...overrides,
};
}
async function flushAsync(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("formatTrackingComment", () => {
it("formats in-progress comments", () => {
const comment = formatTrackingComment({ id: "FN-1", title: "Build thing" }, "in-progress");
expect(comment.startsWith("Fusion task: FN-1\n\n🚧 In progress")).toBe(true);
});
it("formats done comments", () => {
const comment = formatTrackingComment({ id: "FN-1", title: "Build thing" }, "done");
expect(comment.startsWith("Fusion task: FN-1\n\n✅ Done")).toBe(true);
});
it("falls back to untitled task", () => {
const comment = formatTrackingComment({ id: "FN-1", title: " " }, "done");
expect(comment).toContain("Untitled task");
});
it("collapses multiline title whitespace", () => {
const comment = formatTrackingComment({ id: "FN-1", title: "Line 1\n\n Line 2" }, "done");
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");
expect(comment.length).toBeLessThanOrEqual(500);
expect(comment).toContain("…");
});
it("never includes urls or markdown links", () => {
const comment = formatTrackingComment({ id: "FN-1", title: "hello" }, "done");
expect(comment).not.toContain("localhost");
expect(comment).not.toContain("http://");
expect(comment).not.toContain("https://");
expect(comment).not.toContain("](");
});
});
describe("GitHubTrackingCommentService", () => {
let store: MockStore;
let service: GitHubTrackingCommentService;
let tokenValue: string;
let tokenCalls: number;
beforeEach(() => {
vi.clearAllMocks();
store = new MockStore();
tokenValue = "ghp_test";
tokenCalls = 0;
service = new GitHubTrackingCommentService(store as unknown as TaskStore, () => {
tokenCalls += 1;
return tokenValue;
});
});
it("start/stop are idempotent", async () => {
service.start();
service.start();
store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" });
await flushAsync();
expect(mockCommentOnIssue).toHaveBeenCalledTimes(1);
service.stop();
service.stop();
store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" });
await flushAsync();
expect(mockCommentOnIssue).toHaveBeenCalledTimes(1);
});
it("ignores non-target columns", async () => {
service.start();
for (const to of ["triage", "todo", "in-review", "archived"]) {
store.emit("task:moved", { task: createTask(), from: "todo", to });
}
await flushAsync();
expect(mockCommentOnIssue).not.toHaveBeenCalled();
expect(store.logEntry).not.toHaveBeenCalled();
});
it("posts in-progress and done comments in order", async () => {
service.start();
store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" });
store.emit("task:moved", { task: createTask(), from: "in-progress", to: "done" });
await flushAsync();
expect(mockCommentOnIssue).toHaveBeenCalledTimes(2);
expect(mockCommentOnIssue).toHaveBeenNthCalledWith(
1,
"owner",
"repo",
42,
expect.stringContaining("🚧 In progress"),
);
expect(mockCommentOnIssue).toHaveBeenNthCalledWith(
2,
"owner",
"repo",
42,
expect.stringContaining("✅ Done"),
);
});
it("writes success logs", async () => {
service.start();
store.emit("task:moved", { task: createTask(), from: "todo", to: "done" });
await flushAsync();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-1",
"Posted GitHub tracking comment",
"owner/repo#42 (done)",
);
});
it("ignores disabled tracking", async () => {
service.start();
store.emit("task:moved", {
task: createTask({ githubTracking: { enabled: false } }),
from: "todo",
to: "done",
});
await flushAsync();
expect(mockCommentOnIssue).not.toHaveBeenCalled();
});
it("ignores when linked issue is missing", async () => {
service.start();
store.emit("task:moved", {
task: createTask({ githubTracking: { enabled: true } }),
from: "todo",
to: "done",
});
await flushAsync();
expect(mockCommentOnIssue).not.toHaveBeenCalled();
});
it("logs incomplete metadata", async () => {
service.start();
store.emit("task:moved", {
task: createTask({
githubTracking: {
enabled: true,
issue: {
owner: "",
repo: "repo",
number: 42,
url: "u",
createdAt: "2026-01-01T00:00:00.000Z",
},
},
}),
from: "todo",
to: "done",
});
await flushAsync();
expect(mockCommentOnIssue).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-1",
"Failed to post GitHub tracking comment",
"Linked issue metadata is incomplete",
);
});
it("swallows github errors and keeps listener alive", async () => {
service.start();
mockCommentOnIssue.mockRejectedValueOnce(new Error("rate limited"));
expect(() => {
store.emit("task:moved", { task: createTask(), from: "todo", to: "done" });
}).not.toThrow();
await flushAsync();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-1",
"Failed to post GitHub tracking comment",
"rate limited",
);
mockCommentOnIssue.mockResolvedValueOnce(undefined);
store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" });
await flushAsync();
expect(mockCommentOnIssue).toHaveBeenCalledTimes(2);
});
it("ignores same-column events", async () => {
service.start();
store.emit("task:moved", { task: createTask(), from: "done", to: "done" });
await flushAsync();
expect(mockCommentOnIssue).not.toHaveBeenCalled();
});
it("invokes token thunk for each call", async () => {
service.start();
tokenValue = "ghp_1";
store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" });
tokenValue = "ghp_2";
store.emit("task:moved", { task: createTask(), from: "in-progress", to: "done" });
await flushAsync();
expect(mockCommentOnIssue).toHaveBeenCalledTimes(2);
expect(tokenCalls).toBe(2);
});
});

View File

@@ -0,0 +1,107 @@
import type { Task, TaskStore } from "@fusion/core";
import { GitHubClient } from "./github.js";
const COMMENT_MAX_LENGTH = 500;
interface TaskMovedEvent {
task: Task;
from: string;
to: string;
}
function collapseWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
export function formatTrackingComment(
task: Pick<Task, "id" | "title">,
transition: "in-progress" | "done",
): string {
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 rawTitle = collapseWhitespace(task.title ?? "") || "Untitled task";
const available = COMMENT_MAX_LENGTH - prefix.length - stem.length - suffix.length;
const title = rawTitle.length <= available
? rawTitle
: `${rawTitle.slice(0, Math.max(0, available - 1)).trimEnd()}`;
return `${prefix}${stem}${title}${suffix}`;
}
export class GitHubTrackingCommentService {
private readonly store: TaskStore;
private readonly getGitHubToken: () => string | undefined;
private readonly onTaskMoved = (event: TaskMovedEvent): void => {
void this.handleTaskMoved(event);
};
private started = false;
constructor(store: TaskStore, getGitHubToken?: () => string | undefined) {
this.store = store;
this.getGitHubToken = getGitHubToken ?? (() => process.env.GITHUB_TOKEN);
}
start(): void {
if (this.started) return;
this.started = true;
this.store.on("task:moved", this.onTaskMoved);
}
stop(): void {
if (!this.started) return;
this.started = false;
this.store.off("task:moved", this.onTaskMoved);
}
private async handleTaskMoved(event: TaskMovedEvent): Promise<void> {
if (event.from === event.to) {
return;
}
if (event.to !== "in-progress" && event.to !== "done") {
return;
}
if (event.task.githubTracking?.enabled !== true) {
return;
}
const issue = event.task.githubTracking?.issue;
if (!issue) {
return;
}
const { owner, repo, number } = issue;
if (!owner || !repo || !number) {
await this.store.logEntry(
event.task.id,
"Failed to post GitHub tracking comment",
"Linked issue metadata is incomplete",
);
return;
}
const body = formatTrackingComment(event.task, event.to);
try {
const client = new GitHubClient(this.getGitHubToken());
await client.commentOnIssue(owner, repo, number, body);
await this.store.logEntry(
event.task.id,
"Posted GitHub tracking comment",
`${owner}/${repo}#${number} (${event.to})`,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.store.logEntry(
event.task.id,
"Failed to post GitHub tracking comment",
message,
);
}
}
}

View File

@@ -16,6 +16,7 @@ export { maybeCreateTrackingIssue, type MaybeCreateTrackingIssueDeps } from "./g
export { rateLimit, RATE_LIMITS, type RateLimitOptions } from "./rate-limit.js";
export { GitHubPollingService, type GitHubPollingServiceOptions, type TaskWatchInput, type WatchedBadgeType } from "./github-poll.js";
export { GitHubIssueCommentService, DEFAULT_COMMENT_TEMPLATE } from "./github-issue-comment.js";
export { GitHubTrackingCommentService, formatTrackingComment } from "./github-tracking-comments.js";
export { getCliPackageVersion, resolveCliPackageVersionInfo, type CliPackageVersionInfo } from "./cli-package-version.js";
export {
ApiError,

View File

@@ -21,6 +21,7 @@ import {
} from "../api-error.js";
import { GitHubClient, parseBadgeUrl } from "../github.js";
import { GitHubIssueCommentService } from "../github-issue-comment.js";
import { GitHubTrackingCommentService } from "../github-tracking-comments.js";
import { githubRateLimiter } from "../github-poll.js";
import {
classifyWebhookEvent,
@@ -1104,6 +1105,13 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
);
githubIssueCommentService.start();
ctx.registerDispose(() => githubIssueCommentService.stop());
const githubTrackingCommentService = new GitHubTrackingCommentService(
store,
() => ctx.options?.githubToken ?? process.env.GITHUB_TOKEN,
);
githubTrackingCommentService.start();
ctx.registerDispose(() => githubTrackingCommentService.stop());
}
/**