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:
5
.changeset/fn-5792-github-tracking-sweep.md
Normal file
5
.changeset/fn-5792-github-tracking-sweep.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user