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:
5
.changeset/FN-3872-github-tracking-comments.md
Normal file
5
.changeset/FN-3872-github-tracking-comments.md
Normal 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.
|
||||
@@ -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`)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
107
packages/dashboard/src/github-tracking-comments.ts
Normal file
107
packages/dashboard/src/github-tracking-comments.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user