FN-5792: fix GitHub auto-close reconciliation for soft-deleted tasks

Ensure GitHub issue auto-close reconciliation still processes soft-deleted and archived tracked tasks after restart.

- Add pagination-aware reconcile scanning in core store with combined deleted+archived coverage and hasMore metadata.
- Update dashboard GitHub tracking reconciler and route handling to consume paged reconcile batches safely.
- Expand reconcile tests (core + dashboard) with restart/periodic sweep and source-issue scenarios to lock behavior.
- Add release changeset and dashboard guide note for the reconciliation sweep behavior.

Files changed:
 .changeset/fn-5792-github-tracking-sweep.md        |   5 +
 docs/dashboard-guide.md                            |   1 +
 .../store-github-tracking-reconcile.test.ts        |  45 ++++++++-
 packages/core/src/store.ts                         |  32 ++++--
 .../github-source-issue-reconciler.test.ts         |  22 ++--
 ...ithub-tracking-periodic-reconcile-sweep.test.ts |  90 +++++++++++++++++
 .../__tests__/github-tracking-reconciler.test.ts   | 111 +++++----------------
 .../dashboard/src/github-tracking-reconciler.ts    |  19 ++--
 .../dashboard/src/routes/register-git-github.ts    |  57 +++++++++--
 9 files changed, 257 insertions(+), 125 deletions(-)

Fusion-Task-Id: FN-5792

Fusion-Task-Lineage: 259f07cf-3470-47c7-b47a-86d212d44d52
This commit is contained in:
gsxdsm
2026-05-31 17:23:26 -07:00
parent 716f3964c5
commit ffadb0c77f
9 changed files with 257 additions and 125 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix GitHub tracking reconciliation for soft-deleted and archived tasks by adding a periodic 15-minute sweep, paginating archive/deleted candidate scans, and correcting done-task filtering to use the task column.

View File

@@ -587,6 +587,7 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou
- Task metadata also shows compact `Created` / `Updated` timestamps: recent values render as relative time (`just now`, `Xm`, `Xh`, `Xd`) and older values switch to short month/day dates; these stay grouped on one row across desktop and mobile widths for a compact metadata layout.
- Eligible existing tasks (triage, todo, in-progress, in-review) expose a **GitHub tracking** section directly in Task Detail, even when tracking is currently disabled.
- The GitHub tracking section now defaults to a compact summary row; use the disclosure arrow to expand linked-issue details plus tracking edit controls.
- Backstop reconciliation runs every 15 minutes to close tracked GitHub issues for soft-deleted and archived tasks even after restart; the sweep is paginated so large archive backlogs are eventually drained.
- In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**.
- From this section you can explicitly enable/disable tracking and manage a per-task repo override (`owner/repo`). Clearing the override saves `null` and falls back to project/global defaults.
- In `in-review`, pull-request controls/status (including stall badges) are in a dedicated **Pull Request** tab instead of the Definition tab.

View File

@@ -64,7 +64,7 @@ describe("TaskStore.listTasksForGithubTrackingReconcile", () => {
const softDeletedWithoutTracking = await store.createTask({ description: "soft deleted no tracking" });
await store.deleteTask(softDeletedWithoutTracking.id);
const tasks = await store.listTasksForGithubTrackingReconcile();
const { tasks, hasMore } = await store.listTasksForGithubTrackingReconcile();
const byId = new Map(tasks.map((task) => [task.id, task]));
expect(byId.has(softDeleted.id)).toBe(true);
@@ -76,13 +76,52 @@ describe("TaskStore.listTasksForGithubTrackingReconcile", () => {
expect(byId.has(activeTracked.id)).toBe(false);
expect(byId.has(softDeletedWithoutTracking.id)).toBe(false);
expect(hasMore).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([]);
const result = await store.listTasksForGithubTrackingReconcile();
expect(result).toEqual({ tasks: [], hasMore: false });
});
it("paginates across soft-deleted and archived tracked entries", async () => {
for (let i = 0; i < 3; i += 1) {
const task = await store.createTask({ description: `deleted ${i}` });
await store.updateGithubTracking(task.id, { enabled: true });
await store.deleteTask(task.id);
}
for (let i = 0; i < 3; i += 1) {
const task = await store.createTask({ description: `archived ${i}` });
await store.updateGithubTracking(task.id, { enabled: true });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
}
const page1 = await store.listTasksForGithubTrackingReconcile({ offset: 0, limit: 2 });
const page2 = await store.listTasksForGithubTrackingReconcile({ offset: 2, limit: 2 });
const page3 = await store.listTasksForGithubTrackingReconcile({ offset: 4, limit: 2 });
expect(page1.tasks).toHaveLength(2);
expect(page2.tasks).toHaveLength(2);
expect(page3.tasks).toHaveLength(2);
const seen = new Set([...page1.tasks, ...page2.tasks, ...page3.tasks].map((task) => task.id));
expect(seen.size).toBe(6);
expect(page1.hasMore).toBe(true);
expect(page2.hasMore).toBe(true);
expect(page3.hasMore).toBe(false);
const activeTracked = await store.createTask({ description: "active tracked no reconcile" });
await store.updateGithubTracking(activeTracked.id, { enabled: true });
const finalPage = await store.listTasksForGithubTrackingReconcile({ offset: 0, limit: 20 });
expect(finalPage.tasks.some((task) => task.id === activeTracked.id)).toBe(false);
});
});

View File

@@ -4653,15 +4653,23 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return sorted.slice(offset, offset + Math.max(0, limit));
}
async listTasksForGithubTrackingReconcile(): Promise<Task[]> {
async listTasksForGithubTrackingReconcile(options?: { offset?: number; limit?: number }): Promise<{ tasks: Task[]; hasMore: boolean }> {
const reconcileScanLimit = 200;
const offset = Math.max(0, options?.offset ?? 0);
const limit = Math.max(0, options?.limit ?? reconcileScanLimit);
const selectClause = this.getTaskSelectClause(true);
// FN-5577: GitHub tracking reconciliation must inspect soft-deleted rows,
// so this query intentionally bypasses ACTIVE_TASKS_WHERE.
const deletedTotal = this.db.prepare(
"SELECT COUNT(*) as count FROM tasks WHERE \"deletedAt\" IS NOT NULL AND \"githubTracking\" IS NOT NULL",
).get() as { count: number } | undefined;
const deletedCount = Number(deletedTotal?.count ?? 0);
const deletedOffset = Math.min(offset, deletedCount);
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[];
`SELECT ${selectClause} FROM tasks WHERE "deletedAt" IS NOT NULL AND "githubTracking" IS NOT NULL ORDER BY updatedAt ASC LIMIT ? OFFSET ?`,
).all(limit, deletedOffset) as unknown as TaskRow[];
const deletedTasks = deletedRows.map((row) => {
const task = this.rowToTask(row);
@@ -4671,17 +4679,27 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
let archivedTasks: Task[] = [];
let archivedCount = 0;
try {
archivedTasks = this.archiveDb
const archivedCandidates = this.archiveDb
.list()
.map((entry) => this.archiveEntryToTask(entry, true))
.filter((task) => Boolean(task.githubTracking))
.slice(0, reconcileScanLimit);
.filter((task) => Boolean(task.githubTracking));
archivedCount = archivedCandidates.length;
const archivedOffset = Math.max(0, offset - deletedCount);
const remainingLimit = Math.max(0, limit - deletedTasks.length);
archivedTasks = remainingLimit > 0
? archivedCandidates.slice(archivedOffset, archivedOffset + remainingLimit)
: [];
} catch {
archivedTasks = [];
archivedCount = 0;
}
return [...deletedTasks, ...archivedTasks].slice(0, reconcileScanLimit);
const totalCount = deletedCount + archivedCount;
const hasMore = offset + limit < totalCount;
return { tasks: [...deletedTasks, ...archivedTasks], hasMore };
}
async listStrandedRefinements(options?: {

View File

@@ -25,7 +25,7 @@ vi.mock("../github-auth.js", () => ({
function createStore(listTasks: Array<Record<string, unknown>>, settings: Record<string, unknown> = { githubCloseSourceIssueOnDone: true, githubAuthMode: "token", githubAuthToken: "ghp_test" }): TaskStore {
return {
listTasks: vi.fn().mockResolvedValue(listTasks),
listTasksForGithubTrackingReconcile: vi.fn().mockResolvedValue([]),
listTasksForGithubTrackingReconcile: vi.fn().mockResolvedValue({ tasks: [], hasMore: false }),
getSettings: vi.fn().mockResolvedValue(settings),
getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })),
logEntry: vi.fn().mockResolvedValue(undefined),
@@ -40,14 +40,14 @@ describe("GitHubTrackingReconciler.reconcileSourceIssues", () => {
});
it("short-circuits when setting disabled", async () => {
const store = createStore([{ id: "FN-1", status: "done", sourceIssue: { provider: "github", repository: "o/r", issueNumber: 1 } }], { githubCloseSourceIssueOnDone: false });
const store = createStore([{ id: "FN-1", column: "done", sourceIssue: { provider: "github", repository: "o/r", issueNumber: 1 } }], { githubCloseSourceIssueOnDone: false });
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(result).toEqual({ scanned: 1, closed: 0, skipped: 1, errors: 0 });
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("closes open source issues", async () => {
const store = createStore([{ id: "FN-1", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 4 } }]);
const store = createStore([{ id: "FN-1", column: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 4 } }]);
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 4, "closed", "completed");
expect(result.closed).toBe(1);
@@ -55,7 +55,7 @@ describe("GitHubTrackingReconciler.reconcileSourceIssues", () => {
it("skips already-closed source issues", async () => {
mockGetIssue.mockResolvedValueOnce({ state: "closed" });
const store = createStore([{ id: "FN-1", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 4 } }]);
const store = createStore([{ id: "FN-1", column: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 4 } }]);
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(result.skipped).toBe(1);
expect(mockSetIssueState).not.toHaveBeenCalled();
@@ -63,7 +63,7 @@ describe("GitHubTrackingReconciler.reconcileSourceIssues", () => {
it("skips source issues missing from GitHub", async () => {
mockGetIssue.mockResolvedValueOnce(null);
const store = createStore([{ id: "FN-12", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 12 } }]);
const store = createStore([{ id: "FN-12", column: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 12 } }]);
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(result.skipped).toBe(1);
expect(result.errors).toBe(0);
@@ -72,9 +72,9 @@ describe("GitHubTrackingReconciler.reconcileSourceIssues", () => {
it("ignores non-done tasks and tasks without sourceIssue", async () => {
const store = createStore([
{ id: "FN-1", status: "todo", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 1 } },
{ id: "FN-2", status: "done" },
{ id: "FN-3", status: "done", sourceIssue: { provider: "jira", repository: "x/y", issueNumber: 3 } },
{ id: "FN-1", column: "todo", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 1 } },
{ id: "FN-2", column: "done" },
{ id: "FN-3", column: "done", sourceIssue: { provider: "jira", repository: "x/y", issueNumber: 3 } },
]);
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(result).toEqual({ scanned: 0, closed: 0, skipped: 0, errors: 0 });
@@ -83,7 +83,7 @@ describe("GitHubTrackingReconciler.reconcileSourceIssues", () => {
it("counts errors and logs on getIssue failure", async () => {
mockGetIssue.mockRejectedValueOnce(new Error("boom"));
const store = createStore([{ id: "FN-9", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 9 } }]);
const store = createStore([{ id: "FN-9", column: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 9 } }]);
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(result.errors).toBe(1);
expect((store.logEntry as any)).toHaveBeenCalledWith("FN-9", "Failed to reconcile GitHub source issue", "boom");
@@ -91,7 +91,7 @@ describe("GitHubTrackingReconciler.reconcileSourceIssues", () => {
it("counts errors and logs on setIssueState failure", async () => {
mockSetIssueState.mockRejectedValueOnce(new Error("write failed"));
const store = createStore([{ id: "FN-10", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 10 } }]);
const store = createStore([{ id: "FN-10", column: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 10 } }]);
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(result.errors).toBe(1);
expect((store.logEntry as any)).toHaveBeenCalledWith("FN-10", "Failed to reconcile GitHub source issue", "write failed");
@@ -99,7 +99,7 @@ describe("GitHubTrackingReconciler.reconcileSourceIssues", () => {
it("skips and logs when auth resolution fails", async () => {
mockResolveGithubTrackingAuth.mockReturnValueOnce({ ok: false, message: "no auth" });
const store = createStore([{ id: "FN-11", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 11 } }]);
const store = createStore([{ id: "FN-11", column: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 11 } }]);
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
expect(result.skipped).toBe(1);
expect((store.logEntry as any)).toHaveBeenCalledWith("FN-11", "Skipped GitHub source issue reconciliation", "no auth");

View File

@@ -0,0 +1,90 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
import { registerGitGitHubRoutes, GITHUB_TRACKING_RECONCILE_INTERVAL_MS } from "../routes/register-git-github.js";
const reconcile = vi.fn().mockResolvedValue({ scanned: 0, closed: 0, skipped: 0, errors: 0 });
const reconcileDeletedAndArchived = vi.fn();
const reconcileSourceIssues = vi.fn().mockResolvedValue({ scanned: 0, closed: 0, skipped: 0, errors: 0 });
vi.mock("../github-tracking-reconciler.js", () => ({
RECONCILE_SCAN_LIMIT: 200,
GitHubTrackingReconciler: vi.fn().mockImplementation(() => ({
reconcile,
reconcileDeletedAndArchived,
reconcileSourceIssues,
})),
}));
vi.mock("../github-issue-comment.js", () => ({
GitHubIssueCommentService: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })),
}));
vi.mock("../github-tracking-comments.js", () => ({
GitHubTrackingCommentService: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })),
}));
vi.mock("../github-source-issue-close.js", () => ({
GitHubSourceIssueCloseService: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn(), attach: vi.fn(), detach: vi.fn() })),
}));
vi.mock("../github-tracking-state.js", () => ({
GitHubTrackingStateService: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn(), attach: vi.fn(), detach: vi.fn() })),
}));
function createStore(): TaskStore {
return {
on: vi.fn(),
off: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
listTasksForGithubTrackingReconcile: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
logEntry: vi.fn().mockResolvedValue(undefined),
} as unknown as TaskStore;
}
describe("GitHub tracking periodic reconcile sweep", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it("runs startup and periodic sweeps with paged offsets and clears interval on dispose", async () => {
const store = createStore();
const disposers: Array<() => void> = [];
reconcileDeletedAndArchived
.mockResolvedValueOnce({ scanned: 200, closed: 0, skipped: 0, errors: 0, hasMore: true })
.mockResolvedValueOnce({ scanned: 200, closed: 0, skipped: 0, errors: 0, hasMore: true })
.mockResolvedValueOnce({ scanned: 10, closed: 0, skipped: 0, errors: 0, hasMore: false });
registerGitGitHubRoutes({
router: { get: vi.fn(), post: vi.fn(), delete: vi.fn() },
getProjectContext: vi.fn(),
rethrowAsApiError: vi.fn(),
store,
registerDispose: (fn: () => void) => disposers.push(fn),
options: {},
} as any);
await vi.runAllTimersAsync();
expect(reconcileDeletedAndArchived).toHaveBeenNthCalledWith(1, store, { offset: 0, limit: 200 });
await vi.advanceTimersByTimeAsync(GITHUB_TRACKING_RECONCILE_INTERVAL_MS);
expect(reconcileDeletedAndArchived).toHaveBeenNthCalledWith(2, store, { offset: 200, limit: 200 });
await vi.advanceTimersByTimeAsync(GITHUB_TRACKING_RECONCILE_INTERVAL_MS);
expect(reconcileDeletedAndArchived).toHaveBeenNthCalledWith(3, store, { offset: 400, limit: 200 });
await vi.advanceTimersByTimeAsync(GITHUB_TRACKING_RECONCILE_INTERVAL_MS);
expect(reconcileDeletedAndArchived).toHaveBeenNthCalledWith(4, store, { offset: 0, limit: 200 });
for (const dispose of disposers) {
dispose();
}
const callsAfterDispose = reconcileDeletedAndArchived.mock.calls.length;
await vi.advanceTimersByTimeAsync(GITHUB_TRACKING_RECONCILE_INTERVAL_MS);
expect(reconcileDeletedAndArchived.mock.calls.length).toBe(callsAfterDispose);
});
});

View File

@@ -25,10 +25,13 @@ vi.mock("../github-auth.js", () => ({
function createStore(options: {
listTasks?: Array<Record<string, unknown>>;
reconcileCandidates?: Array<Record<string, unknown>>;
reconcileHasMore?: boolean;
}): TaskStore {
return {
listTasks: vi.fn().mockResolvedValue(options.listTasks ?? []),
listTasksForGithubTrackingReconcile: vi.fn().mockResolvedValue(options.reconcileCandidates ?? []),
listTasksForGithubTrackingReconcile: vi
.fn()
.mockResolvedValue({ tasks: options.reconcileCandidates ?? [], hasMore: options.reconcileHasMore ?? false }),
logEntry: vi.fn().mockResolvedValue(undefined),
getSettings: vi.fn().mockResolvedValue({ githubAuthMode: "token", githubAuthToken: "ghp_test" }),
getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })),
@@ -40,10 +43,10 @@ describe("GitHubTrackingReconciler", () => {
vi.clearAllMocks();
});
it("closes open issues for done tracked tasks", async () => {
it("closes open issues for done-column tracked tasks", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "open" });
const store = createStore({ listTasks: [{ id: "FN-1", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } }] });
const store = createStore({ listTasks: [{ id: "FN-1", column: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } }] });
const result = await new GitHubTrackingReconciler().reconcile(store);
@@ -55,10 +58,10 @@ describe("GitHubTrackingReconciler", () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
mockGetIssue.mockResolvedValue({ state: "closed" });
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 } } },
{ id: "FN-1", column: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } },
{ id: "FN-2", column: "done", githubTracking: { enabled: false, issue: { owner: "o", repo: "r", number: 2 } } },
{ id: "FN-3", column: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "", number: 3 } } },
{ id: "FN-4", column: "todo", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 4 } } },
] });
const result = await new GitHubTrackingReconciler().reconcile(store);
@@ -73,8 +76,8 @@ describe("GitHubTrackingReconciler", () => {
mockGetIssue.mockRejectedValueOnce(new Error("boom"));
mockGetIssue.mockResolvedValueOnce({ state: "open" });
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 } } },
{ id: "FN-1", column: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } },
{ id: "FN-2", column: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 2 } } },
] });
const result = await new GitHubTrackingReconciler().reconcile(store);
@@ -86,7 +89,7 @@ describe("GitHubTrackingReconciler", () => {
it("skips and logs when auth is unavailable", async () => {
mockResolveGithubTrackingAuth.mockReturnValue({ ok: false, message: "no auth" });
const store = createStore({ listTasks: [{ id: "FN-1", status: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } }] });
const store = createStore({ listTasks: [{ id: "FN-1", column: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } }] });
const result = await new GitHubTrackingReconciler().reconcile(store);
@@ -108,7 +111,7 @@ describe("GitHubTrackingReconciler", () => {
const tasks = Array.from({ length: 10 }, (_, i) => ({
id: `FN-${i + 1}`,
status: "done",
column: "done",
githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: i + 1 } },
}));
@@ -122,91 +125,25 @@ describe("GitHubTrackingReconciler", () => {
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);
const result = await new GitHubTrackingReconciler().reconcileDeletedAndArchived(store, { offset: 0, limit: 10 });
expect(mockSetIssueState).toHaveBeenCalledWith("o", "r", 1, "closed", "not_planned");
expect(result.closed).toBe(1);
expect(result.hasMore).toBe(false);
expect((store.listTasksForGithubTrackingReconcile as any)).toHaveBeenCalledWith({ offset: 0, limit: 10 });
});
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 () => {
it("returns hasMore from store paging", 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);
const store = createStore({
reconcileCandidates: [{ id: "FN-5", column: "archived", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 5 } } }],
reconcileHasMore: true,
});
const result = await new GitHubTrackingReconciler().reconcileDeletedAndArchived(store, { offset: 200, limit: 200 });
expect(result.hasMore).toBe(true);
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

@@ -9,7 +9,7 @@ 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 tasks = (Array.isArray(listedTasks) ? listedTasks : [])
.filter((task) => task.status === "done")
.filter((task) => task.column === "done")
.slice(0, RECONCILE_SCAN_LIMIT);
const projectSettings = ((await store.getSettings()) ?? {}) as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
@@ -62,7 +62,7 @@ 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 tasks = (Array.isArray(listedTasks) ? listedTasks : [])
.filter((task) => task.status === "done" && task.sourceIssue?.provider === "github")
.filter((task) => task.column === "done" && task.sourceIssue?.provider === "github")
.slice(0, RECONCILE_SCAN_LIMIT);
const projectSettings = ((await store.getSettings()) ?? {}) as Pick<ProjectSettings, "githubCloseSourceIssueOnDone" | "githubAuthMode" | "githubAuthToken">;
@@ -120,9 +120,14 @@ 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);
async reconcileDeletedAndArchived(
store: TaskStore,
options?: { offset?: number; limit?: number },
): Promise<{ scanned: number; closed: number; skipped: number; errors: number; hasMore: boolean }> {
// Pagination is authoritative in TaskStore.listTasksForGithubTrackingReconcile.
const listedTasks = await store.listTasksForGithubTrackingReconcile(options);
const tasks = Array.isArray(listedTasks?.tasks) ? listedTasks.tasks : [];
const hasMore = listedTasks?.hasMore === true;
const projectSettings = ((await store.getSettings()) ?? {}) as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
@@ -131,7 +136,7 @@ export class GitHubTrackingReconciler {
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 };
return { scanned: tasks.length, closed: 0, skipped: tasks.length, errors: 0, hasMore };
}
const client = resolution.auth.mode === "token"
@@ -176,7 +181,7 @@ export class GitHubTrackingReconciler {
}
});
return { scanned: tasks.length, closed, skipped, errors };
return { scanned: tasks.length, closed, skipped, errors, hasMore };
}
}

View File

@@ -41,7 +41,7 @@ import { GitHubClient, type PrReviewSnapshot, parseBadgeUrl } from "../github.js
import { GitHubIssueCommentService } from "../github-issue-comment.js";
import { GitHubTrackingCommentService } from "../github-tracking-comments.js";
import { GitHubTrackingStateService } from "../github-tracking-state.js";
import { GitHubTrackingReconciler } from "../github-tracking-reconciler.js";
import { GitHubTrackingReconciler, RECONCILE_SCAN_LIMIT } from "../github-tracking-reconciler.js";
import { GitHubSourceIssueCloseService } from "../github-source-issue-close.js";
import { githubRateLimiter } from "../github-poll.js";
import * as projectStoreResolver from "../project-store-resolver.js";
@@ -61,6 +61,7 @@ const PR_ROUTE_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
const PR_PREFLIGHT_TIMEOUT_MS = 15_000;
const PR_OPTIONS_TIMEOUT_MS = 10_000;
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/;
export const GITHUB_TRACKING_RECONCILE_INTERVAL_MS = 15 * 60 * 1000;
function getCommandErrorMessage(error: unknown): string {
if (error instanceof Error) {
@@ -2353,8 +2354,45 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const githubTrackingStateService = new GitHubTrackingStateService(store);
const githubTrackingReconciler = new GitHubTrackingReconciler();
const reconcileScheduledStores = new WeakSet<TaskStore>();
const reconcileSweepOffsetByStore = new WeakMap<TaskStore, number>();
const reconcileSweepInFlightByStore = new WeakMap<TaskStore, boolean>();
githubTrackingStateService.start();
const runReconcileSweep = async (projectStore: TaskStore, options?: { startup?: boolean }) => {
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;
}
if (reconcileSweepInFlightByStore.get(projectStore) === true) {
return;
}
reconcileSweepInFlightByStore.set(projectStore, true);
try {
const offset = options?.startup ? 0 : reconcileSweepOffsetByStore.get(projectStore) ?? 0;
const deletedArchivedResult = await githubTrackingReconciler.reconcileDeletedAndArchived(projectStore, {
offset,
limit: RECONCILE_SCAN_LIMIT,
});
if (deletedArchivedResult.hasMore) {
reconcileSweepOffsetByStore.set(projectStore, offset + RECONCILE_SCAN_LIMIT);
} else {
reconcileSweepOffsetByStore.set(projectStore, 0);
}
await githubTrackingReconciler.reconcile(projectStore);
await githubTrackingReconciler.reconcileSourceIssues(projectStore);
} catch {
// best-effort sweep
} finally {
reconcileSweepInFlightByStore.set(projectStore, false);
}
};
const attachedStateStores = new Set<TaskStore>();
const attachStateStore = (projectStore: TaskStore) => {
if (attachedStateStores.has(projectStore)) {
@@ -2367,15 +2405,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
if (!reconcileScheduledStores.has(projectStore)) {
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(() => {});
void githubTrackingReconciler.reconcileSourceIssues(projectStore).catch(() => {});
void runReconcileSweep(projectStore, { startup: true });
});
}
};
@@ -2415,7 +2445,14 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
attachStateStore(projectStore);
});
const periodicReconcileInterval = setInterval(() => {
for (const projectStore of attachedStateStores) {
void runReconcileSweep(projectStore);
}
}, GITHUB_TRACKING_RECONCILE_INTERVAL_MS);
ctx.registerDispose(() => {
clearInterval(periodicReconcileInterval);
unsubscribeProjectStoreRegistration();
for (const projectStore of attachedStateStores) {
githubTrackingStateService.detach(projectStore);