Files
fusion/packages/dashboard/src/github-tracking-state.ts
Fusion d0c9e47f66 feat(FN-4253): close linked GitHub issues on task deletion
Implements automatic closing of linked GitHub issues when a task is deleted (FN-4253), adding deletion logic to the GitHub tracking state module with corresponding tests for both state management and deletion behavior, plus a patch changeset for the `@runfusion/fusion` CLI.

Fusion-Task-Id: FN-4253
2026-05-12 21:11:21 -07:00

167 lines
4.9 KiB
TypeScript

import type { GlobalSettings, ProjectSettings, Task, TaskStore } from "@fusion/core";
import { GitHubClient } from "./github.js";
import { resolveGithubTrackingAuth } from "./github-auth.js";
type Column = "triage" | "todo" | "in-progress" | "in-review" | "done" | "archived";
interface TaskMovedEvent {
task: {
id: string;
githubTracking?: {
enabled?: boolean;
issue?: {
owner?: string;
repo?: string;
number?: number;
url?: string;
htmlUrl?: string;
createdAt?: string;
};
};
};
from: Column;
to: Column;
}
export function decideIssueAction(
from: Column,
to: Column,
): { action: "close" | "reopen"; stateReason: "completed" | "reopened" } | null {
if (to === "done" && from !== "done") {
return { action: "close", stateReason: "completed" };
}
if (from === "done" && to !== "done" && to !== "archived") {
return { action: "reopen", stateReason: "reopened" };
}
return null;
}
export class GitHubTrackingStateService {
private readonly store: TaskStore;
private readonly onTaskMoved = (event: TaskMovedEvent): void => {
void this.handleTaskMoved(event);
};
private readonly onTaskDeleted = (task: Task): void => {
void this.handleTaskDeleted(task);
};
private started = false;
constructor(store: TaskStore) {
this.store = store;
}
start(): void {
if (this.started) return;
this.started = true;
this.store.on("task:moved", this.onTaskMoved);
this.store.on("task:deleted", this.onTaskDeleted);
}
stop(): void {
if (!this.started) return;
this.started = false;
this.store.off("task:moved", this.onTaskMoved);
this.store.off("task:deleted", this.onTaskDeleted);
}
private async handleTaskMoved(event: TaskMovedEvent): Promise<void> {
const decision = decideIssueAction(event.from, event.to);
if (!decision) {
return;
}
if (event.task.githubTracking?.enabled !== true) {
return;
}
const issue = event.task.githubTracking?.issue;
if (!issue) {
return;
}
const { owner, repo, number } = issue;
if (!owner || !repo || !number) {
await this.store.logEntry(
event.task.id,
"Failed to update GitHub tracking issue state",
"Linked issue metadata is incomplete",
);
return;
}
try {
const projectSettings = await this.store.getSettings() as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
const globalSettings = (await this.store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
const resolution = resolveGithubTrackingAuth({ projectSettings, globalSettings });
if (!resolution.ok) {
await this.store.logEntry(event.task.id, "Skipped GitHub tracking issue state update", resolution.message);
return;
}
const client = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
await client.setIssueState(
owner,
repo,
number,
decision.action === "close" ? "closed" : "open",
decision.stateReason,
);
await this.store.logEntry(
event.task.id,
decision.action === "close"
? "Closed linked GitHub tracking issue"
: "Reopened linked GitHub tracking issue",
`${owner}/${repo}#${number}`,
);
} catch (err) {
await this.store.logEntry(
event.task.id,
decision.action === "close"
? "Failed to close GitHub tracking issue"
: "Failed to reopen GitHub tracking issue",
err instanceof Error ? err.message : String(err),
);
}
}
private async handleTaskDeleted(task: Task): Promise<void> {
if (task.githubTracking?.enabled !== true) {
return;
}
const issue = task.githubTracking.issue;
if (!issue) {
return;
}
const { owner, repo, number } = issue;
if (!owner || !repo || !number) {
return;
}
const projectSettings = await this.store.getSettings() as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
const globalSettings = (await this.store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
const resolution = resolveGithubTrackingAuth({ projectSettings, globalSettings });
if (!resolution.ok) {
return;
}
const client = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
try {
await client.setIssueState(owner, repo, number, "closed", "not_planned");
} catch (err) {
console.warn(
`[github-tracking-state] Failed to close linked GitHub tracking issue for deleted task ${task.id}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}