Files
fusion/packages/dashboard/src/__tests__/github-source-issue-close.test.ts
gsxdsm 747bbf51f1 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
2026-06-10 10:34:36 -07:00

247 lines
9.4 KiB
TypeScript

import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import type { TaskStore } from "@fusion/core";
import { GitHubSourceIssueCloseService } from "../github-source-issue-close.js";
const { mockSetIssueState, mockGetIssue } = vi.hoisted(() => ({
mockSetIssueState: vi.fn(),
mockGetIssue: vi.fn(),
}));
const { mockResolveGithubTrackingAuth } = vi.hoisted(() => ({
mockResolveGithubTrackingAuth: vi.fn(),
}));
vi.mock("../github.js", () => ({
GitHubClient: vi.fn().mockImplementation(function () { return {
setIssueState: (...args: unknown[]) => mockSetIssueState(...args),
getIssue: (...args: unknown[]) => mockGetIssue(...args),
}; }),
}));
vi.mock("../github-auth.js", () => ({
resolveGithubTrackingAuth: (...args: unknown[]) => mockResolveGithubTrackingAuth(...args),
}));
class MockStore extends EventEmitter {
logEntry: Mock;
getSettings: Mock;
getGlobalSettingsStore: Mock;
constructor() {
super();
this.logEntry = vi.fn().mockResolvedValue(undefined);
this.getSettings = vi.fn().mockResolvedValue({ githubCloseSourceIssueOnDone: true, githubAuthMode: "token", githubAuthToken: "ghp_test" });
this.getGlobalSettingsStore = vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) }));
}
}
function createEvent(overrides: Record<string, unknown> = {}) {
return {
task: {
id: "FN-1",
sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 42 },
...overrides,
},
from: "todo",
to: "done",
};
}
async function flushAsync(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("GitHubSourceIssueCloseService", () => {
let store: MockStore;
let service: GitHubSourceIssueCloseService;
beforeEach(() => {
vi.clearAllMocks();
store = new MockStore();
service = new GitHubSourceIssueCloseService(store as unknown as TaskStore);
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "open" });
});
it("does nothing when setting is disabled", async () => {
store.getSettings.mockResolvedValueOnce({ githubCloseSourceIssueOnDone: false });
service.start();
store.emit("task:moved", createEvent());
await flushAsync();
expect(mockGetIssue).not.toHaveBeenCalled();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("ignores missing sourceIssue", async () => {
service.start();
store.emit("task:moved", createEvent({ sourceIssue: undefined }));
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("ignores non-github provider", async () => {
service.start();
store.emit("task:moved", createEvent({ sourceIssue: { provider: "jira", repository: "owner/repo", issueNumber: 42 } }));
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("logs malformed repository", async () => {
service.start();
store.emit("task:moved", createEvent({ sourceIssue: { provider: "github", repository: "bad", issueNumber: 42 } }));
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to close linked GitHub source issue", "Invalid GitHub source issue metadata: bad#42");
});
it("short-circuits when already closed", async () => {
service.start();
mockGetIssue.mockResolvedValueOnce({ state: "closed" });
store.emit("task:moved", createEvent());
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Skipped closing GitHub source issue - issue not found or already closed", "owner/repo#42");
});
it("skips when source issue is missing from GitHub", async () => {
service.start();
mockGetIssue.mockResolvedValueOnce(null);
store.emit("task:moved", createEvent());
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Skipped closing GitHub source issue - issue not found or already closed", "owner/repo#42");
});
it("closes open github source issue", async () => {
service.start();
store.emit("task:moved", createEvent());
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "completed");
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"));
mockSetIssueState.mockResolvedValueOnce(undefined);
store.emit("task:moved", createEvent());
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockSetIssueState).toHaveBeenCalledTimes(2);
});
it("logs non-transient close failures", async () => {
service.start();
mockSetIssueState.mockRejectedValueOnce(new Error("bad request"));
store.emit("task:moved", createEvent());
await flushAsync();
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to close linked GitHub source issue", "bad request");
});
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 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();
store.emit("task:moved", createEvent());
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Skipped closing GitHub source issue", "no auth");
});
it("attach adds listener for additional project store", async () => {
const lateStore = new MockStore();
service.start();
service.attach(lateStore as unknown as TaskStore);
lateStore.emit("task:moved", createEvent());
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "completed");
});
it("detach removes listeners", async () => {
service.start();
service.detach(store as unknown as TaskStore);
store.emit("task:moved", createEvent());
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
});