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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
Reference in New Issue
Block a user