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
167 lines
4.9 KiB
TypeScript
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)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|