feat(FN-2623): merge fusion/fn-2623

This commit is contained in:
gsxdsm
2026-04-26 18:11:09 -07:00
parent 3fc49a2e6b
commit e2bc644fd5
14 changed files with 442 additions and 4 deletions

View File

@@ -127,6 +127,8 @@ export const DEFAULT_PROJECT_SETTINGS = {
archiveAgentLogMode: "compact",
autoUpdatePrStatus: false,
autoCreatePr: false,
githubCommentOnDone: false,
githubCommentTemplate: undefined,
autoBackupEnabled: false,
autoBackupSchedule: "0 2 * * *",
autoBackupRetention: 7,

View File

@@ -1487,6 +1487,12 @@ export interface ProjectSettings {
/** When true, automatically create GitHub PRs for completed tasks.
* Default: false. */
autoCreatePr?: boolean;
/** When true, automatically post a comment to the originating GitHub issue
* when an imported task is moved to done. Default: false. */
githubCommentOnDone?: boolean;
/** Optional template used for GitHub issue comments posted on task completion.
* Supports `{taskId}` and `{taskTitle}` placeholders. */
githubCommentTemplate?: string;
/** When true, automatic database backups are enabled. Default: false. */
autoBackupEnabled?: boolean;
/** Cron expression for backup schedule. Default: "0 2 * * *" (daily at 2 AM). */

View File

@@ -0,0 +1,188 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import type { TaskStore } from "@fusion/core";
import { DEFAULT_COMMENT_TEMPLATE, GitHubIssueCommentService } from "../github-issue-comment.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 {
private settings: Record<string, unknown>;
logEntry: Mock;
constructor(settings: Record<string, unknown>) {
super();
this.settings = settings;
this.logEntry = vi.fn().mockResolvedValue(undefined);
}
async getSettings(): Promise<Record<string, unknown>> {
return this.settings;
}
setSettings(settings: Record<string, unknown>): void {
this.settings = settings;
}
}
function createTask(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: "FN-2623",
title: "Imported task",
sourceIssue: {
provider: "github",
repository: "owner/repo",
issueNumber: 123,
},
...overrides,
};
}
async function flushAsync(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("GitHubIssueCommentService", () => {
let store: MockStore;
let service: GitHubIssueCommentService;
beforeEach(() => {
vi.clearAllMocks();
store = new MockStore({ githubCommentOnDone: true });
service = new GitHubIssueCommentService(store as unknown as TaskStore, () => "ghp_test");
service.start();
});
it("does nothing when setting is disabled", async () => {
store.setSettings({ githubCommentOnDone: false });
store.emit("task:moved", { task: createTask(), from: "in-progress", to: "done" });
await flushAsync();
expect(mockCommentOnIssue).not.toHaveBeenCalled();
expect(store.logEntry).not.toHaveBeenCalled();
});
it("does nothing when task has no sourceIssue", async () => {
store.emit("task:moved", {
task: createTask({ sourceIssue: undefined }),
from: "in-progress",
to: "done",
});
await flushAsync();
expect(mockCommentOnIssue).not.toHaveBeenCalled();
});
it("does nothing when sourceIssue provider is not github", async () => {
store.emit("task:moved", {
task: createTask({
sourceIssue: {
provider: "gitlab",
repository: "owner/repo",
issueNumber: 123,
},
}),
from: "in-progress",
to: "done",
});
await flushAsync();
expect(mockCommentOnIssue).not.toHaveBeenCalled();
});
it("does nothing when task moves to a non-done column", async () => {
store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" });
await flushAsync();
expect(mockCommentOnIssue).not.toHaveBeenCalled();
});
it("posts comment when setting enabled and task moved to done", async () => {
mockCommentOnIssue.mockResolvedValue(undefined);
store.emit("task:moved", { task: createTask(), from: "in-progress", to: "done" });
await flushAsync();
expect(mockCommentOnIssue).toHaveBeenCalledWith(
"owner",
"repo",
123,
"✅ Task FN-2623 (Imported task) has been completed and resolved.",
);
});
it("uses custom template with placeholder substitution", async () => {
store.setSettings({
githubCommentOnDone: true,
githubCommentTemplate: "Task {taskId}: {taskTitle} complete",
});
store.emit("task:moved", { task: createTask(), from: "in-progress", to: "done" });
await flushAsync();
expect(mockCommentOnIssue).toHaveBeenCalledWith(
"owner",
"repo",
123,
"Task FN-2623: Imported task complete",
);
});
it("uses default template when custom template is not provided", async () => {
store.setSettings({ githubCommentOnDone: true, githubCommentTemplate: undefined });
store.emit("task:moved", { task: createTask(), from: "in-progress", to: "done" });
await flushAsync();
expect(mockCommentOnIssue).toHaveBeenCalledWith(
"owner",
"repo",
123,
DEFAULT_COMMENT_TEMPLATE.replace("{taskId}", "FN-2623").replace("{taskTitle}", "Imported task"),
);
});
it("logs success to task log", async () => {
store.emit("task:moved", { task: createTask(), from: "in-progress", to: "done" });
await flushAsync();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-2623",
"Posted GitHub issue completion comment",
"owner/repo#123",
);
});
it("logs error and does not throw when comment call fails", async () => {
mockCommentOnIssue.mockRejectedValue(new Error("rate limited"));
expect(() => {
store.emit("task:moved", { task: createTask(), from: "in-progress", to: "done" });
}).not.toThrow();
await flushAsync();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-2623",
"Failed to post GitHub issue comment",
"rate limited",
);
});
it("stop unregisters listener", async () => {
service.stop();
store.emit("task:moved", { task: createTask(), from: "in-progress", to: "done" });
await flushAsync();
expect(mockCommentOnIssue).not.toHaveBeenCalled();
});
});

View File

@@ -408,6 +408,71 @@ describe("GitHubClient", () => {
});
});
describe("commentOnIssue", () => {
it("posts comment via gh CLI when auth is available", async () => {
mockRunGh.mockReturnValue("commented");
await client.commentOnIssue("owner", "repo", 123, "Done ✅");
expect(mockRunGh).toHaveBeenCalledWith([
"issue",
"comment",
"123",
"--repo",
"owner/repo",
"--body",
"Done ✅",
]);
});
it("falls back to REST API when gh CLI is unavailable and token exists", async () => {
mockIsGhAvailable.mockReturnValue(false);
const clientWithToken = new GitHubClient("ghp_token");
const fetchSpy = vi.spyOn(clientWithToken, "fetchThrottled").mockResolvedValue({
success: true,
data: { id: 77 },
});
await clientWithToken.commentOnIssue("owner", "repo", 77, "Completed");
expect(fetchSpy).toHaveBeenCalledWith(
"https://api.github.com/repos/owner/repo/issues/77/comments",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ body: "Completed" }),
},
);
});
it("falls back to REST API when gh CLI call fails and token exists", async () => {
mockRunGh.mockImplementation(() => {
throw new Error("gh failed");
});
const clientWithToken = new GitHubClient("ghp_token");
const fetchSpy = vi.spyOn(clientWithToken, "fetchThrottled").mockResolvedValue({
success: true,
data: { id: 78 },
});
await clientWithToken.commentOnIssue("owner", "repo", 78, "Completed");
expect(fetchSpy).toHaveBeenCalled();
});
it("throws when neither gh auth nor token is available", async () => {
mockIsGhAvailable.mockReturnValue(false);
mockIsGhAuthenticated.mockReturnValue(false);
const unauthClient = new GitHubClient();
await expect(unauthClient.commentOnIssue("owner", "repo", 1, "Done")).rejects.toThrow(
"GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.",
);
});
});
describe("getBatchIssueStatus", () => {
it("uses the REST issues list endpoint for recent requested issues", async () => {
mockRunGhJsonAsync.mockResolvedValue([

View File

@@ -0,0 +1,94 @@
import type { TaskStore } from "@fusion/core";
import { GitHubClient } from "./github.js";
interface TaskMovedEvent {
task: {
id: string;
title?: string;
sourceIssue?: {
provider: string;
repository: string;
issueNumber: number;
};
};
to: string;
}
const DEFAULT_COMMENT_TEMPLATE = "✅ Task {taskId} ({taskTitle}) has been completed and resolved.";
export class GitHubIssueCommentService {
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.to !== "done") {
return;
}
const task = event.task;
const settings = await this.store.getSettings();
if (!settings.githubCommentOnDone) {
return;
}
const sourceIssue = task.sourceIssue;
if (!sourceIssue || sourceIssue.provider !== "github") {
return;
}
const [owner, repo] = sourceIssue.repository.split("/");
if (!owner || !repo) {
await this.store.logEntry(
task.id,
"Failed to post GitHub issue comment",
`Invalid GitHub repository format: ${sourceIssue.repository}`,
);
return;
}
const template = settings.githubCommentTemplate || DEFAULT_COMMENT_TEMPLATE;
const commentBody = template
.replaceAll("{taskId}", task.id)
.replaceAll("{taskTitle}", task.title ?? "");
try {
const client = new GitHubClient(this.getGitHubToken());
await client.commentOnIssue(owner, repo, sourceIssue.issueNumber, commentBody);
await this.store.logEntry(
task.id,
"Posted GitHub issue completion comment",
`${sourceIssue.repository}#${sourceIssue.issueNumber}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await this.store.logEntry(
task.id,
"Failed to post GitHub issue comment",
message,
);
}
}
}
export { DEFAULT_COMMENT_TEMPLATE };

View File

@@ -925,6 +925,47 @@ export class GitHubClient {
return response.json() as Promise<PrComment[]>;
}
async commentOnIssue(owner: string, repo: string, issueNumber: number, body: string): Promise<void> {
if (this.hasGhAuth()) {
try {
runGh([
"issue",
"comment",
String(issueNumber),
"--repo",
`${owner}/${repo}`,
"--body",
body,
]);
return;
} catch (err) {
if (!this.token) {
throw new Error(getGhErrorMessage(err));
}
}
}
if (!this.token) {
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
}
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}/comments`;
const result = await this.fetchThrottled<{ id: number }>(
url,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ body }),
},
);
if (!result.success) {
throw new Error(result.error ?? "Failed to comment on GitHub issue");
}
}
/**
* Fetch current issue status using gh CLI if available, otherwise REST API.
* Returns null if the issue is not found or is a pull request.

View File

@@ -14,6 +14,7 @@ export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type D
export { GitHubClient, isPrMergeReady, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type FindPrParams } from "./github.js";
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 {
ApiError,
type ApiErrorResponse,

View File

@@ -829,6 +829,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
resolveAutomationStore,
resolveRoutineStore,
resolveRoutineRunner,
registerDispose,
dispose,
} = createApiRoutesContext(store, options);
const summarizeDiagnostics = createSessionDiagnostics("ai-summarize");
@@ -853,6 +855,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
resolveAutomationStore,
resolveRoutineStore,
resolveRoutineRunner,
registerDispose,
dispose,
rethrowAsApiError,
};
@@ -3726,6 +3730,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// the wildcard /proxy/:nodeId/{*splat} route in Express match order.
registerProxyRoutes(router, { store, runtimeLogger });
(router as Router & { dispose?: () => void }).dispose = dispose;
return router;
}

View File

@@ -172,6 +172,7 @@ export function createApiRoutesContext(store: TaskStore, options?: ServerOptions
const resolveScopedStore = (req: Request): Promise<TaskStore> => getScopedStore(req, store);
const resolveProjectContext = (req: Request): Promise<ProjectContext> => getProjectContext(req, store, options);
const disposeCallbacks: Array<() => void> = [];
function emitAuthSyncAuditLog(input: AuthSyncAuditLogInput): void {
const logger = runtimeLogger.child("settings-sync").child("auth");
@@ -316,6 +317,20 @@ export function createApiRoutesContext(store: TaskStore, options?: ServerOptions
resolveAutomationStore,
resolveRoutineStore,
resolveRoutineRunner,
registerDispose: (callback) => {
disposeCallbacks.push(callback);
},
dispose: () => {
while (disposeCallbacks.length > 0) {
const callback = disposeCallbacks.pop();
if (!callback) continue;
try {
callback();
} catch {
// best-effort cleanup
}
}
},
rethrowAsApiError,
};
}

View File

@@ -12,6 +12,7 @@ import {
unauthorized,
} from "../api-error.js";
import { GitHubClient, parseBadgeUrl } from "../github.js";
import { GitHubIssueCommentService } from "../github-issue-comment.js";
import { githubRateLimiter } from "../github-poll.js";
import {
classifyWebhookEvent,
@@ -1043,8 +1044,17 @@ export async function refreshIssueInBackground(
}
export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const { router, getProjectContext, rethrowAsApiError } = ctx;
const { router, getProjectContext, rethrowAsApiError, store } = ctx;
const githubToken = ctx.options?.githubToken ?? process.env.GITHUB_TOKEN;
if (typeof (store as Partial<{ on: unknown; off: unknown }>).on === "function" &&
typeof (store as Partial<{ off: unknown }>).off === "function") {
const githubIssueCommentService = new GitHubIssueCommentService(
store,
() => ctx.options?.githubToken ?? process.env.GITHUB_TOKEN,
);
githubIssueCommentService.start();
ctx.registerDispose(() => githubIssueCommentService.stop());
}
/**
* GET /api/git/remotes

View File

@@ -56,6 +56,8 @@ export interface ApiRoutesContext {
resolveAutomationStore(req: Request, scope: ScopeValue | undefined): AutomationStore;
resolveRoutineStore(req: Request, scope: ScopeValue | undefined): RoutineStore;
resolveRoutineRunner(req: Request, scope: ScopeValue | undefined): NonNullable<ServerOptions["routineRunner"]>;
registerDispose(callback: () => void): void;
dispose(): void;
rethrowAsApiError(error: unknown, fallbackMessage?: string): never;
}

View File

@@ -1,4 +1,4 @@
import express from "express";
import express, { type Router } from "express";
import { randomUUID } from "node:crypto";
import { join, dirname } from "node:path";
import { existsSync, readFileSync } from "node:fs";
@@ -966,14 +966,15 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
});
// REST API
app.use("/api", createApiRoutes(store, {
const apiRouter = createApiRoutes(store, {
...options,
runtimeLogger,
aiSessionStore,
chatStore,
chatManager,
skillsAdapter: options?.skillsAdapter,
}));
});
app.use("/api", apiRouter);
// API 404 Handler - Return JSON for unmatched API routes (instead of falling through to SPA)
app.use("/api", (_req: express.Request, res: express.Response) => {
@@ -1046,6 +1047,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
server.once("close", () => {
clearAiSessionCleanupInterval();
aiSessionStore.stopScheduledCleanup();
(apiRouter as Router & { dispose?: () => void }).dispose?.();
void stopAllDevServers().catch((error) => {
runtimeLogger.warn("Failed to shutdown dev-server managers", {
message: "Failed to shutdown dev-server managers",