feat(FN-2623): merge fusion/fn-2623
This commit is contained in:
5
.changeset/github-issue-auto-comment.md
Normal file
5
.changeset/github-issue-auto-comment.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add project settings to auto-comment on imported GitHub issues when tasks move to done, plus dashboard GitHub integration support for posting issue comments.
|
||||||
@@ -139,6 +139,8 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
|
|||||||
| `autoArchiveDoneAfterMs` | `number` | `172800000` | Age in ms after entering done before auto-archive (48h). |
|
| `autoArchiveDoneAfterMs` | `number` | `172800000` | Age in ms after entering done before auto-archive (48h). |
|
||||||
| `archiveAgentLogMode` | `"none" \| "compact" \| "full"` | `"compact"` | Agent log retention strategy for cold archive snapshots. |
|
| `archiveAgentLogMode` | `"none" \| "compact" \| "full"` | `"compact"` | Agent log retention strategy for cold archive snapshots. |
|
||||||
| `autoUpdatePrStatus` | `boolean` | `false` | Auto-refresh PR status badges. |
|
| `autoUpdatePrStatus` | `boolean` | `false` | Auto-refresh PR status badges. |
|
||||||
|
| `githubCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitHub issues post a completion comment to the source issue when the task moves to `done`. |
|
||||||
|
| `githubCommentTemplate` | `string` | `undefined` | Optional issue comment template used by `githubCommentOnDone`. Supports `{taskId}` and `{taskTitle}` placeholders. If unset, Fusion uses a default completion message. |
|
||||||
| `autoCreatePr` | `boolean` | `false` | Auto-create PRs for completed tasks. |
|
| `autoCreatePr` | `boolean` | `false` | Auto-create PRs for completed tasks. |
|
||||||
| `autoBackupEnabled` | `boolean` | `false` | Enable scheduled DB backups. |
|
| `autoBackupEnabled` | `boolean` | `false` | Enable scheduled DB backups. |
|
||||||
| `autoBackupSchedule` | `string` | `"0 2 * * *"` | Backup cron schedule. |
|
| `autoBackupSchedule` | `string` | `"0 2 * * *"` | Backup cron schedule. |
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
|||||||
archiveAgentLogMode: "compact",
|
archiveAgentLogMode: "compact",
|
||||||
autoUpdatePrStatus: false,
|
autoUpdatePrStatus: false,
|
||||||
autoCreatePr: false,
|
autoCreatePr: false,
|
||||||
|
githubCommentOnDone: false,
|
||||||
|
githubCommentTemplate: undefined,
|
||||||
autoBackupEnabled: false,
|
autoBackupEnabled: false,
|
||||||
autoBackupSchedule: "0 2 * * *",
|
autoBackupSchedule: "0 2 * * *",
|
||||||
autoBackupRetention: 7,
|
autoBackupRetention: 7,
|
||||||
|
|||||||
@@ -1487,6 +1487,12 @@ export interface ProjectSettings {
|
|||||||
/** When true, automatically create GitHub PRs for completed tasks.
|
/** When true, automatically create GitHub PRs for completed tasks.
|
||||||
* Default: false. */
|
* Default: false. */
|
||||||
autoCreatePr?: boolean;
|
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. */
|
/** When true, automatic database backups are enabled. Default: false. */
|
||||||
autoBackupEnabled?: boolean;
|
autoBackupEnabled?: boolean;
|
||||||
/** Cron expression for backup schedule. Default: "0 2 * * *" (daily at 2 AM). */
|
/** Cron expression for backup schedule. Default: "0 2 * * *" (daily at 2 AM). */
|
||||||
|
|||||||
188
packages/dashboard/src/__tests__/github-issue-comment.test.ts
Normal file
188
packages/dashboard/src/__tests__/github-issue-comment.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", () => {
|
describe("getBatchIssueStatus", () => {
|
||||||
it("uses the REST issues list endpoint for recent requested issues", async () => {
|
it("uses the REST issues list endpoint for recent requested issues", async () => {
|
||||||
mockRunGhJsonAsync.mockResolvedValue([
|
mockRunGhJsonAsync.mockResolvedValue([
|
||||||
|
|||||||
94
packages/dashboard/src/github-issue-comment.ts
Normal file
94
packages/dashboard/src/github-issue-comment.ts
Normal 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 };
|
||||||
@@ -925,6 +925,47 @@ export class GitHubClient {
|
|||||||
return response.json() as Promise<PrComment[]>;
|
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.
|
* 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.
|
* Returns null if the issue is not found or is a pull request.
|
||||||
|
|||||||
@@ -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 { 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 { 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 {
|
export {
|
||||||
ApiError,
|
ApiError,
|
||||||
type ApiErrorResponse,
|
type ApiErrorResponse,
|
||||||
|
|||||||
@@ -829,6 +829,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
resolveAutomationStore,
|
resolveAutomationStore,
|
||||||
resolveRoutineStore,
|
resolveRoutineStore,
|
||||||
resolveRoutineRunner,
|
resolveRoutineRunner,
|
||||||
|
registerDispose,
|
||||||
|
dispose,
|
||||||
} = createApiRoutesContext(store, options);
|
} = createApiRoutesContext(store, options);
|
||||||
const summarizeDiagnostics = createSessionDiagnostics("ai-summarize");
|
const summarizeDiagnostics = createSessionDiagnostics("ai-summarize");
|
||||||
|
|
||||||
@@ -853,6 +855,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
resolveAutomationStore,
|
resolveAutomationStore,
|
||||||
resolveRoutineStore,
|
resolveRoutineStore,
|
||||||
resolveRoutineRunner,
|
resolveRoutineRunner,
|
||||||
|
registerDispose,
|
||||||
|
dispose,
|
||||||
rethrowAsApiError,
|
rethrowAsApiError,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3726,6 +3730,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
// the wildcard /proxy/:nodeId/{*splat} route in Express match order.
|
// the wildcard /proxy/:nodeId/{*splat} route in Express match order.
|
||||||
registerProxyRoutes(router, { store, runtimeLogger });
|
registerProxyRoutes(router, { store, runtimeLogger });
|
||||||
|
|
||||||
|
(router as Router & { dispose?: () => void }).dispose = dispose;
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ export function createApiRoutesContext(store: TaskStore, options?: ServerOptions
|
|||||||
|
|
||||||
const resolveScopedStore = (req: Request): Promise<TaskStore> => getScopedStore(req, store);
|
const resolveScopedStore = (req: Request): Promise<TaskStore> => getScopedStore(req, store);
|
||||||
const resolveProjectContext = (req: Request): Promise<ProjectContext> => getProjectContext(req, store, options);
|
const resolveProjectContext = (req: Request): Promise<ProjectContext> => getProjectContext(req, store, options);
|
||||||
|
const disposeCallbacks: Array<() => void> = [];
|
||||||
|
|
||||||
function emitAuthSyncAuditLog(input: AuthSyncAuditLogInput): void {
|
function emitAuthSyncAuditLog(input: AuthSyncAuditLogInput): void {
|
||||||
const logger = runtimeLogger.child("settings-sync").child("auth");
|
const logger = runtimeLogger.child("settings-sync").child("auth");
|
||||||
@@ -316,6 +317,20 @@ export function createApiRoutesContext(store: TaskStore, options?: ServerOptions
|
|||||||
resolveAutomationStore,
|
resolveAutomationStore,
|
||||||
resolveRoutineStore,
|
resolveRoutineStore,
|
||||||
resolveRoutineRunner,
|
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,
|
rethrowAsApiError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
unauthorized,
|
unauthorized,
|
||||||
} 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 { githubRateLimiter } from "../github-poll.js";
|
import { githubRateLimiter } from "../github-poll.js";
|
||||||
import {
|
import {
|
||||||
classifyWebhookEvent,
|
classifyWebhookEvent,
|
||||||
@@ -1043,8 +1044,17 @@ export async function refreshIssueInBackground(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
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;
|
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
|
* GET /api/git/remotes
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export interface ApiRoutesContext {
|
|||||||
resolveAutomationStore(req: Request, scope: ScopeValue | undefined): AutomationStore;
|
resolveAutomationStore(req: Request, scope: ScopeValue | undefined): AutomationStore;
|
||||||
resolveRoutineStore(req: Request, scope: ScopeValue | undefined): RoutineStore;
|
resolveRoutineStore(req: Request, scope: ScopeValue | undefined): RoutineStore;
|
||||||
resolveRoutineRunner(req: Request, scope: ScopeValue | undefined): NonNullable<ServerOptions["routineRunner"]>;
|
resolveRoutineRunner(req: Request, scope: ScopeValue | undefined): NonNullable<ServerOptions["routineRunner"]>;
|
||||||
|
registerDispose(callback: () => void): void;
|
||||||
|
dispose(): void;
|
||||||
rethrowAsApiError(error: unknown, fallbackMessage?: string): never;
|
rethrowAsApiError(error: unknown, fallbackMessage?: string): never;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import express from "express";
|
import express, { type Router } from "express";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
@@ -966,14 +966,15 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
});
|
});
|
||||||
|
|
||||||
// REST API
|
// REST API
|
||||||
app.use("/api", createApiRoutes(store, {
|
const apiRouter = createApiRoutes(store, {
|
||||||
...options,
|
...options,
|
||||||
runtimeLogger,
|
runtimeLogger,
|
||||||
aiSessionStore,
|
aiSessionStore,
|
||||||
chatStore,
|
chatStore,
|
||||||
chatManager,
|
chatManager,
|
||||||
skillsAdapter: options?.skillsAdapter,
|
skillsAdapter: options?.skillsAdapter,
|
||||||
}));
|
});
|
||||||
|
app.use("/api", apiRouter);
|
||||||
|
|
||||||
// API 404 Handler - Return JSON for unmatched API routes (instead of falling through to SPA)
|
// API 404 Handler - Return JSON for unmatched API routes (instead of falling through to SPA)
|
||||||
app.use("/api", (_req: express.Request, res: express.Response) => {
|
app.use("/api", (_req: express.Request, res: express.Response) => {
|
||||||
@@ -1046,6 +1047,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
server.once("close", () => {
|
server.once("close", () => {
|
||||||
clearAiSessionCleanupInterval();
|
clearAiSessionCleanupInterval();
|
||||||
aiSessionStore.stopScheduledCleanup();
|
aiSessionStore.stopScheduledCleanup();
|
||||||
|
(apiRouter as Router & { dispose?: () => void }).dispose?.();
|
||||||
void stopAllDevServers().catch((error) => {
|
void stopAllDevServers().catch((error) => {
|
||||||
runtimeLogger.warn("Failed to shutdown dev-server managers", {
|
runtimeLogger.warn("Failed to shutdown dev-server managers", {
|
||||||
message: "Failed to shutdown dev-server managers",
|
message: "Failed to shutdown dev-server managers",
|
||||||
|
|||||||
Reference in New Issue
Block a user