feat(FN-4941): close or delete GitHub issues when tasks are deleted

Fixed GitHub issue close and delete behavior when tasks are deleted in the dashboard's GitHub tracking state module, with test coverage added for both delete and state tracking scenarios.

Fusion-Task-Id: FN-4941
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 12:51:01 -07:00
committed by gsxdsm
parent 3ba9e13f75
commit 9dfefc551b
5 changed files with 220 additions and 8 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Closing/deleting the linked GitHub issue when deleting a tracked Fusion task now completes reliably even after the task is removed from the store, including observable success/failure signals and safe post-delete logging behavior.

View File

@@ -1497,7 +1497,7 @@ When Fusion does create a tracking issue, it formats the title as `[FN-XXXX] Tas
When a tracked task later moves to `in-progress` or `done`, Fusion posts one short lifecycle comment on the linked tracking issue. These comments always include the Fusion task ID as plain text (`Fusion task: FN-XXXX`) and never link back to the Fusion app. The `in-progress` comment stays plain-text; the `done` comment can additionally include GitHub commit/PR markdown links plus branch, file-change, and merge-timestamp details when that merge context is available on the task. No comment is posted for any other transition.
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`. 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.
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.

View File

@@ -6,14 +6,16 @@ import { tmpdir } from "node:os";
import { TaskStore } from "@fusion/core";
import { GitHubTrackingStateService } from "../github-tracking-state.js";
const { mockSetIssueState, mockResolveGithubTrackingAuth } = vi.hoisted(() => ({
const { mockSetIssueState, mockGetIssue, mockResolveGithubTrackingAuth } = vi.hoisted(() => ({
mockSetIssueState: vi.fn(),
mockGetIssue: vi.fn(),
mockResolveGithubTrackingAuth: vi.fn(),
}));
vi.mock("../github.js", () => ({
GitHubClient: vi.fn().mockImplementation(() => ({
setIssueState: (...args: unknown[]) => mockSetIssueState(...args),
getIssue: (...args: unknown[]) => mockGetIssue(...args),
})),
}));
@@ -38,6 +40,7 @@ describe("github tracking delete flow", () => {
beforeEach(async () => {
vi.clearAllMocks();
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "token" } });
mockGetIssue.mockResolvedValue({ state: "open" });
rootDir = makeTmpDir();
globalDir = makeTmpDir();
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
@@ -98,4 +101,70 @@ describe("github tracking delete flow", () => {
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("does not trigger an unhandled rejection when closing linked issue fails on delete", async () => {
const task = await store.createTask({
description: "delete tracked task with close failure",
githubTracking: { enabled: true },
});
await store.linkGithubIssue(task.id, {
owner: "octocat",
repo: "hello-world",
number: 8,
url: "https://github.com/octocat/hello-world/issues/8",
createdAt: new Date().toISOString(),
});
mockSetIssueState.mockRejectedValueOnce(new Error("close failed"));
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => {
unhandledRejections.push(reason);
};
process.on("unhandledRejection", onUnhandledRejection);
try {
await store.deleteTask(task.id);
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("octocat", "hello-world", 8, "closed", "not_planned");
expect(unhandledRejections).toHaveLength(0);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
it("emits github issue action event on successful close-on-delete", async () => {
const task = await store.createTask({
description: "delete tracked task emits github issue close event",
githubTracking: { enabled: true },
});
await store.linkGithubIssue(task.id, {
owner: "octocat",
repo: "hello-world",
number: 9,
url: "https://github.com/octocat/hello-world/issues/9",
createdAt: new Date().toISOString(),
});
const events: Array<Record<string, unknown>> = [];
(store as unknown as { on: (event: string, listener: (payload: Record<string, unknown>) => void) => void }).on(
"github-issue:action",
(payload) => {
events.push(payload);
},
);
await store.deleteTask(task.id);
await flushAsync();
expect(events).toContainEqual({
taskId: task.id,
action: "close",
owner: "octocat",
repo: "hello-world",
number: 9,
outcome: "success",
});
});
});

View File

@@ -388,6 +388,37 @@ describe("GitHubTrackingStateService", () => {
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to close linked GitHub tracking issue", "delete close failed");
});
it("still attempts close and emits failure event when logEntry rejects for deleted task", async () => {
service.start();
store.logEntry = vi.fn().mockRejectedValue(new Error("Task FN-1 not found"));
mockSetIssueState.mockRejectedValueOnce(new Error("delete close failed"));
const emitSpy = vi.spyOn(store, "emit");
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => {
unhandledRejections.push(reason);
};
process.on("unhandledRejection", onUnhandledRejection);
try {
store.emit("task:deleted", createTask(), { githubIssueAction: "close" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledTimes(1);
expect(unhandledRejections).toHaveLength(0);
expect(emitSpy).toHaveBeenCalledWith(
"github-issue:action",
expect.objectContaining({
taskId: "FN-1",
action: "close",
outcome: "failed",
error: "delete close failed",
}),
);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
it("logs delete failures without throwing", async () => {
service.start();
mockDeleteIssue.mockRejectedValueOnce(new Error("delete failed"));
@@ -399,5 +430,36 @@ describe("GitHubTrackingStateService", () => {
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to delete linked GitHub tracking issue", "delete failed");
});
it("still attempts delete and emits failure event when logEntry rejects for deleted task", async () => {
service.start();
store.logEntry = vi.fn().mockRejectedValue(new Error("Task FN-1 not found"));
mockDeleteIssue.mockRejectedValueOnce(new Error("delete failed"));
const emitSpy = vi.spyOn(store, "emit");
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => {
unhandledRejections.push(reason);
};
process.on("unhandledRejection", onUnhandledRejection);
try {
store.emit("task:deleted", createTask(), { githubIssueAction: "delete" });
await flushAsync();
expect(mockDeleteIssue).toHaveBeenCalledTimes(1);
expect(unhandledRejections).toHaveLength(0);
expect(emitSpy).toHaveBeenCalledWith(
"github-issue:action",
expect.objectContaining({
taskId: "FN-1",
action: "delete",
outcome: "failed",
error: "delete failed",
}),
);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
});
});

View File

@@ -58,6 +58,16 @@ async function delay(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
type GitHubIssueActionEvent = {
taskId: string;
action: "close" | "reopen" | "delete" | "leave";
owner: string;
repo: string;
number: number;
outcome: "success" | "failed" | "skipped";
error?: string;
};
export class GitHubTrackingStateService {
private readonly defaultStore: TaskStore;
private readonly listeners = new Map<TaskStore, {
@@ -197,6 +207,23 @@ export class GitHubTrackingStateService {
}
}
private emitGitHubIssueAction(store: TaskStore, event: GitHubIssueActionEvent): void {
(store as unknown as { emit: (eventName: string, payload: GitHubIssueActionEvent) => void }).emit("github-issue:action", event);
}
private async safeLogDeletedTaskEntry(store: TaskStore, taskId: string, message: string, details: string): Promise<void> {
try {
await store.logEntry(taskId, message, details);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes(`Task ${taskId} not found`)) {
console.warn(`[github-tracking-state] Unable to write log entry for deleted task ${taskId}: ${message}`);
return;
}
throw error;
}
}
private async handleTaskDeleted(store: TaskStore, task: Task, meta?: { githubIssueAction?: GithubIssueAction }): Promise<void> {
if (task.githubTracking?.enabled !== true) {
return;
@@ -214,7 +241,8 @@ export class GitHubTrackingStateService {
const githubIssueAction = meta?.githubIssueAction ?? "auto";
if (githubIssueAction === "leave") {
await store.logEntry(task.id, "Left linked GitHub tracking issue unchanged on task delete", `${owner}/${repo}#${number}`);
await this.safeLogDeletedTaskEntry(store, task.id, "Left linked GitHub tracking issue unchanged on task delete", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "leave", owner, repo, number, outcome: "skipped" });
return;
}
@@ -222,6 +250,15 @@ export class GitHubTrackingStateService {
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
const resolution = resolveGithubTrackingAuth({ projectSettings, globalSettings });
if (!resolution.ok) {
this.emitGitHubIssueAction(store, {
taskId: task.id,
action: githubIssueAction === "delete" ? "delete" : "close",
owner,
repo,
number,
outcome: "failed",
error: resolution.message,
});
return;
}
@@ -231,18 +268,57 @@ export class GitHubTrackingStateService {
if (githubIssueAction === "delete") {
try {
await client.deleteIssue(owner, repo, number);
await store.logEntry(task.id, "Deleted linked GitHub tracking issue", `${owner}/${repo}#${number}`);
const deleteIssue = async () => {
await client.deleteIssue(owner, repo, number);
};
try {
await deleteIssue();
} catch (error) {
if (!isTransientGitHubError(error)) {
throw error;
}
await delay(TRANSIENT_RETRY_DELAY_MS);
await deleteIssue();
}
await this.safeLogDeletedTaskEntry(store, task.id, "Deleted linked GitHub tracking issue", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "delete", owner, repo, number, outcome: "success" });
} catch (err) {
await store.logEntry(task.id, "Failed to delete linked GitHub tracking issue", err instanceof Error ? err.message : String(err));
const message = err instanceof Error ? err.message : String(err);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "delete", owner, repo, number, outcome: "failed", error: message });
await this.safeLogDeletedTaskEntry(store, task.id, "Failed to delete linked GitHub tracking issue", message);
}
return;
}
try {
await client.setIssueState(owner, repo, number, "closed", "not_planned");
const existing = await client.getIssue(owner, repo, number);
if (existing?.state === "closed") {
await this.safeLogDeletedTaskEntry(store, task.id, "Linked GitHub tracking issue already closed", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "skipped" });
return;
}
const closeIssue = async () => {
await client.setIssueState(owner, repo, number, "closed", "not_planned");
};
try {
await closeIssue();
} catch (error) {
if (!isTransientGitHubError(error)) {
throw error;
}
await delay(TRANSIENT_RETRY_DELAY_MS);
await closeIssue();
}
await this.safeLogDeletedTaskEntry(store, task.id, "Closed linked GitHub tracking issue", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "success" });
} catch (err) {
await store.logEntry(task.id, "Failed to close linked GitHub tracking issue", err instanceof Error ? err.message : String(err));
const message = err instanceof Error ? err.message : String(err);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "failed", error: message });
await this.safeLogDeletedTaskEntry(store, task.id, "Failed to close linked GitHub tracking issue", message);
}
}
}