FN-6194: fix archived GitHub issue reconciliation

Handle archived task transitions consistently for linked GitHub issues.

- close tracked and source issues when archived tasks should land closed, including not_planned vs completed reasons
- reopen linked issues when archived tasks move back to done or active work resumes
- extend dashboard reconciler and state coverage to include archived tasks and source issue handling

Files changed:
 .../__tests__/github-source-issue-close.test.ts    | 70 +++++++++++++++++++++-
 .../__tests__/github-tracking-reconciler.test.ts   | 60 ++++++++++++++++++-
 .../src/__tests__/github-tracking-state.test.ts    | 10 +++-
 .../dashboard/src/github-source-issue-close.ts     | 32 ++++++----
 .../dashboard/src/github-tracking-reconciler.ts    | 14 +++--
 packages/dashboard/src/github-tracking-state.ts    |  4 ++
 6 files changed, 167 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-6194

Fusion-Task-Lineage: 867527a0-6eb9-416e-b1eb-d690fa029d60
This commit is contained in:
gsxdsm
2026-06-10 10:34:36 -07:00
parent 6359a67aab
commit 747bbf51f1
6 changed files with 168 additions and 24 deletions

View File

@@ -121,6 +121,65 @@ describe("GitHubSourceIssueCloseService", () => {
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Closed linked GitHub source issue", "owner/repo#42");
});
it.each([
["todo", "not_planned"],
["in-progress", "not_planned"],
["done", "completed"],
])("closes source issue when archived from %s", async (from, stateReason) => {
service.start();
store.emit("task:moved", { ...createEvent(), from, to: "archived" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", stateReason);
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Closed linked GitHub source issue", "owner/repo#42");
});
it("reopens source issue when unarchived to done", async () => {
mockGetIssue.mockResolvedValueOnce({ state: "closed" });
service.start();
store.emit("task:moved", { ...createEvent(), from: "archived", to: "done" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "open", "reopened");
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Reopened linked GitHub source issue", "owner/repo#42");
});
it("reopens source issue when moved out of done", async () => {
mockGetIssue.mockResolvedValueOnce({ state: "closed" });
service.start();
store.emit("task:moved", { ...createEvent(), from: "done", to: "todo" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "open", "reopened");
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Reopened linked GitHub source issue", "owner/repo#42");
});
it("does nothing for archived source issue transition when setting is disabled", async () => {
store.getSettings.mockResolvedValueOnce({ githubCloseSourceIssueOnDone: false });
service.start();
store.emit("task:moved", { ...createEvent(), to: "archived" });
await flushAsync();
expect(mockGetIssue).not.toHaveBeenCalled();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(store.logEntry).not.toHaveBeenCalled();
});
it("ignores archived transitions without sourceIssue", async () => {
service.start();
store.emit("task:moved", { ...createEvent({ sourceIssue: undefined }), to: "archived" });
await flushAsync();
expect(mockGetIssue).not.toHaveBeenCalled();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("ignores archived transitions for non-github provider", async () => {
service.start();
store.emit("task:moved", {
...createEvent({ sourceIssue: { provider: "jira", repository: "owner/repo", issueNumber: 42 } }),
to: "archived",
});
await flushAsync();
expect(mockGetIssue).not.toHaveBeenCalled();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("retries transient close failures once", async () => {
service.start();
mockSetIssueState.mockRejectedValueOnce(new Error("ECONNRESET"));
@@ -138,20 +197,27 @@ describe("GitHubSourceIssueCloseService", () => {
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to close linked GitHub source issue", "bad request");
});
it("no-ops when to is not done", async () => {
it("no-ops for non-actionable transitions", async () => {
service.start();
store.emit("task:moved", { ...createEvent(), to: "in-review" });
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("no-ops when from is done", async () => {
it("no-ops when remaining done", async () => {
service.start();
store.emit("task:moved", { ...createEvent(), from: "done", to: "done" });
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("no-ops when remaining archived", async () => {
service.start();
store.emit("task:moved", { ...createEvent(), from: "archived", to: "archived" });
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("skips and logs when auth resolution fails", async () => {
mockResolveGithubTrackingAuth.mockReturnValueOnce({ ok: false, message: "no auth" });
service.start();

View File

@@ -26,6 +26,7 @@ function createStore(options: {
listTasks?: Array<Record<string, unknown>>;
reconcileCandidates?: Array<Record<string, unknown>>;
reconcileHasMore?: boolean;
settings?: Record<string, unknown>;
}): TaskStore {
return {
listTasks: vi.fn().mockResolvedValue(options.listTasks ?? []),
@@ -33,7 +34,7 @@ function createStore(options: {
.fn()
.mockResolvedValue({ tasks: options.reconcileCandidates ?? [], hasMore: options.reconcileHasMore ?? false }),
logEntry: vi.fn().mockResolvedValue(undefined),
getSettings: vi.fn().mockResolvedValue({ githubAuthMode: "token", githubAuthToken: "ghp_test" }),
getSettings: vi.fn().mockResolvedValue(options.settings ?? { githubAuthMode: "token", githubAuthToken: "ghp_test" }),
getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })),
} as unknown as TaskStore;
}
@@ -50,10 +51,28 @@ describe("GitHubTrackingReconciler", () => {
const result = await new GitHubTrackingReconciler().reconcile(store);
expect((store.listTasks as any)).toHaveBeenCalledWith({ slim: true, includeArchived: true });
expect(mockSetIssueState).toHaveBeenCalledWith("o", "r", 1, "closed", "completed");
expect(result.closed).toBe(1);
});
it("closes open issues for archived tracked tasks using completion heuristic", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "open" });
const store = createStore({
listTasks: [
{ id: "FN-1", column: "archived", executionCompletedAt: "2026-01-01T00:00:00.000Z", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } },
{ id: "FN-2", column: "archived", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 2 } } },
],
});
const result = await new GitHubTrackingReconciler().reconcile(store);
expect(mockSetIssueState).toHaveBeenCalledWith("o", "r", 1, "closed", "completed");
expect(mockSetIssueState).toHaveBeenCalledWith("o", "r", 2, "closed", "not_planned");
expect(result).toMatchObject({ scanned: 2, closed: 2, skipped: 0, errors: 0 });
});
it("skips closed issues and invalid tracking tasks", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "closed" });
@@ -119,6 +138,45 @@ describe("GitHubTrackingReconciler", () => {
expect(maxInFlight).toBeLessThanOrEqual(RECONCILE_CONCURRENCY_LIMIT);
});
describe("reconcileSourceIssues", () => {
const sourceSettings = { githubCloseSourceIssueOnDone: true, githubAuthMode: "token", githubAuthToken: "ghp_test" };
it("scans done and archived GitHub source issues including archived tasks", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "open" });
const store = createStore({
settings: sourceSettings,
listTasks: [
{ id: "FN-1", column: "done", sourceIssue: { provider: "github", repository: "o/r", issueNumber: 1 } },
{ id: "FN-2", column: "archived", executionCompletedAt: "2026-01-01T00:00:00.000Z", sourceIssue: { provider: "github", repository: "o/r", issueNumber: 2 } },
{ id: "FN-3", column: "archived", sourceIssue: { provider: "github", repository: "o/r", issueNumber: 3 } },
{ id: "FN-4", column: "todo", sourceIssue: { provider: "github", repository: "o/r", issueNumber: 4 } },
{ id: "FN-5", column: "archived", sourceIssue: { provider: "jira", repository: "o/r", issueNumber: 5 } },
],
});
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect((store.listTasks as any)).toHaveBeenCalledWith({ slim: false, includeArchived: true });
expect(mockSetIssueState).toHaveBeenCalledWith("o", "r", 1, "closed", "completed");
expect(mockSetIssueState).toHaveBeenCalledWith("o", "r", 2, "closed", "completed");
expect(mockSetIssueState).toHaveBeenCalledWith("o", "r", 3, "closed", "not_planned");
expect(result).toMatchObject({ scanned: 3, closed: 3, skipped: 0, errors: 0 });
});
it("skips source issue reconciliation when close-on-done is disabled", async () => {
const store = createStore({
settings: { githubCloseSourceIssueOnDone: false, githubAuthMode: "token", githubAuthToken: "ghp_test" },
listTasks: [{ id: "FN-1", column: "archived", sourceIssue: { provider: "github", repository: "o/r", issueNumber: 1 } }],
});
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(result).toEqual({ scanned: 1, closed: 0, skipped: 1, errors: 0 });
});
});
describe("reconcileDeletedAndArchived", () => {
it("closes with not_planned for soft-deleted tasks", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });

View File

@@ -76,10 +76,14 @@ describe("decideIssueAction", () => {
const columns = ["triage", "todo", "in-progress", "in-review", "done", "archived"] as const;
const activeColumns = ["triage", "todo", "in-progress", "in-review"] as const;
it.each(columns.filter((from) => from !== "done"))("returns close for %s -> done", (from) => {
it.each(columns.filter((from) => from !== "done" && from !== "archived"))("returns close for %s -> done", (from) => {
expect(decideIssueAction(from, "done")).toEqual({ action: "close", stateReason: "completed" });
});
it("returns reopen for archived -> done", () => {
expect(decideIssueAction("archived", "done")).toEqual({ action: "reopen", stateReason: "reopened" });
});
it.each(activeColumns)("returns reopen for done -> %s", (to) => {
expect(decideIssueAction("done", to)).toEqual({ action: "reopen", stateReason: "reopened" });
});
@@ -146,13 +150,13 @@ describe("GitHubTrackingStateService", () => {
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Closed linked GitHub tracking issue", "owner/repo#42");
});
it("closes on archived -> done", async () => {
it("reopens on archived -> done", async () => {
service.start();
store.emit("task:moved", { task: createTask(), from: "archived", to: "done" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "completed");
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "open", "reopened");
});
it.each(["todo", "triage", "in-progress", "in-review"] as const)("reopens on done -> %s", async (to) => {

View File

@@ -1,7 +1,7 @@
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";
import { decideIssueAction, delay, isTransientGitHubError } from "./github-tracking-state.js";
interface TaskMovedEvent {
task: {
@@ -66,15 +66,17 @@ export class GitHubSourceIssueCloseService {
}
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 action = decideIssueAction(event.from, event.to);
if (!action) {
return;
}
const state = action.action === "close" ? "closed" : "open";
const sourceIssue = event.task.sourceIssue;
if (!sourceIssue || sourceIssue.provider !== "github") {
return;
@@ -107,26 +109,34 @@ export class GitHubSourceIssueCloseService {
: 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}`);
if (!existing || existing.state === state) {
await store.logEntry(
event.task.id,
`Skipped ${action.action === "close" ? "closing" : "reopening"} GitHub source issue - issue not found or already ${state}`,
`${owner}/${repo}#${issueNumberValue}`,
);
return;
}
const closeIssue = async () => {
await client.setIssueState(owner, repo, issueNumberValue, "closed", "completed");
const applyIssueAction = async () => {
await client.setIssueState(owner, repo, issueNumberValue, state, action.stateReason);
};
try {
await closeIssue();
await applyIssueAction();
} catch (error) {
if (!isTransientGitHubError(error)) {
throw error;
}
await delay(25);
await closeIssue();
await applyIssueAction();
}
await store.logEntry(event.task.id, "Closed linked GitHub source issue", `${owner}/${repo}#${issueNumberValue}`);
await store.logEntry(
event.task.id,
`${action.action === "close" ? "Closed" : "Reopened"} linked GitHub source issue`,
`${owner}/${repo}#${issueNumberValue}`,
);
} catch (error) {
await store.logEntry(
event.task.id,

View File

@@ -7,9 +7,9 @@ const RECONCILE_CONCURRENCY_LIMIT = 4;
export class GitHubTrackingReconciler {
async reconcile(store: TaskStore): Promise<{ scanned: number; closed: number; skipped: number; errors: number }> {
const listedTasks = await store.listTasks({ slim: true, includeArchived: false });
const listedTasks = await store.listTasks({ slim: true, includeArchived: true });
const tasks = (Array.isArray(listedTasks) ? listedTasks : [])
.filter((task) => task.column === "done")
.filter((task) => task.column === "done" || task.column === "archived")
.slice(0, RECONCILE_SCAN_LIMIT);
const projectSettings = ((await store.getSettings()) ?? {}) as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
@@ -44,7 +44,8 @@ export class GitHubTrackingReconciler {
return;
}
await client.setIssueState(issue.owner, issue.repo, issue.number, "closed", "completed");
const stateReason = task.column === "archived" && !task.executionCompletedAt ? "not_planned" : "completed";
await client.setIssueState(issue.owner, issue.repo, issue.number, "closed", stateReason);
closed += 1;
} catch (error) {
errors += 1;
@@ -60,9 +61,9 @@ export class GitHubTrackingReconciler {
}
async reconcileSourceIssues(store: TaskStore): Promise<{ scanned: number; closed: number; skipped: number; errors: number }> {
const listedTasks = await store.listTasks({ slim: false, includeArchived: false });
const listedTasks = await store.listTasks({ slim: false, includeArchived: true });
const tasks = (Array.isArray(listedTasks) ? listedTasks : [])
.filter((task) => task.column === "done" && task.sourceIssue?.provider === "github")
.filter((task) => (task.column === "done" || task.column === "archived") && task.sourceIssue?.provider === "github")
.slice(0, RECONCILE_SCAN_LIMIT);
const projectSettings = ((await store.getSettings()) ?? {}) as Pick<ProjectSettings, "githubCloseSourceIssueOnDone" | "githubAuthMode" | "githubAuthToken">;
@@ -105,7 +106,8 @@ export class GitHubTrackingReconciler {
return;
}
await client.setIssueState(owner, repo, issueNumberValue, "closed", "completed");
const stateReason = task.column === "archived" && !task.executionCompletedAt ? "not_planned" : "completed";
await client.setIssueState(owner, repo, issueNumberValue, "closed", stateReason);
closed += 1;
} catch (error) {
errors += 1;

View File

@@ -30,6 +30,10 @@ export function decideIssueAction(
from: string,
to: string,
): { action: "close" | "reopen"; stateReason: "completed" | "not_planned" | "reopened" } | null {
if (from === "archived" && to === "done") {
return { action: "reopen", stateReason: "reopened" };
}
if (to === "done" && from !== "done") {
return { action: "close", stateReason: "completed" };
}