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:
@@ -0,0 +1,88 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
import { TaskStore } from "../store.js";
|
||||||
|
|
||||||
|
function makeTmpDir(): string {
|
||||||
|
return mkdtempSync(join(tmpdir(), "kb-store-github-reconcile-test-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TaskStore.listTasksForGithubTrackingReconcile", () => {
|
||||||
|
let rootDir: string;
|
||||||
|
let globalDir: string;
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
rootDir = makeTmpDir();
|
||||||
|
globalDir = makeTmpDir();
|
||||||
|
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||||
|
await store.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
store.close();
|
||||||
|
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
|
await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns soft-deleted and archived tasks with github tracking", async () => {
|
||||||
|
const softDeleted = await store.createTask({ description: "soft deleted" });
|
||||||
|
await store.updateGithubTracking(softDeleted.id, { enabled: true });
|
||||||
|
await store.deleteTask(softDeleted.id);
|
||||||
|
|
||||||
|
const archivedDone = await store.createTask({ description: "archived done" });
|
||||||
|
await store.updateGithubTracking(archivedDone.id, { enabled: true });
|
||||||
|
await store.moveTask(archivedDone.id, "todo");
|
||||||
|
await store.moveTask(archivedDone.id, "in-progress");
|
||||||
|
await store.moveTask(archivedDone.id, "in-review");
|
||||||
|
await store.moveTask(archivedDone.id, "done");
|
||||||
|
await store.archiveTask(archivedDone.id);
|
||||||
|
|
||||||
|
const archivedTodo = await store.createTask({ description: "archived todo" });
|
||||||
|
await store.updateGithubTracking(archivedTodo.id, { enabled: true });
|
||||||
|
await store.moveTask(archivedTodo.id, "todo");
|
||||||
|
await store.moveTask(archivedTodo.id, "in-progress");
|
||||||
|
await store.moveTask(archivedTodo.id, "in-review");
|
||||||
|
await store.moveTask(archivedTodo.id, "done");
|
||||||
|
await store.archiveTask(archivedTodo.id);
|
||||||
|
|
||||||
|
const archivedTodoEntry = (store as unknown as {
|
||||||
|
archiveDb: { get: (id: string) => { executionCompletedAt?: string } | undefined; upsert: (entry: Record<string, unknown>) => void };
|
||||||
|
}).archiveDb.get(archivedTodo.id);
|
||||||
|
if (archivedTodoEntry) {
|
||||||
|
(store as unknown as {
|
||||||
|
archiveDb: { upsert: (entry: Record<string, unknown>) => void };
|
||||||
|
}).archiveDb.upsert({ ...archivedTodoEntry, executionCompletedAt: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTracked = await store.createTask({ description: "active tracked" });
|
||||||
|
await store.updateGithubTracking(activeTracked.id, { enabled: true });
|
||||||
|
|
||||||
|
const softDeletedWithoutTracking = await store.createTask({ description: "soft deleted no tracking" });
|
||||||
|
await store.deleteTask(softDeletedWithoutTracking.id);
|
||||||
|
|
||||||
|
const tasks = await store.listTasksForGithubTrackingReconcile();
|
||||||
|
const byId = new Map(tasks.map((task) => [task.id, task]));
|
||||||
|
|
||||||
|
expect(byId.has(softDeleted.id)).toBe(true);
|
||||||
|
expect(byId.has(archivedDone.id)).toBe(true);
|
||||||
|
expect(byId.has(archivedTodo.id)).toBe(true);
|
||||||
|
|
||||||
|
expect(byId.get(archivedDone.id)?.executionCompletedAt).toBeTruthy();
|
||||||
|
expect(byId.get(archivedTodo.id)?.executionCompletedAt).toBeFalsy();
|
||||||
|
|
||||||
|
expect(byId.has(activeTracked.id)).toBe(false);
|
||||||
|
expect(byId.has(softDeletedWithoutTracking.id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty results when nothing matches", async () => {
|
||||||
|
const task = await store.createTask({ description: "no tracking" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
|
||||||
|
const tasks = await store.listTasksForGithubTrackingReconcile();
|
||||||
|
expect(tasks).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4282,6 +4282,37 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
return sorted.slice(offset, offset + Math.max(0, limit));
|
return sorted.slice(offset, offset + Math.max(0, limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listTasksForGithubTrackingReconcile(): Promise<Task[]> {
|
||||||
|
const reconcileScanLimit = 200;
|
||||||
|
const selectClause = this.getTaskSelectClause(true);
|
||||||
|
|
||||||
|
// FN-5577: GitHub tracking reconciliation must inspect soft-deleted rows,
|
||||||
|
// so this query intentionally bypasses ACTIVE_TASKS_WHERE.
|
||||||
|
const deletedRows = this.db.prepare(
|
||||||
|
`SELECT ${selectClause} FROM tasks WHERE "deletedAt" IS NOT NULL AND "githubTracking" IS NOT NULL ORDER BY updatedAt ASC LIMIT ?`,
|
||||||
|
).all(reconcileScanLimit) as unknown as TaskRow[];
|
||||||
|
|
||||||
|
const deletedTasks = deletedRows.map((row) => {
|
||||||
|
const task = this.rowToTask(row);
|
||||||
|
task.timedExecutionMs = this.computeTimedExecutionMs(task.log);
|
||||||
|
task.log = [];
|
||||||
|
return task;
|
||||||
|
});
|
||||||
|
|
||||||
|
let archivedTasks: Task[] = [];
|
||||||
|
try {
|
||||||
|
archivedTasks = this.archiveDb
|
||||||
|
.list()
|
||||||
|
.map((entry) => this.archiveEntryToTask(entry, true))
|
||||||
|
.filter((task) => Boolean(task.githubTracking))
|
||||||
|
.slice(0, reconcileScanLimit);
|
||||||
|
} catch {
|
||||||
|
archivedTasks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...deletedTasks, ...archivedTasks].slice(0, reconcileScanLimit);
|
||||||
|
}
|
||||||
|
|
||||||
async listStrandedRefinements(options?: {
|
async listStrandedRefinements(options?: {
|
||||||
freshnessThresholdMs?: number;
|
freshnessThresholdMs?: number;
|
||||||
}): Promise<Array<{
|
}): Promise<Array<{
|
||||||
|
|||||||
@@ -22,9 +22,13 @@ vi.mock("../github-auth.js", () => ({
|
|||||||
resolveGithubTrackingAuth: (...args: unknown[]) => mockResolveGithubTrackingAuth(...args),
|
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 {
|
return {
|
||||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
listTasks: vi.fn().mockResolvedValue(options.listTasks ?? []),
|
||||||
|
listTasksForGithubTrackingReconcile: vi.fn().mockResolvedValue(options.reconcileCandidates ?? []),
|
||||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
getSettings: vi.fn().mockResolvedValue({ githubAuthMode: "token", githubAuthToken: "ghp_test" }),
|
getSettings: vi.fn().mockResolvedValue({ githubAuthMode: "token", githubAuthToken: "ghp_test" }),
|
||||||
getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })),
|
getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })),
|
||||||
@@ -35,10 +39,11 @@ describe("GitHubTrackingReconciler", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("closes open issues for done tracked tasks", async () => {
|
it("closes open issues for done tracked tasks", async () => {
|
||||||
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
||||||
mockGetIssue.mockResolvedValue({ state: "open" });
|
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);
|
const result = await new GitHubTrackingReconciler().reconcile(store);
|
||||||
|
|
||||||
@@ -49,12 +54,12 @@ describe("GitHubTrackingReconciler", () => {
|
|||||||
it("skips closed issues and invalid tracking tasks", async () => {
|
it("skips closed issues and invalid tracking tasks", async () => {
|
||||||
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
||||||
mockGetIssue.mockResolvedValue({ state: "closed" });
|
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-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-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-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 } } },
|
{ id: "FN-4", status: "todo", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 4 } } },
|
||||||
]);
|
] });
|
||||||
|
|
||||||
const result = await new GitHubTrackingReconciler().reconcile(store);
|
const result = await new GitHubTrackingReconciler().reconcile(store);
|
||||||
|
|
||||||
@@ -67,10 +72,10 @@ describe("GitHubTrackingReconciler", () => {
|
|||||||
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
||||||
mockGetIssue.mockRejectedValueOnce(new Error("boom"));
|
mockGetIssue.mockRejectedValueOnce(new Error("boom"));
|
||||||
mockGetIssue.mockResolvedValueOnce({ state: "open" });
|
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-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 } } },
|
{ id: "FN-2", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 2 } } },
|
||||||
]);
|
] });
|
||||||
|
|
||||||
const result = await new GitHubTrackingReconciler().reconcile(store);
|
const result = await new GitHubTrackingReconciler().reconcile(store);
|
||||||
|
|
||||||
@@ -81,7 +86,7 @@ describe("GitHubTrackingReconciler", () => {
|
|||||||
|
|
||||||
it("skips and logs when auth is unavailable", async () => {
|
it("skips and logs when auth is unavailable", async () => {
|
||||||
mockResolveGithubTrackingAuth.mockReturnValue({ ok: false, message: "no auth" });
|
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);
|
const result = await new GitHubTrackingReconciler().reconcile(store);
|
||||||
|
|
||||||
@@ -107,7 +112,101 @@ describe("GitHubTrackingReconciler", () => {
|
|||||||
githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: i + 1 } },
|
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);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,6 +58,65 @@ export class GitHubTrackingReconciler {
|
|||||||
|
|
||||||
return { scanned: tasks.length, closed, skipped, errors };
|
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> {
|
async function runWithConcurrencyLimit<T>(items: T[], limit: number, worker: (item: T) => Promise<void>): Promise<void> {
|
||||||
|
|||||||
@@ -2362,11 +2362,13 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
reconcileScheduledStores.add(projectStore);
|
reconcileScheduledStores.add(projectStore);
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
if (typeof (projectStore as Partial<TaskStore>).listTasks !== "function"
|
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>).getSettings !== "function"
|
||||||
|| typeof (projectStore as Partial<TaskStore>).logEntry !== "function") {
|
|| typeof (projectStore as Partial<TaskStore>).logEntry !== "function") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void githubTrackingReconciler.reconcile(projectStore).catch(() => {});
|
void githubTrackingReconciler.reconcile(projectStore).catch(() => {});
|
||||||
|
void githubTrackingReconciler.reconcileDeletedAndArchived(projectStore).catch(() => {});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user