Recovery: Re-land (Close source-imported GitHub issues on ta

This commit is contained in:
gsxdsm
2026-05-28 19:49:31 -07:00
parent c722451a1c
commit b2dce7d852
14 changed files with 515 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
FN-5631 re-lands FN-5616 to add an opt-in `githubCloseSourceIssueOnDone` setting that closes source-imported GitHub issues when linked tasks are completed, including startup reconciliation for previously missed closes.

View File

@@ -1538,7 +1538,7 @@ When a tracked task later moves to `in-progress` or `done`, Fusion posts one sho
When a tracked task transitions into `done`, Fusion closes the linked GitHub issue with `state_reason: completed`. When a task transitions out of `done` into any active column (`triage`, `todo`, `in-progress`, `in-review`), Fusion reopens it with `state_reason: reopened`. When a tracked task is permanently deleted, Fusion closes the linked GitHub issue with `state_reason: not_planned` (or deletes it when explicitly requested). Delete-path outcomes emit a `github-issue:action` store event payload (`{ taskId, action, owner, repo, number, outcome, error? }`) so success/failure remains observable even after the task row is gone and task activity logs are unwritable. Moves from `done` to `archived` leave the issue closed. Tasks without `githubTracking.enabled` or without a linked issue are unaffected, and GitHub failures are logged to task activity without blocking the move.
The GitHub tracking state listener now attaches to every registered project store (including projects registered after startup), and each store gets a one-time asynchronous startup reconciliation sweep. That sweep scans bounded done tasks with tracking enabled and closes any linked GitHub issue still open, so missed/momentary failures are caught up without blocking server boot.
The GitHub tracking state listener now attaches to every registered project store (including projects registered after startup), and each store gets a one-time asynchronous startup reconciliation sweep. That sweep scans bounded done tasks with tracking enabled and closes any linked GitHub issue still open, so missed/momentary failures are caught up without blocking server boot. Source-imported GitHub issues can also be auto-closed when `githubCloseSourceIssueOnDone === true`: `GitHubSourceIssueCloseService` listens for `task:moved` transitions into `done` and closes open `task.sourceIssue` links, while `GitHubTrackingReconciler.reconcileSourceIssues` performs a parallel startup sweep over done tasks with GitHub source metadata to close any source issues still open.
### Worktree model
- Each active task runs in isolated worktree under `.worktrees/*`

View File

@@ -371,6 +371,7 @@ Default notes:
| `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. |
| `githubCloseSourceIssueOnDone` | `boolean` | `false` | When enabled, source-imported GitHub issues are automatically closed with `state_reason: completed` when the Fusion task moves to `done`. A startup reconciliation sweep also closes missed open source issues on boot. |
| `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on new tasks. When this is false, the Quick Entry GitHub toggle is disabled until tracking is enabled in Settings. |
| `githubTrackingDefaultRepo` | `string` | `undefined` | Project default issue-tracking repo (`owner/repo`) used before global fallback for tracked task creation (precedence: task override → project default → global default). In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: project saves go through `PUT /api/settings` (Settings → General → GitHub Tracking) while global saves go through `PUT /api/settings/global` (Settings → Global General). |
| `githubTrackingDedupEnabled` | `boolean` | `true` | When enabled, tracking issue creation searches open and closed repo issues for likely duplicates before opening a new issue (gh CLI search first, with REST search fallback). Set `false` to skip dedup and always create a new issue when tracking is enabled. Dashboard location: **Settings → Project → General → GitHub Tracking**. |

View File

@@ -656,6 +656,7 @@ Recovery/backfill guidance:
Import issues:
- GitHub-imported tasks retain typed source issue metadata (`sourceIssue.provider/repository/externalIssueId/issueNumber/url`), which executor and merger flows use to include `Ref: owner/repo#N` in commit bodies.
- When `githubCloseSourceIssueOnDone` is enabled (default: `false`), Fusion also closes linked source-imported GitHub issues with `state_reason: completed` when the task moves into `done`. On startup, a bounded reconciliation sweep checks done tasks and closes any still-open source issue links that were missed due to transient failures.
```bash
fn task import owner/repo --labels bug --limit 20

View File

@@ -69,6 +69,11 @@ describe("settings defaults invariants", () => {
});
});
it("keeps github source issue auto-close disabled by default", () => {
expect(DEFAULT_PROJECT_SETTINGS.githubCloseSourceIssueOnDone).toBe(false);
expect("githubCloseSourceIssueOnDone" in DEFAULT_GLOBAL_SETTINGS).toBe(false);
});
describe("recycleWorktrees default", () => {
it("keeps recycleWorktrees explicitly false in project defaults", () => {
expect(DEFAULT_PROJECT_SETTINGS.recycleWorktrees).toBe(false);

View File

@@ -342,6 +342,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
autoUpdatePrStatus: false,
githubCommentOnDone: false,
githubCommentTemplate: undefined,
githubCloseSourceIssueOnDone: false,
githubTrackingEnabledByDefault: false,
githubTrackingDefaultRepo: undefined,
githubTrackingDedupEnabled: true,

View File

@@ -3333,6 +3333,9 @@ export interface ProjectSettings {
/** Optional template used for GitHub issue comments posted on task completion.
* Supports `{taskId}` and `{taskTitle}` placeholders. */
githubCommentTemplate?: string;
/** When true, automatically close linked source-imported GitHub issues
* when a task moves to done. Default: false. */
githubCloseSourceIssueOnDone?: boolean;
/** When true, new tasks default GitHub tracking to enabled for this project (FN-3868).
* Default: false. */
githubTrackingEnabledByDefault?: boolean;

View File

@@ -0,0 +1,180 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import type { TaskStore } from "@fusion/core";
import { GitHubSourceIssueCloseService } from "../github-source-issue-close.js";
const { mockSetIssueState, mockGetIssue } = vi.hoisted(() => ({
mockSetIssueState: vi.fn(),
mockGetIssue: vi.fn(),
}));
const { mockResolveGithubTrackingAuth } = vi.hoisted(() => ({
mockResolveGithubTrackingAuth: vi.fn(),
}));
vi.mock("../github.js", () => ({
GitHubClient: vi.fn().mockImplementation(() => ({
setIssueState: (...args: unknown[]) => mockSetIssueState(...args),
getIssue: (...args: unknown[]) => mockGetIssue(...args),
})),
}));
vi.mock("../github-auth.js", () => ({
resolveGithubTrackingAuth: (...args: unknown[]) => mockResolveGithubTrackingAuth(...args),
}));
class MockStore extends EventEmitter {
logEntry: Mock;
getSettings: Mock;
getGlobalSettingsStore: Mock;
constructor() {
super();
this.logEntry = vi.fn().mockResolvedValue(undefined);
this.getSettings = vi.fn().mockResolvedValue({ githubCloseSourceIssueOnDone: true, githubAuthMode: "token", githubAuthToken: "ghp_test" });
this.getGlobalSettingsStore = vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) }));
}
}
function createEvent(overrides: Record<string, unknown> = {}) {
return {
task: {
id: "FN-1",
sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 42 },
...overrides,
},
from: "todo",
to: "done",
};
}
async function flushAsync(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("GitHubSourceIssueCloseService", () => {
let store: MockStore;
let service: GitHubSourceIssueCloseService;
beforeEach(() => {
vi.clearAllMocks();
store = new MockStore();
service = new GitHubSourceIssueCloseService(store as unknown as TaskStore);
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "open" });
});
it("does nothing when setting is disabled", async () => {
store.getSettings.mockResolvedValueOnce({ githubCloseSourceIssueOnDone: false });
service.start();
store.emit("task:moved", createEvent());
await flushAsync();
expect(mockGetIssue).not.toHaveBeenCalled();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("ignores missing sourceIssue", async () => {
service.start();
store.emit("task:moved", createEvent({ sourceIssue: undefined }));
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("ignores non-github provider", async () => {
service.start();
store.emit("task:moved", createEvent({ sourceIssue: { provider: "jira", repository: "owner/repo", issueNumber: 42 } }));
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("logs malformed repository", async () => {
service.start();
store.emit("task:moved", createEvent({ sourceIssue: { provider: "github", repository: "bad", issueNumber: 42 } }));
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to close linked GitHub source issue", "Invalid GitHub source issue metadata: bad#42");
});
it("short-circuits when already closed", async () => {
service.start();
mockGetIssue.mockResolvedValueOnce({ state: "closed" });
store.emit("task:moved", createEvent());
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Skipped closing GitHub source issue - issue not found or already closed", "owner/repo#42");
});
it("skips when source issue is missing from GitHub", async () => {
service.start();
mockGetIssue.mockResolvedValueOnce(null);
store.emit("task:moved", createEvent());
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Skipped closing GitHub source issue - issue not found or already closed", "owner/repo#42");
});
it("closes open github source issue", async () => {
service.start();
store.emit("task:moved", createEvent());
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "completed");
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Closed linked GitHub source issue", "owner/repo#42");
});
it("retries transient close failures once", async () => {
service.start();
mockSetIssueState.mockRejectedValueOnce(new Error("ECONNRESET"));
mockSetIssueState.mockResolvedValueOnce(undefined);
store.emit("task:moved", createEvent());
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockSetIssueState).toHaveBeenCalledTimes(2);
});
it("logs non-transient close failures", async () => {
service.start();
mockSetIssueState.mockRejectedValueOnce(new Error("bad request"));
store.emit("task:moved", createEvent());
await flushAsync();
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to close linked GitHub source issue", "bad request");
});
it("no-ops when to is not done", async () => {
service.start();
store.emit("task:moved", { ...createEvent(), to: "in-review" });
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("no-ops when from is done", async () => {
service.start();
store.emit("task:moved", { ...createEvent(), from: "done", to: "done" });
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("skips and logs when auth resolution fails", async () => {
mockResolveGithubTrackingAuth.mockReturnValueOnce({ ok: false, message: "no auth" });
service.start();
store.emit("task:moved", createEvent());
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Skipped closing GitHub source issue", "no auth");
});
it("attach adds listener for additional project store", async () => {
const lateStore = new MockStore();
service.start();
service.attach(lateStore as unknown as TaskStore);
lateStore.emit("task:moved", createEvent());
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "completed");
});
it("detach removes listeners", async () => {
service.start();
service.detach(store as unknown as TaskStore);
store.emit("task:moved", createEvent());
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
import { GitHubTrackingReconciler } from "../github-tracking-reconciler.js";
const { mockGetIssue, mockSetIssueState } = vi.hoisted(() => ({
mockGetIssue: vi.fn(),
mockSetIssueState: vi.fn(),
}));
const { mockResolveGithubTrackingAuth } = vi.hoisted(() => ({
mockResolveGithubTrackingAuth: vi.fn(),
}));
vi.mock("../github.js", () => ({
GitHubClient: vi.fn().mockImplementation(() => ({
getIssue: (...args: unknown[]) => mockGetIssue(...args),
setIssueState: (...args: unknown[]) => mockSetIssueState(...args),
})),
}));
vi.mock("../github-auth.js", () => ({
resolveGithubTrackingAuth: (...args: unknown[]) => mockResolveGithubTrackingAuth(...args),
}));
function createStore(listTasks: Array<Record<string, unknown>>, settings: Record<string, unknown> = { githubCloseSourceIssueOnDone: true, githubAuthMode: "token", githubAuthToken: "ghp_test" }): TaskStore {
return {
listTasks: vi.fn().mockResolvedValue(listTasks),
listTasksForGithubTrackingReconcile: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue(settings),
getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })),
logEntry: vi.fn().mockResolvedValue(undefined),
} as unknown as TaskStore;
}
describe("GitHubTrackingReconciler.reconcileSourceIssues", () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "open" });
});
it("short-circuits when setting disabled", async () => {
const store = createStore([{ id: "FN-1", status: "done", sourceIssue: { provider: "github", repository: "o/r", issueNumber: 1 } }], { githubCloseSourceIssueOnDone: false });
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(result).toEqual({ scanned: 1, closed: 0, skipped: 1, errors: 0 });
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("closes open source issues", async () => {
const store = createStore([{ id: "FN-1", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 4 } }]);
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 4, "closed", "completed");
expect(result.closed).toBe(1);
});
it("skips already-closed source issues", async () => {
mockGetIssue.mockResolvedValueOnce({ state: "closed" });
const store = createStore([{ id: "FN-1", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 4 } }]);
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(result.skipped).toBe(1);
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("skips source issues missing from GitHub", async () => {
mockGetIssue.mockResolvedValueOnce(null);
const store = createStore([{ id: "FN-12", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 12 } }]);
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(result.skipped).toBe(1);
expect(result.errors).toBe(0);
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("ignores non-done tasks and tasks without sourceIssue", async () => {
const store = createStore([
{ id: "FN-1", status: "todo", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 1 } },
{ id: "FN-2", status: "done" },
{ id: "FN-3", status: "done", sourceIssue: { provider: "jira", repository: "x/y", issueNumber: 3 } },
]);
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(result).toEqual({ scanned: 0, closed: 0, skipped: 0, errors: 0 });
expect(mockGetIssue).not.toHaveBeenCalled();
});
it("counts errors and logs on getIssue failure", async () => {
mockGetIssue.mockRejectedValueOnce(new Error("boom"));
const store = createStore([{ id: "FN-9", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 9 } }]);
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(result.errors).toBe(1);
expect((store.logEntry as any)).toHaveBeenCalledWith("FN-9", "Failed to reconcile GitHub source issue", "boom");
});
it("counts errors and logs on setIssueState failure", async () => {
mockSetIssueState.mockRejectedValueOnce(new Error("write failed"));
const store = createStore([{ id: "FN-10", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 10 } }]);
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(result.errors).toBe(1);
expect((store.logEntry as any)).toHaveBeenCalledWith("FN-10", "Failed to reconcile GitHub source issue", "write failed");
});
it("skips and logs when auth resolution fails", async () => {
mockResolveGithubTrackingAuth.mockReturnValueOnce({ ok: false, message: "no auth" });
const store = createStore([{ id: "FN-11", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 11 } }]);
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(result.skipped).toBe(1);
expect((store.logEntry as any)).toHaveBeenCalledWith("FN-11", "Skipped GitHub source issue reconciliation", "no auth");
expect(mockSetIssueState).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,138 @@
import type { GlobalSettings, ProjectSettings, TaskStore } from "@fusion/core";
import { resolveGithubTrackingAuth } from "./github-auth.js";
import { GitHubClient } from "./github.js";
import { delay, isTransientGitHubError } from "./github-tracking-state.js";
type Column = "triage" | "todo" | "in-progress" | "in-review" | "done" | "archived";
interface TaskMovedEvent {
task: {
id: string;
sourceIssue?: {
provider?: string;
repository?: string;
issueNumber?: number;
};
};
from: Column;
to: Column;
}
export class GitHubSourceIssueCloseService {
private readonly defaultStore: TaskStore;
private readonly listeners = new Map<TaskStore, { onTaskMoved: (event: TaskMovedEvent) => void }>();
private started = false;
constructor(store: TaskStore) {
this.defaultStore = store;
}
start(): void {
if (this.started) return;
this.started = true;
this.attach(this.defaultStore);
}
stop(): void {
if (!this.started) return;
this.started = false;
for (const store of this.listeners.keys()) {
this.detach(store);
}
}
attach(store: TaskStore): void {
if (this.listeners.has(store)) {
return;
}
const onTaskMoved = (event: TaskMovedEvent): void => {
void this.handleTaskMoved(store, event);
};
this.listeners.set(store, { onTaskMoved });
if (this.started) {
store.on("task:moved", onTaskMoved);
}
}
detach(store: TaskStore): void {
const handlers = this.listeners.get(store);
if (!handlers) {
return;
}
store.off("task:moved", handlers.onTaskMoved);
this.listeners.delete(store);
}
private async handleTaskMoved(store: TaskStore, event: TaskMovedEvent): Promise<void> {
if (event.to !== "done" || event.from === "done") {
return;
}
const settings = ((await store.getSettings()) ?? {}) as Pick<ProjectSettings, "githubCloseSourceIssueOnDone" | "githubAuthMode" | "githubAuthToken">;
if (settings.githubCloseSourceIssueOnDone !== true) {
return;
}
const sourceIssue = event.task.sourceIssue;
if (!sourceIssue || sourceIssue.provider !== "github") {
return;
}
const repository = sourceIssue.repository ?? "";
const [owner, repo] = repository.split("/");
const issueNumber = sourceIssue.issueNumber;
if (!owner || !repo || !Number.isInteger(issueNumber)) {
await store.logEntry(
event.task.id,
"Failed to close linked GitHub source issue",
`Invalid GitHub source issue metadata: ${repository}#${String(issueNumber)}`,
);
return;
}
const issueNumberValue = issueNumber as number;
try {
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
const resolution = resolveGithubTrackingAuth({ projectSettings: settings, globalSettings });
if (!resolution.ok) {
await store.logEntry(event.task.id, "Skipped closing GitHub source issue", resolution.message);
return;
}
const client = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
const existing = await client.getIssue(owner, repo, issueNumberValue);
if (!existing || existing.state === "closed") {
await store.logEntry(event.task.id, "Skipped closing GitHub source issue - issue not found or already closed", `${owner}/${repo}#${issueNumberValue}`);
return;
}
const closeIssue = async () => {
await client.setIssueState(owner, repo, issueNumberValue, "closed", "completed");
};
try {
await closeIssue();
} catch (error) {
if (!isTransientGitHubError(error)) {
throw error;
}
await delay(25);
await closeIssue();
}
await store.logEntry(event.task.id, "Closed linked GitHub source issue", `${owner}/${repo}#${issueNumberValue}`);
} catch (error) {
await store.logEntry(
event.task.id,
"Failed to close linked GitHub source issue",
error instanceof Error ? error.message : String(error),
);
}
}
}

View File

@@ -59,6 +59,67 @@ export class GitHubTrackingReconciler {
return { scanned: tasks.length, closed, skipped, errors };
}
async reconcileSourceIssues(store: TaskStore): Promise<{ scanned: number; closed: number; skipped: number; errors: number }> {
const listedTasks = await store.listTasks({ slim: false, includeArchived: false });
const tasks = (Array.isArray(listedTasks) ? listedTasks : [])
.filter((task) => task.status === "done" && task.sourceIssue?.provider === "github")
.slice(0, RECONCILE_SCAN_LIMIT);
const projectSettings = ((await store.getSettings()) ?? {}) as Pick<ProjectSettings, "githubCloseSourceIssueOnDone" | "githubAuthMode" | "githubAuthToken">;
if (projectSettings.githubCloseSourceIssueOnDone !== true) {
return { scanned: tasks.length, closed: 0, skipped: tasks.length, errors: 0 };
}
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
const resolution = resolveGithubTrackingAuth({ projectSettings, globalSettings });
if (!resolution.ok) {
for (const task of tasks) {
await store.logEntry(task.id, "Skipped GitHub source issue reconciliation", resolution.message);
}
return { scanned: tasks.length, closed: 0, skipped: tasks.length, errors: 0 };
}
const client = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
let closed = 0;
let skipped = 0;
let errors = 0;
await runWithConcurrencyLimit(tasks, RECONCILE_CONCURRENCY_LIMIT, async (task) => {
const sourceIssue = task.sourceIssue;
const repository = sourceIssue?.repository ?? "";
const [owner, repo] = repository.split("/");
const issueNumber = sourceIssue?.issueNumber;
if (!owner || !repo || !Number.isInteger(issueNumber)) {
skipped += 1;
return;
}
const issueNumberValue = issueNumber as number;
try {
const linkedIssue = await client.getIssue(owner, repo, issueNumberValue);
if (!linkedIssue || linkedIssue.state === "closed") {
skipped += 1;
return;
}
await client.setIssueState(owner, repo, issueNumberValue, "closed", "completed");
closed += 1;
} catch (error) {
errors += 1;
await store.logEntry(
task.id,
"Failed to reconcile GitHub source issue",
error instanceof Error ? error.message : String(error),
);
}
});
return { scanned: tasks.length, closed, skipped, errors };
}
async reconcileDeletedAndArchived(store: TaskStore): Promise<{ scanned: number; closed: number; skipped: number; errors: number }> {
const listedTasks = await store.listTasksForGithubTrackingReconcile();
const tasks = (Array.isArray(listedTasks) ? listedTasks : []).slice(0, RECONCILE_SCAN_LIMIT);

View File

@@ -44,7 +44,7 @@ export function decideIssueAction(
return null;
}
function isTransientGitHubError(error: unknown): boolean {
export function isTransientGitHubError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
@@ -58,7 +58,7 @@ function isTransientGitHubError(error: unknown): boolean {
|| message.includes("socket hang up");
}
async function delay(ms: number): Promise<void> {
export async function delay(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -31,6 +31,7 @@ export {
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 { GitHubSourceIssueCloseService } from "./github-source-issue-close.js";
export { GitHubTrackingCommentService, formatTrackingComment } from "./github-tracking-comments.js";
export { GitHubTrackingStateService, decideIssueAction } from "./github-tracking-state.js";
export { GitHubTrackingReconciler, RECONCILE_CONCURRENCY_LIMIT, RECONCILE_SCAN_LIMIT } from "./github-tracking-reconciler.js";

View File

@@ -42,6 +42,7 @@ import { GitHubIssueCommentService } from "../github-issue-comment.js";
import { GitHubTrackingCommentService } from "../github-tracking-comments.js";
import { GitHubTrackingStateService } from "../github-tracking-state.js";
import { GitHubTrackingReconciler } from "../github-tracking-reconciler.js";
import { GitHubSourceIssueCloseService } from "../github-source-issue-close.js";
import { githubRateLimiter } from "../github-poll.js";
import * as projectStoreResolver from "../project-store-resolver.js";
import { generatePrMetadata } from "../pr-metadata-generator.js";
@@ -2345,6 +2346,10 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
githubTrackingCommentService.start();
ctx.registerDispose(() => githubTrackingCommentService.stop());
const githubSourceIssueCloseService = new GitHubSourceIssueCloseService(store);
githubSourceIssueCloseService.start();
ctx.registerDispose(() => githubSourceIssueCloseService.stop());
const githubTrackingStateService = new GitHubTrackingStateService(store);
const githubTrackingReconciler = new GitHubTrackingReconciler();
const reconcileScheduledStores = new WeakSet<TaskStore>();
@@ -2357,6 +2362,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
attachedStateStores.add(projectStore);
githubTrackingStateService.attach(projectStore);
githubSourceIssueCloseService.attach(projectStore);
if (!reconcileScheduledStores.has(projectStore)) {
reconcileScheduledStores.add(projectStore);
@@ -2369,6 +2375,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
void githubTrackingReconciler.reconcile(projectStore).catch(() => {});
void githubTrackingReconciler.reconcileDeletedAndArchived(projectStore).catch(() => {});
void githubTrackingReconciler.reconcileSourceIssues(projectStore).catch(() => {});
});
}
};
@@ -2412,6 +2419,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
unsubscribeProjectStoreRegistration();
for (const projectStore of attachedStateStores) {
githubTrackingStateService.detach(projectStore);
githubSourceIssueCloseService.detach(projectStore);
}
githubTrackingStateService.stop();
});