From b1c7a32419a3be092e42096b21bf9892a33574bc Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Wed, 27 May 2026 10:32:30 -0600 Subject: [PATCH] feat: add closed issue reconciliation to scheduled sync Add targeted reconciliation for cached issues with active status labels (status/ready, status/in-progress, status/in-review) that are actually closed on GitHub. During scheduled sync, after the regular open-issue sync, query cached issues with active statuses, check their real state on GitHub, and mark them as status/done if closed. This prevents stale board state where closed GitHub issues remain claimable in Dispatch because their cached labels still show an active status. The reconciliation is bounded to only cache-matched issues with active labels, not a full historical crawl of all closed issues. #Fixes #233 --- src/app/api/sync/scheduled/route.test.ts | 164 +++++++++++++++++++++++ src/app/api/sync/scheduled/route.ts | 59 +++++++- src/lib/issue-sync.ts | 116 ++++++++++++++++ 3 files changed, 337 insertions(+), 2 deletions(-) diff --git a/src/app/api/sync/scheduled/route.test.ts b/src/app/api/sync/scheduled/route.test.ts index a5b5b61..f6b4b61 100644 --- a/src/app/api/sync/scheduled/route.test.ts +++ b/src/app/api/sync/scheduled/route.test.ts @@ -57,6 +57,7 @@ function createPrismaMock() { findUnique: vi.fn(), update: vi.fn().mockResolvedValue(undefined), create: vi.fn().mockResolvedValue(undefined), + findMany: vi.fn().mockResolvedValue([]), }, $transaction: vi.fn(async (fn: (tx: any) => Promise) => { const tx = { @@ -81,6 +82,12 @@ function createPrismaMock() { function createGithubMock() { return { fetchIssues: vi.fn().mockResolvedValue([]), + fetchIssue: vi.fn().mockResolvedValue({ + number: 1, + state: "open", + labels: [], + closed_at: null, + }), fetchRepo: vi.fn().mockResolvedValue({ name: "test-repo", owner: { login: "test-owner" }, @@ -403,9 +410,166 @@ describe("POST /api/sync/scheduled — sync behavior", () => { it("includes startedAt and finishedAt in response", async () => { const { POST } = await import("./route"); + prismaMock.prisma.syncLock.findUnique.mockResolvedValue(null); + const res = await POST(makeRequest()); + expect(res.status).toBe(200); const body = await res.json(); expect(body.startedAt).toBeDefined(); expect(body.finishedAt).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Closed issue reconciliation tests +// --------------------------------------------------------------------------- + +describe("POST /api/sync/scheduled — closed issue reconciliation", () => { + let prismaMock: ReturnType; + let githubMock: ReturnType; + let configMock: ReturnType; + + beforeEach(() => { + vi.resetModules(); + prismaMock = createPrismaMock(); + githubMock = createGithubMock(); + configMock = createConfigMock(); + setupModules(prismaMock, githubMock, configMock); + }); + + it("reconciles a closed GitHub issue cached as status/in-review to status/done", async () => { + const { POST } = await import("./route"); + prismaMock.prisma.syncLock.findUnique.mockResolvedValue(null); + + prismaMock.prisma.issue.findMany.mockResolvedValue([ + { id: "issue-1", number: 42, labels: ["status/in-review", "type/bug"], state: "open" }, + ]); + + githubMock.fetchIssue.mockResolvedValue({ + number: 42, + state: "closed", + closed_at: "2025-01-15T10:00:00Z", + labels: [], + }); + + const res = await POST(makeRequest()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.closedIssueReconcile).toBeDefined(); + expect(body.closedIssueReconcile.issuesReconciled).toBe(1); + expect(prismaMock.prisma.issue.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "issue-1" }, + data: expect.objectContaining({ + state: "closed", + labels: expect.arrayContaining(["status/done"]), + }), + }), + ); + }); + + it("reconciles a closed GitHub issue cached as status/ready to status/done", async () => { + const { POST } = await import("./route"); + prismaMock.prisma.syncLock.findUnique.mockResolvedValue(null); + + prismaMock.prisma.issue.findMany.mockResolvedValue([ + { id: "issue-2", number: 99, labels: ["status/ready", "type/feature"], state: "closed" }, + ]); + + githubMock.fetchIssue.mockResolvedValue({ + number: 99, + state: "closed", + closed_at: null, + labels: [], + }); + + const res = await POST(makeRequest()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.closedIssueReconcile.issuesReconciled).toBe(1); + expect(prismaMock.prisma.issue.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "issue-2" }, + data: expect.objectContaining({ + state: "closed", + labels: expect.arrayContaining(["status/done"]), + }), + }), + ); + }); + + it("leaves open issues that are still open on GitHub", async () => { + const { POST } = await import("./route"); + prismaMock.prisma.syncLock.findUnique.mockResolvedValue(null); + + prismaMock.prisma.issue.findMany.mockResolvedValue([ + { id: "issue-3", number: 10, labels: ["status/in-progress"], state: "open" }, + ]); + + githubMock.fetchIssue.mockResolvedValue({ + number: 10, + state: "open", + labels: ["status/in-progress"], + }); + + const res = await POST(makeRequest()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.closedIssueReconcile.issuesReconciled).toBe(0); + expect(prismaMock.prisma.issue.update).not.toHaveBeenCalled(); + }); + + it("skips issues without active status labels", async () => { + const { POST } = await import("./route"); + prismaMock.prisma.syncLock.findUnique.mockResolvedValue(null); + + prismaMock.prisma.issue.findMany.mockResolvedValue([ + { id: "issue-4", number: 5, labels: ["type/bug"], state: "open" }, + ]); + + const res = await POST(makeRequest()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.closedIssueReconcile.issuesChecked).toBe(0); + }); + + it("preserves agent/* labels during reconciliation", async () => { + const { POST } = await import("./route"); + prismaMock.prisma.syncLock.findUnique.mockResolvedValue(null); + + prismaMock.prisma.issue.findMany.mockResolvedValue([ + { id: "issue-5", number: 7, labels: ["status/in-progress", "agent/alpha"], state: "open" }, + ]); + + githubMock.fetchIssue.mockResolvedValue({ + number: 7, + state: "closed", + closed_at: "2025-06-01T12:00:00Z", + labels: [], + }); + + const res = await POST(makeRequest()); + expect(res.status).toBe(200); + expect(prismaMock.prisma.issue.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "issue-5" }, + data: expect.objectContaining({ + labels: expect.arrayContaining(["agent/alpha", "status/done"]), + }), + }), + ); + }); + + it("includes closedIssueReconcile in response when reconciliation runs", async () => { + const { POST } = await import("./route"); + prismaMock.prisma.syncLock.findUnique.mockResolvedValue(null); + + prismaMock.prisma.issue.findMany.mockResolvedValue([]); + + const res = await POST(makeRequest()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.closedIssueReconcile).toBeDefined(); + expect(body.closedIssueReconcile.issuesChecked).toBe(0); + }); +}); diff --git a/src/app/api/sync/scheduled/route.ts b/src/app/api/sync/scheduled/route.ts index 67ebbc5..a4b4d12 100644 --- a/src/app/api/sync/scheduled/route.ts +++ b/src/app/api/sync/scheduled/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { fetchIssues, fetchRepo, fetchWorkflows, fetchRecentRunsAllWorkflows, fetchReleases, fetchPullRequests, fetchLatestCommit, fetchPackages, fetchRunJobs } from "@/lib/github"; +import { fetchIssues, fetchIssue, fetchRepo, fetchWorkflows, fetchRecentRunsAllWorkflows, fetchReleases, fetchPullRequests, fetchLatestCommit, fetchPackages, fetchRunJobs } from "@/lib/github"; import { getSyncRepos, getTrackedRepos } from "@/lib/config"; -import { syncIssuesForRepos, SyncResponse } from "@/lib/issue-sync"; +import { syncIssuesForRepos, reconcileClosedIssues, SyncResponse, ClosedIssueReconcileResponse } from "@/lib/issue-sync"; import { authorizeRequest } from "@/lib/auth"; // --------------------------------------------------------------------------- @@ -97,6 +97,7 @@ export async function POST(request: Request) { try { let issueSync: SyncResponse | null = null; + let closedIssueReconcile: ClosedIssueReconcileResponse | null = null; let automationResult: { synced: number; failed: number } | null = null; // Issue sync (default enabled) @@ -124,6 +125,45 @@ export async function POST(request: Request) { await prisma.issue.create({ data: { ...data, repositoryId } }); }, }); + + // Reconcile closed issues with stale active statuses + try { + const reposForReconcile = await getSyncRepos(); + closedIssueReconcile = await reconcileClosedIssues(reposForReconcile, fetchIssue, { + async findActiveCachedIssues(repositoryId) { + return prisma.issue.findMany({ + where: { + repositoryId, + state: { in: ["open", "closed"] as const }, + labels: { + hasSome: ["status/ready", "status/in-progress", "status/in-review"], + }, + }, + select: { id: true, number: true, labels: true, state: true }, + }); + }, + async updateIssue(id, data) { + await prisma.issue.update({ + where: { id }, + data: { + labels: data.labels, + state: data.state, + closedAt: data.closedAt, + }, + }); + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + console.error("Closed issue reconciliation failed:", error); + closedIssueReconcile = { + success: false, + reposProcessed: 0, + issuesChecked: 0, + issuesReconciled: 0, + results: [{ repo: "", issueNumber: 0, reconciled: false, action: "no_change", error: message }], + }; + } } // Automation sync (optional, opt-in) @@ -268,6 +308,13 @@ export async function POST(request: Request) { syncedCount: issueSync?.syncedCount ?? 0, notes: JSON.stringify({ issueResults: issueSync?.results, + closedIssueReconcile: closedIssueReconcile + ? { + issuesReconciled: closedIssueReconcile.issuesReconciled, + issuesChecked: closedIssueReconcile.issuesChecked, + results: closedIssueReconcile.results, + } + : null, automationResult, }), }, @@ -288,6 +335,14 @@ export async function POST(request: Request) { syncedCount: issueSync.syncedCount, results: issueSync.results, }; + + if (closedIssueReconcile) { + response.closedIssueReconcile = { + issuesReconciled: closedIssueReconcile.issuesReconciled, + issuesChecked: closedIssueReconcile.issuesChecked, + results: closedIssueReconcile.results, + }; + } } if (syncAutomation && automationResult) { diff --git a/src/lib/issue-sync.ts b/src/lib/issue-sync.ts index c5553cd..89c8c7a 100644 --- a/src/lib/issue-sync.ts +++ b/src/lib/issue-sync.ts @@ -41,6 +41,22 @@ export interface RefreshIssueResult { issueData?: SingleIssueData; } +export interface ReconcileClosedIssueResult { + repo: string; + issueNumber: number; + reconciled: boolean; + action: "marked_done" | "released_lease" | "no_change"; + error: string | null; +} + +export interface ClosedIssueReconcileResponse { + success: boolean; + reposProcessed: number; + issuesChecked: number; + issuesReconciled: number; + results: ReconcileClosedIssueResult[]; +} + export interface SyncResponse { success: boolean; repos: number; @@ -154,6 +170,106 @@ export async function syncIssuesForRepos( }; } +/** + * Status labels that indicate an issue is actively being worked on. + * Closed issues with these labels in Dispatch need reconciliation. + */ +const ACTIVE_STATUS_LABELS = ["status/ready", "status/in-progress", "status/in-review"] as const; + +export async function reconcileClosedIssues( + repos: SyncRepo[], + fetchIssueFn: (repoFullName: string, issueNumber: number) => Promise, + store: { + findActiveCachedIssues(repositoryId: string): Promise>; + updateIssue(id: string, data: { labels: string[]; state: string; closedAt: Date | null }): Promise; + }, +): Promise { + const results: ReconcileClosedIssueResult[] = []; + let issuesReconciled = 0; + + for (const repo of repos) { + try { + const cachedIssues = await store.findActiveCachedIssues(repo.id); + let repoReconciled = 0; + + for (const cached of cachedIssues) { + const hasActiveStatus = ACTIVE_STATUS_LABELS.some((s) => cached.labels.includes(s)); + if (!hasActiveStatus) continue; + + try { + const ghIssue = await fetchIssueFn(repo.fullName, cached.number); + + if (ghIssue.state === "closed") { + // Determine the new label: close any active status, add status/done + const newLabels = cached.labels + .filter((l) => !ACTIVE_STATUS_LABELS.includes(l as typeof ACTIVE_STATUS_LABELS[number])) + .concat(["status/done"]); + + await store.updateIssue(cached.id, { + labels: newLabels, + state: "closed", + closedAt: ghIssue.closed_at ? new Date(ghIssue.closed_at) : new Date(), + }); + + repoReconciled++; + results.push({ + repo: repo.fullName, + issueNumber: cached.number, + reconciled: true, + action: "marked_done", + error: null, + }); + + // If the issue had in-progress status, that implies a lease was active. + // Mark it as released for observability (actual lease release is handled separately). + if (cached.labels.includes("status/in-progress")) { + results[results.length - 1].action = "released_lease"; + } + } else { + results.push({ + repo: repo.fullName, + issueNumber: cached.number, + reconciled: false, + action: "no_change", + error: null, + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + console.error(`Reconcile failed for ${repo.fullName}#${cached.number}:`, error); + results.push({ + repo: repo.fullName, + issueNumber: cached.number, + reconciled: false, + action: "no_change", + error: message, + }); + } + } + + issuesReconciled += repoReconciled; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + console.error(`Closed issue reconciliation failed for ${repo.fullName}:`, error); + results.push({ + repo: repo.fullName, + issueNumber: 0, + reconciled: false, + action: "no_change", + error: message, + }); + } + } + + return { + success: results.every((r) => r.error === null || !r.reconciled), + reposProcessed: repos.length, + issuesChecked: results.length, + issuesReconciled, + results, + }; +} + export async function refreshSingleIssue( repoFullName: string, issueNumber: number,