From 101c426796636f6b66112b3362e1020524d4f00b Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Wed, 1 Jul 2026 22:03:09 -0600 Subject: [PATCH] fix(sync): fetch closed issues so closed=>done enforcement actually runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closedIssueStatusFix (#521) only applies to issues in the sync fetch set, and every caller fetched state=open — so closed issues never flowed through it and kept stale claimable labels (a closed issue sat status/ready and got claimed by the bridge). The gap was masked by the heartbeat cron, whose reconcileClosedIssues did its own includeClosed fetch; retiring it (in-app scheduler cutover) exposed this. All three sync callers now fetch state=all. --- src/app/api/sync/route.ts | 9 ++++++++- src/app/api/sync/scheduled/route.test.ts | 7 +++++++ src/app/api/sync/scheduled/route.ts | 9 ++++++++- src/lib/heartbeat.ts | 9 ++++++++- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/app/api/sync/route.ts b/src/app/api/sync/route.ts index e9a3d483..20a2e121 100644 --- a/src/app/api/sync/route.ts +++ b/src/app/api/sync/route.ts @@ -6,6 +6,13 @@ import { syncIssuesForRepos, mergeLabels } from "@/lib/issue-sync"; import { authorizeRequest } from "@/lib/auth"; import { acquireLock, releaseLock } from "@/lib/sync-lock"; +// Sync must see closed issues, or closedIssueStatusFix never runs: the +// closed=>done enforcement (#521) only applies to issues in the fetch set, +// and the default fetch is state=open. Regressed to open-only when the +// heartbeat cron (whose reconcile did its own closed fetch) was retired. +const fetchAllStateIssues = (repoFullName: string) => fetchIssues(repoFullName, { includeClosed: true }); + + export async function POST(request: NextRequest) { if (!(await authorizeRequest(request)).authorized) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -48,7 +55,7 @@ export async function POST(request: NextRequest) { try { const excludedLabels = parseExcludedLabels(process.env.DISPATCH_EXCLUDED_LABELS); - const result = await syncIssuesForRepos(repos, fetchIssues, { + const result = await syncIssuesForRepos(repos, fetchAllStateIssues, { findIssue(repositoryId, number) { return prisma.issue.findUnique({ where: { repositoryId_number: { repositoryId, number } }, diff --git a/src/app/api/sync/scheduled/route.test.ts b/src/app/api/sync/scheduled/route.test.ts index e8906e24..ee66f692 100644 --- a/src/app/api/sync/scheduled/route.test.ts +++ b/src/app/api/sync/scheduled/route.test.ts @@ -386,6 +386,13 @@ describe("POST /api/sync/scheduled — sync behavior", () => { expect(body.issues.repos).toBe(1); }); + it("fetches closed issues too, so closed=>done enforcement can run (#521 regression)", async () => { + const { POST } = await import("./route"); + const github = await import("@/lib/github"); + await POST(makeRequest()); + expect(github.fetchIssues).toHaveBeenCalledWith(expect.any(String), { includeClosed: true }); + }); + it("does not sync automation by default", async () => { const { POST } = await import("./route"); const res = await POST(makeRequest()); diff --git a/src/app/api/sync/scheduled/route.ts b/src/app/api/sync/scheduled/route.ts index 0f39744f..f678c8f0 100644 --- a/src/app/api/sync/scheduled/route.ts +++ b/src/app/api/sync/scheduled/route.ts @@ -6,6 +6,13 @@ import { syncIssuesForRepos, reconcileClosedIssues, SyncResponse, ClosedIssueRec import { authorizeRequest } from "@/lib/auth"; import { acquireLock, releaseLock } from "@/lib/sync-lock"; +// Sync must see closed issues, or closedIssueStatusFix never runs: the +// closed=>done enforcement (#521) only applies to issues in the fetch set, +// and the default fetch is state=open. Regressed to open-only when the +// heartbeat cron (whose reconcile did its own closed fetch) was retired. +const fetchAllStateIssues = (repoFullName: string) => fetchIssues(repoFullName, { includeClosed: true }); + + // --------------------------------------------------------------------------- // Route handlers // --------------------------------------------------------------------------- @@ -52,7 +59,7 @@ export async function POST(request: Request) { if (syncIssues) { const repos = await getSyncRepos(); const excludedLabels = parseExcludedLabels(process.env.DISPATCH_EXCLUDED_LABELS); - issueSync = await syncIssuesForRepos(repos, fetchIssues, { + issueSync = await syncIssuesForRepos(repos, fetchAllStateIssues, { findIssue(repositoryId, number) { return prisma.issue.findUnique({ where: { repositoryId_number: { repositoryId, number } }, diff --git a/src/lib/heartbeat.ts b/src/lib/heartbeat.ts index ae3ff481..00a09f2a 100644 --- a/src/lib/heartbeat.ts +++ b/src/lib/heartbeat.ts @@ -9,6 +9,7 @@ import { prisma } from "@/lib/prisma"; import { fetchIssues, syncStatusLabels } from "@/lib/github"; import { getSyncRepos, parseExcludedLabels } from "@/lib/config"; import { + syncIssuesForRepos, mergeLabels, reconcileClosedIssues, @@ -16,6 +17,12 @@ import { type ClosedIssueReconcileResponse, } from "@/lib/issue-sync"; +// Sync must see closed issues, or closedIssueStatusFix never runs: the +// closed=>done enforcement (#521) only applies to issues in the fetch set, +// and the default fetch is state=open. Regressed to open-only when the +// heartbeat cron (whose reconcile did its own closed fetch) was retired. +const fetchAllStateIssues = (repoFullName: string) => fetchIssues(repoFullName, { includeClosed: true }); + // --------------------------------------------------------------------------- // Sync orchestration // --------------------------------------------------------------------------- @@ -54,7 +61,7 @@ export async function runSyncBestEffort( const excludedLabels = opts?.excludedLabels ?? parseExcludedLabels(process.env.DISPATCH_EXCLUDED_LABELS); - const result = await syncIssuesForRepos(repos, fetchIssues, { + const result = await syncIssuesForRepos(repos, fetchAllStateIssues, { findIssue(repositoryId: string, number: number) { return prisma.issue.findUnique({ where: { repositoryId_number: { repositoryId, number } },