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 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 { if (event.to !== "done" || event.from === "done") { return; } const settings = ((await store.getSettings()) ?? {}) as Pick; 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; 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), ); } } }