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,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),
);
}
}
}