feat(FN-5577): add github-tracking reconciler for deleted archived tasks

Added GitHub tracking reconciliation to sync hidden and deleted archived tasks on engine startup, spanning a new reconcile task listing method in the core store, a reconciler pass in the dashboard, and comprehensive test coverage for both the store listing and the deleted-archived reconciliation log

Fusion-Task-Id: FN-5577

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5577
This commit is contained in:
gsxdsm
2026-05-23 21:11:08 -07:00
parent 13b3f53dea
commit 41727cd3bb
5 changed files with 288 additions and 9 deletions

View File

@@ -22,9 +22,13 @@ vi.mock("../github-auth.js", () => ({
resolveGithubTrackingAuth: (...args: unknown[]) => mockResolveGithubTrackingAuth(...args),
}));
function createStore(tasks: Array<Record<string, unknown>>): TaskStore {
function createStore(options: {
listTasks?: Array<Record<string, unknown>>;
reconcileCandidates?: Array<Record<string, unknown>>;
}): TaskStore {
return {
listTasks: vi.fn().mockResolvedValue(tasks),
listTasks: vi.fn().mockResolvedValue(options.listTasks ?? []),
listTasksForGithubTrackingReconcile: vi.fn().mockResolvedValue(options.reconcileCandidates ?? []),
logEntry: vi.fn().mockResolvedValue(undefined),
getSettings: vi.fn().mockResolvedValue({ githubAuthMode: "token", githubAuthToken: "ghp_test" }),
getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })),
@@ -35,10 +39,11 @@ describe("GitHubTrackingReconciler", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("closes open issues for done tracked tasks", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "open" });
const store = createStore([{ id: "FN-1", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } }]);
const store = createStore({ listTasks: [{ id: "FN-1", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } }] });
const result = await new GitHubTrackingReconciler().reconcile(store);
@@ -49,12 +54,12 @@ describe("GitHubTrackingReconciler", () => {
it("skips closed issues and invalid tracking tasks", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "closed" });
const store = createStore([
const store = createStore({ listTasks: [
{ id: "FN-1", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } },
{ id: "FN-2", status: "done", githubTracking: { enabled: false, issue: { owner: "o", repo: "r", number: 2 } } },
{ id: "FN-3", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "", number: 3 } } },
{ id: "FN-4", status: "todo", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 4 } } },
]);
] });
const result = await new GitHubTrackingReconciler().reconcile(store);
@@ -67,10 +72,10 @@ describe("GitHubTrackingReconciler", () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockRejectedValueOnce(new Error("boom"));
mockGetIssue.mockResolvedValueOnce({ state: "open" });
const store = createStore([
const store = createStore({ listTasks: [
{ id: "FN-1", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } },
{ id: "FN-2", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 2 } } },
]);
] });
const result = await new GitHubTrackingReconciler().reconcile(store);
@@ -81,7 +86,7 @@ describe("GitHubTrackingReconciler", () => {
it("skips and logs when auth is unavailable", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: false, message: "no auth" });
const store = createStore([{ id: "FN-1", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } }]);
const store = createStore({ listTasks: [{ id: "FN-1", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } }] });
const result = await new GitHubTrackingReconciler().reconcile(store);
@@ -107,7 +112,101 @@ describe("GitHubTrackingReconciler", () => {
githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: i + 1 } },
}));
await new GitHubTrackingReconciler().reconcile(createStore(tasks));
await new GitHubTrackingReconciler().reconcile(createStore({ listTasks: tasks }));
expect(maxInFlight).toBeLessThanOrEqual(RECONCILE_CONCURRENCY_LIMIT);
});
describe("reconcileDeletedAndArchived", () => {
it("closes with not_planned for soft-deleted tasks", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "open" });
const store = createStore({ reconcileCandidates: [{ id: "FN-1", deletedAt: "2026-01-01T00:00:00.000Z", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } }] });
const result = await new GitHubTrackingReconciler().reconcileDeletedAndArchived(store);
expect(mockSetIssueState).toHaveBeenCalledWith("o", "r", 1, "closed", "not_planned");
expect(result.closed).toBe(1);
});
it("chooses completed for archived tasks with executionCompletedAt", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "open" });
const store = createStore({ reconcileCandidates: [{ id: "FN-2", column: "archived", executionCompletedAt: "2026-01-01T00:00:00.000Z", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 2 } } }] });
await new GitHubTrackingReconciler().reconcileDeletedAndArchived(store);
expect(mockSetIssueState).toHaveBeenCalledWith("o", "r", 2, "closed", "completed");
});
it("chooses not_planned for archived tasks without executionCompletedAt", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "open" });
const store = createStore({ reconcileCandidates: [{ id: "FN-3", column: "archived", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 3 } } }] });
await new GitHubTrackingReconciler().reconcileDeletedAndArchived(store);
expect(mockSetIssueState).toHaveBeenCalledWith("o", "r", 3, "closed", "not_planned");
});
it("uses deletion reason when task is both deleted and archived", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "open" });
const store = createStore({ reconcileCandidates: [{ id: "FN-4", column: "archived", deletedAt: "2026-01-01T00:00:00.000Z", executionCompletedAt: "2026-01-01T00:00:00.000Z", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 4 } } }] });
await new GitHubTrackingReconciler().reconcileDeletedAndArchived(store);
expect(mockSetIssueState).toHaveBeenCalledWith("o", "r", 4, "closed", "not_planned");
});
it("skips already closed issues and malformed tracking", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "closed" });
const store = createStore({ reconcileCandidates: [
{ id: "FN-5", column: "archived", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 5 } } },
{ id: "FN-6", deletedAt: "2026-01-01T00:00:00.000Z", githubTracking: { enabled: false, issue: { owner: "o", repo: "r", number: 6 } } },
{ id: "FN-7", deletedAt: "2026-01-01T00:00:00.000Z", githubTracking: { enabled: true, issue: { owner: "o", repo: "", number: 7 } } },
] });
const result = await new GitHubTrackingReconciler().reconcileDeletedAndArchived(store);
expect(result.skipped).toBe(3);
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("logs task errors and continues", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockRejectedValueOnce(new Error("boom"));
mockGetIssue.mockResolvedValueOnce({ state: "open" });
const store = createStore({ reconcileCandidates: [
{ id: "FN-8", deletedAt: "2026-01-01T00:00:00.000Z", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 8 } } },
{ id: "FN-9", deletedAt: "2026-01-01T00:00:00.000Z", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 9 } } },
] });
const result = await new GitHubTrackingReconciler().reconcileDeletedAndArchived(store);
expect(result.errors).toBe(1);
expect(result.closed).toBe(1);
expect((store.logEntry as any)).toHaveBeenCalledWith(
"FN-8",
"Failed to reconcile GitHub tracking issue (deleted/archived pass)",
"boom",
);
});
it("counts all as skipped when auth is unavailable", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: false, message: "no auth" });
const store = createStore({ reconcileCandidates: [{ id: "FN-10", deletedAt: "2026-01-01T00:00:00.000Z", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 10 } } }] });
const result = await new GitHubTrackingReconciler().reconcileDeletedAndArchived(store);
expect(result.skipped).toBe(1);
expect((store.logEntry as any)).toHaveBeenCalledWith(
"FN-10",
"Skipped GitHub tracking issue reconciliation (deleted/archived pass)",
"no auth",
);
expect(mockGetIssue).not.toHaveBeenCalled();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
});
});

View File

@@ -58,6 +58,65 @@ export class GitHubTrackingReconciler {
return { scanned: tasks.length, closed, skipped, errors };
}
async reconcileDeletedAndArchived(store: TaskStore): Promise<{ scanned: number; closed: number; skipped: number; errors: number }> {
const listedTasks = await store.listTasksForGithubTrackingReconcile();
const tasks = (Array.isArray(listedTasks) ? listedTasks : []).slice(0, RECONCILE_SCAN_LIMIT);
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) {
for (const task of tasks) {
await store.logEntry(task.id, "Skipped GitHub tracking issue reconciliation (deleted/archived pass)", resolution.message);
}
return { scanned: tasks.length, closed: 0, skipped: tasks.length, errors: 0 };
}
const client = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
let closed = 0;
let skipped = 0;
let errors = 0;
await runWithConcurrencyLimit(tasks, RECONCILE_CONCURRENCY_LIMIT, async (task) => {
const issue = task.githubTracking?.issue;
if (task.githubTracking?.enabled !== true || !issue?.owner || !issue.repo || !issue.number) {
skipped += 1;
return;
}
try {
const linkedIssue = await client.getIssue(issue.owner, issue.repo, issue.number);
if (!linkedIssue || linkedIssue.state === "closed") {
skipped += 1;
return;
}
// Archived entries do not preserve the pre-archive column. FN-5577 uses
// executionCompletedAt as the done-heuristic for archived rows.
const stateReason = task.deletedAt
? "not_planned"
: task.column === "archived" && task.executionCompletedAt
? "completed"
: "not_planned";
await client.setIssueState(issue.owner, issue.repo, issue.number, "closed", stateReason);
closed += 1;
} catch (error) {
errors += 1;
await store.logEntry(
task.id,
"Failed to reconcile GitHub tracking issue (deleted/archived pass)",
error instanceof Error ? error.message : String(error),
);
}
});
return { scanned: tasks.length, closed, skipped, errors };
}
}
async function runWithConcurrencyLimit<T>(items: T[], limit: number, worker: (item: T) => Promise<void>): Promise<void> {

View File

@@ -2362,11 +2362,13 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
reconcileScheduledStores.add(projectStore);
setImmediate(() => {
if (typeof (projectStore as Partial<TaskStore>).listTasks !== "function"
|| typeof (projectStore as Partial<TaskStore>).listTasksForGithubTrackingReconcile !== "function"
|| typeof (projectStore as Partial<TaskStore>).getSettings !== "function"
|| typeof (projectStore as Partial<TaskStore>).logEntry !== "function") {
return;
}
void githubTrackingReconciler.reconcile(projectStore).catch(() => {});
void githubTrackingReconciler.reconcileDeletedAndArchived(projectStore).catch(() => {});
});
}
};