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 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
|
### Worktree model
|
||||||
- Each active task runs in isolated worktree under `.worktrees/*`
|
- Each active task runs in isolated worktree under `.worktrees/*`
|
||||||
- Executor creates branches like `fusion/{task-id}` (`executor.ts`)
|
- 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 { rateLimit, RATE_LIMITS, type RateLimitOptions } from "./rate-limit.js";
|
||||||
export { GitHubPollingService, type GitHubPollingServiceOptions, type TaskWatchInput, type WatchedBadgeType } from "./github-poll.js";
|
export { GitHubPollingService, type GitHubPollingServiceOptions, type TaskWatchInput, type WatchedBadgeType } from "./github-poll.js";
|
||||||
export { GitHubIssueCommentService, DEFAULT_COMMENT_TEMPLATE } from "./github-issue-comment.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 { getCliPackageVersion, resolveCliPackageVersionInfo, type CliPackageVersionInfo } from "./cli-package-version.js";
|
||||||
export {
|
export {
|
||||||
ApiError,
|
ApiError,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "../api-error.js";
|
} from "../api-error.js";
|
||||||
import { GitHubClient, parseBadgeUrl } from "../github.js";
|
import { GitHubClient, parseBadgeUrl } from "../github.js";
|
||||||
import { GitHubIssueCommentService } from "../github-issue-comment.js";
|
import { GitHubIssueCommentService } from "../github-issue-comment.js";
|
||||||
|
import { GitHubTrackingCommentService } from "../github-tracking-comments.js";
|
||||||
import { githubRateLimiter } from "../github-poll.js";
|
import { githubRateLimiter } from "../github-poll.js";
|
||||||
import {
|
import {
|
||||||
classifyWebhookEvent,
|
classifyWebhookEvent,
|
||||||
@@ -1104,6 +1105,13 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
);
|
);
|
||||||
githubIssueCommentService.start();
|
githubIssueCommentService.start();
|
||||||
ctx.registerDispose(() => githubIssueCommentService.stop());
|
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