feat(FN-5618): handle deleted source issues in TaskCard

Implements source issue delete handling in the task dashboard, including prompt-based user interaction for source-linked issues, new GitHub tracking state logic in `packages/dashboard/src/github-tracking-state.ts`, and corresponding test coverage, with documentation added to `docs/task-management.md

Fusion-Task-Id: FN-5618

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5618
This commit is contained in:
gsxdsm
2026-05-28 08:57:01 -07:00
parent 9969b0779b
commit e84673c632
6 changed files with 407 additions and 7 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Close source-imported GitHub issues when their linked Fusion task is deleted, with parity to tracking-issue delete handling. Dashboard delete confirmation now prompts for `close`, `delete`, or `leave` on source-imported issues and forwards `githubIssueAction` through task deletion flows. For API callers that omit `githubIssueAction` (or send `auto`) on source-imported issue deletes, Fusion now defaults to `close`.

View File

@@ -714,6 +714,8 @@ When tracked tasks later move to `in-progress` or `done`, Fusion also posts a sh
When a tracked task moves into `done`, Fusion closes the linked GitHub issue with `state_reason: completed`; when it leaves `done` for an active column, Fusion reopens the issue with `state_reason: reopened`; and when the Fusion task is permanently deleted from the dashboard, Fusion now prompts for issue handling (`close`, `delete`, or `leave`). If no explicit choice is provided by API callers, Fusion preserves the legacy default and closes the linked issue with `state_reason: not_planned`.
For source-imported GitHub issues (`task.sourceIssue` / `sourceType: "github_import"`) without tracking metadata, delete now follows the same close/delete/leave prompt. The prompt references the source issue (`owner/repo#number`) and forwards the selected `githubIssueAction` through the existing delete route. For non-interactive callers that omit `githubIssueAction` (or send `"auto"`), the source-imported delete default is `close`.
GitHub authentication/settings are configured in [Settings Reference](./settings-reference.md) via `githubAuthMode` (`gh-cli` or `token`) and `githubAuthToken`.
## Completion Modes (`mergeStrategy`)

View File

@@ -1273,12 +1273,32 @@ function TaskCardComponent({
}
const trackedIssue = task.githubTracking?.enabled === true ? task.githubTracking.issue : undefined;
const sourceIssueRef = (() => {
if (trackedIssue) {
return null;
}
const sourceIssue = task.sourceIssue;
if (sourceIssue?.provider === "github") {
const [owner, repo, extra] = sourceIssue.repository.split("/");
if (owner && repo && !extra && Number.isInteger(sourceIssue.issueNumber) && sourceIssue.issueNumber > 0) {
return { owner, repo, number: sourceIssue.issueNumber };
}
}
return parseGithubIssueUrl(getIssueUrlFromMetadata(task.sourceMetadata) ?? task.issueInfo?.url);
})();
const issueRef = trackedIssue?.owner && trackedIssue.repo && trackedIssue.number
? { owner: trackedIssue.owner, repo: trackedIssue.repo, number: trackedIssue.number }
: sourceIssueRef;
let githubIssueAction: GithubIssueAction | undefined;
if (trackedIssue?.owner && trackedIssue.repo && trackedIssue.number) {
const issueRef = `${trackedIssue.owner}/${trackedIssue.repo}#${trackedIssue.number}`;
if (issueRef?.owner && issueRef.repo && issueRef.number) {
const issueLabel = `${issueRef.owner}/${issueRef.repo}#${issueRef.number}`;
const shouldCloseIssue = await confirm({
title: "Linked GitHub Issue",
message: `Choose what to do with ${issueRef} when deleting ${task.id}.\n\nClose the issue?`,
message: `Choose what to do with ${issueLabel} when deleting ${task.id}.\n\nClose the issue?`,
confirmLabel: "Close Issue",
cancelLabel: "More Options",
});
@@ -1288,7 +1308,7 @@ function TaskCardComponent({
} else {
const shouldDeleteIssue = await confirm({
title: "Delete Linked GitHub Issue",
message: `Delete ${issueRef} on GitHub, or leave it unchanged?`,
message: `Delete ${issueLabel} on GitHub, or leave it unchanged?`,
confirmLabel: "Delete Issue",
cancelLabel: "Leave Unchanged",
danger: true,
@@ -1303,8 +1323,8 @@ function TaskCardComponent({
} else {
await onDeleteTask(task.id);
}
const issueSuffix = trackedIssue?.owner && trackedIssue.repo && trackedIssue.number && githubIssueAction
? ` and ${githubIssueAction === "close" ? "closed" : githubIssueAction === "delete" ? "deleted" : "left"} issue ${trackedIssue.owner}/${trackedIssue.repo}#${trackedIssue.number}`
const issueSuffix = issueRef?.owner && issueRef.repo && issueRef.number && githubIssueAction
? ` and ${githubIssueAction === "close" ? "closed" : githubIssueAction === "delete" ? "deleted" : "left"} issue ${issueRef.owner}/${issueRef.repo}#${issueRef.number}`
: "";
addToast(`Deleted ${task.id}${issueSuffix}`, "success");
} catch (err) {
@@ -1389,7 +1409,7 @@ function TaskCardComponent({
addToast(`Failed to delete ${task.id}: ${getErrorMessage(retryErr)}`, "error");
}
}
}, [addToast, confirm, onDeleteTask, task.githubTracking?.enabled, task.githubTracking?.issue, task.id]);
}, [addToast, confirm, onDeleteTask, task.githubTracking?.enabled, task.githubTracking?.issue, task.id, task.issueInfo?.url, task.sourceIssue, task.sourceMetadata]);
const handleOpenFiles = useCallback((e: React.MouseEvent) => {
e.stopPropagation();

View File

@@ -237,6 +237,111 @@ describe("TaskCard", () => {
});
});
it("uses githubIssueAction for source-imported task delete close", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
render(
<TaskCard
task={makeTask({
column: "triage",
sourceIssue: { provider: "github", repository: "acme/widgets", issueNumber: 42, externalIssueId: "42" },
} as any)}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
/>,
);
await act(async () => {
fireEvent.click(screen.getByLabelText("Delete task"));
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-001", { githubIssueAction: "close" });
});
});
it("uses githubIssueAction=delete for source-imported task delete", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
render(
<TaskCard
task={makeTask({
column: "triage",
sourceIssue: { provider: "github", repository: "acme/widgets", issueNumber: 42, externalIssueId: "42" },
} as any)}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
/>,
);
await act(async () => {
fireEvent.click(screen.getByLabelText("Delete task"));
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-001", { githubIssueAction: "delete" });
});
});
it("uses githubIssueAction=leave for source-imported task delete", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false);
render(
<TaskCard
task={makeTask({
column: "triage",
sourceIssue: { provider: "github", repository: "acme/widgets", issueNumber: 42, externalIssueId: "42" },
} as any)}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
/>,
);
await act(async () => {
fireEvent.click(screen.getByLabelText("Delete task"));
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-001", { githubIssueAction: "leave" });
});
});
it("deletes without githubIssueAction when no linked github issue exists", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm.mockResolvedValueOnce(true);
render(
<TaskCard
task={makeTask({ column: "triage", githubTracking: { enabled: false }, sourceIssue: undefined } as any)}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
/>,
);
await act(async () => {
fireEvent.click(screen.getByLabelText("Delete task"));
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-001");
});
});
it("preserves githubIssueAction on dependency-conflict retry", async () => {
const conflict = new Error("Cannot delete task FN-001: still referenced as a dependency by FN-002.") as Error & { status: number; details: { code: string; dependentIds: string[] } };
conflict.status = 409;

View File

@@ -55,6 +55,19 @@ function createTask(overrides: Record<string, unknown> = {}): Record<string, unk
};
}
function createSourceTask(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: "FN-source",
sourceIssue: {
provider: "github",
repository: "acme/widgets",
issueNumber: 42,
externalIssueId: "42",
},
...overrides,
};
}
async function flushAsync(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
@@ -351,6 +364,145 @@ describe("GitHubTrackingStateService", () => {
);
});
describe("source-imported issue delete", () => {
it("leaves source issue untouched when githubIssueAction is leave", async () => {
service.start();
store.emit("task:deleted", createSourceTask(), { githubIssueAction: "leave" });
await flushAsync();
expect(mockDeleteIssue).not.toHaveBeenCalled();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-source",
"Left linked source GitHub issue unchanged on task delete",
"acme/widgets#42",
);
});
it("closes source issue with completed reason", async () => {
service.start();
store.emit("task:deleted", createSourceTask(), { githubIssueAction: "close" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("acme", "widgets", 42, "closed", "completed");
});
it("short-circuits when source issue is already closed", async () => {
service.start();
mockGetIssue.mockResolvedValueOnce({ state: "closed" });
store.emit("task:deleted", createSourceTask(), { githubIssueAction: "close" });
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith("FN-source", "Linked source GitHub issue already closed", "acme/widgets#42");
});
it("retries transient source close errors once", async () => {
service.start();
mockSetIssueState.mockRejectedValueOnce(new Error("ECONNRESET"));
mockSetIssueState.mockResolvedValueOnce(undefined);
store.emit("task:deleted", createSourceTask(), { githubIssueAction: "close" });
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockSetIssueState).toHaveBeenCalledTimes(2);
});
it("emits failure for non-transient source close errors", async () => {
service.start();
mockSetIssueState.mockRejectedValueOnce(new Error("close failed"));
const emitSpy = vi.spyOn(store, "emit");
store.emit("task:deleted", createSourceTask(), { githubIssueAction: "close" });
await flushAsync();
expect(emitSpy).toHaveBeenCalledWith(
"github-issue:action",
expect.objectContaining({ taskId: "FN-source", action: "close", outcome: "failed", error: "close failed" }),
);
});
it("deletes source issue when githubIssueAction is delete", async () => {
service.start();
store.emit("task:deleted", createSourceTask(), { githubIssueAction: "delete" });
await flushAsync();
expect(mockDeleteIssue).toHaveBeenCalledWith("acme", "widgets", 42);
});
it("retries transient source delete errors once", async () => {
service.start();
mockDeleteIssue.mockRejectedValueOnce(new Error("timed out"));
mockDeleteIssue.mockResolvedValueOnce(undefined);
store.emit("task:deleted", createSourceTask(), { githubIssueAction: "delete" });
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDeleteIssue).toHaveBeenCalledTimes(2);
});
it.each([undefined, "auto"] as const)("defaults source delete action %s to close", async (action) => {
service.start();
if (action === undefined) {
store.emit("task:deleted", createSourceTask());
} else {
store.emit("task:deleted", createSourceTask(), { githubIssueAction: action });
}
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("acme", "widgets", 42, "closed", "completed");
});
it("ignores non-github source providers", async () => {
service.start();
store.emit("task:deleted", createSourceTask({ sourceIssue: { provider: "gitlab", repository: "group/proj", issueNumber: 42 } }));
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(mockDeleteIssue).not.toHaveBeenCalled();
});
it("logs malformed source repository", async () => {
service.start();
store.emit("task:deleted", createSourceTask({ sourceIssue: { provider: "github", repository: "no-slash", issueNumber: 42 } }));
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-source",
"Failed to close linked source GitHub issue",
"Invalid source issue repository: no-slash",
);
});
it.each([0, -1, 1.5] as const)("ignores invalid source issue numbers (%s)", async (issueNumber) => {
service.start();
store.emit("task:deleted", createSourceTask({ sourceIssue: { provider: "github", repository: "acme/widgets", issueNumber } }));
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(mockDeleteIssue).not.toHaveBeenCalled();
});
it("prefers tracking branch when both tracking and source issue exist", async () => {
service.start();
store.emit("task:deleted", createTask({ sourceIssue: { provider: "github", repository: "acme/widgets", issueNumber: 42 } }), { githubIssueAction: "close" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "not_planned");
expect(mockSetIssueState).not.toHaveBeenCalledWith("acme", "widgets", 42, "closed", "completed");
});
});
it.each([
{
label: "tracking disabled",

View File

@@ -231,8 +231,124 @@ export class GitHubTrackingStateService {
}
}
private async handleSourceIssueDelete(store: TaskStore, task: Task, meta?: { githubIssueAction?: GithubIssueAction }): Promise<void> {
const sourceIssue = task.sourceIssue;
if (sourceIssue?.provider !== "github") {
return;
}
const [owner, repo, extra] = sourceIssue.repository.split("/");
if (!owner || !repo || extra) {
await this.safeLogDeletedTaskEntry(
store,
task.id,
"Failed to close linked source GitHub issue",
`Invalid source issue repository: ${sourceIssue.repository}`,
);
return;
}
const number = sourceIssue.issueNumber;
if (!Number.isInteger(number) || number <= 0) {
await this.safeLogDeletedTaskEntry(
store,
task.id,
"Failed to close linked source GitHub issue",
`Invalid source issue number: ${String(number)}`,
);
return;
}
const githubIssueAction = meta?.githubIssueAction ?? "auto";
// Source-imported issues represent real incoming work; if no explicit action is provided,
// deleting the task defaults to closing the source issue.
const resolvedAction = githubIssueAction === "auto" ? "close" : githubIssueAction;
if (resolvedAction === "leave") {
await this.safeLogDeletedTaskEntry(store, task.id, "Left linked source GitHub issue unchanged on task delete", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "leave", owner, repo, number, outcome: "skipped" });
return;
}
const projectSettings = await store.getSettings() as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
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: resolvedAction === "delete" ? "delete" : "close",
owner,
repo,
number,
outcome: "failed",
error: resolution.message,
});
return;
}
const client = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
if (resolvedAction === "delete") {
try {
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 source GitHub issue", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "delete", owner, repo, number, outcome: "success" });
} catch (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 source GitHub issue", message);
}
return;
}
try {
const existing = await client.getIssue(owner, repo, number);
if (existing?.state === "closed") {
await this.safeLogDeletedTaskEntry(store, task.id, "Linked source GitHub issue already closed", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "skipped" });
return;
}
const closeIssue = async () => {
// Source-imported issues map to completed work, so closure reason is "completed".
await client.setIssueState(owner, repo, number, "closed", "completed");
};
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 source GitHub issue", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "success" });
} catch (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 source GitHub issue", message);
}
}
private async handleTaskDeleted(store: TaskStore, task: Task, meta?: { githubIssueAction?: GithubIssueAction }): Promise<void> {
if (task.githubTracking?.enabled !== true) {
await this.handleSourceIssueDelete(store, task, meta);
return;
}