diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index b05be15b6e..96ede94e11 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -19228,6 +19228,33 @@ } ] } + }, + "/v1/internal/jobs/agent-regate-pr": { + "post": { + "summary": "Force a re-gate of a specific pull request", + "responses": { + "202": { + "description": "Forced re-gate queued" + }, + "400": { + "description": "Invalid repo/PR request" + }, + "401": { + "description": "Invalid internal token" + }, + "404": { + "description": "Pull request or repository not found" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/api/routes.ts b/src/api/routes.ts index c21cbe1199..94a02d197c 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -4830,6 +4830,30 @@ export function createApp() { return c.json(await backfillOpenPullRequestDetails(c.env, { repoFullName: body.repoFullName, mode, ...(Number.isFinite(Number(body?.cursor)) ? { cursor: Number(body.cursor) } : {}) })); }); + // #8898: the first real producer for agent-regate-pr's `force` field (src/types.ts) -- every other + // producer re-gates automatically and never forces a cache bypass. This lets an operator force a + // fresh AI opinion on one specific PR instead of reusing a recent (possibly disputed) result. + app.post("/v1/internal/jobs/agent-regate-pr", async (c) => { + const body = await c.req.json().catch(() => ({})); + if (typeof body?.repoFullName !== "string" || body.repoFullName.length === 0) return c.json({ error: "repo_full_name_required" }, 400); + if (typeof body?.prNumber !== "number" || !Number.isInteger(body.prNumber) || body.prNumber <= 0) return c.json({ error: "pr_number_required" }, 400); + const pullRequest = await getPullRequest(c.env, body.repoFullName, body.prNumber); + if (!pullRequest) return c.json({ error: "pull_request_not_found" }, 404); + const repo = await getRepository(c.env, body.repoFullName); + if (!repo || typeof repo.installationId !== "number") return c.json({ error: "repo_not_found" }, 404); + const message: JobMessage = { + type: "agent-regate-pr", + deliveryId: `manual-regate:${body.repoFullName}#${body.prNumber}:${crypto.randomUUID()}`, + repoFullName: body.repoFullName, + prNumber: body.prNumber, + installationId: repo.installationId, + force: true, + ...(pullRequest.createdAt ? { prCreatedAt: pullRequest.createdAt } : {}), + }; + await c.env.JOBS.send(message); + return c.json({ ok: true, status: "queued", repoFullName: body.repoFullName, prNumber: body.prNumber, force: true }, 202); + }); + app.post("/v1/internal/jobs/refresh-scoring-model", async (c) => { const message: JobMessage = { type: "refresh-scoring-model", requestedBy: "api" }; await c.env.JOBS.send(message); diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 5c53eb4c4a..955afd4822 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -1312,6 +1312,17 @@ export function buildOpenApiSpec() { 401: { description: "Invalid internal token" }, }, }); + registry.registerPath({ + method: "post", + path: "/v1/internal/jobs/agent-regate-pr", + summary: "Force a re-gate of a specific pull request", + responses: { + 202: { description: "Forced re-gate queued" }, + 400: { description: "Invalid repo/PR request" }, + 404: { description: "Pull request or repository not found" }, + 401: { description: "Invalid internal token" }, + }, + }); registry.registerPath({ method: "post", path: "/v1/internal/jobs/generate-review-recap", diff --git a/src/types.ts b/src/types.ts index c62cd2de1c..d3f05901ab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,9 +31,11 @@ export type JobMessage = // (#audit-sweep-fanout, deliveryId prefixed "regate-sweep:" — genuinely deferrable maintenance) and the // sweep's own outage-repair fan-out (deliveryId prefixed "regate-repair:" — a PR missing a current-head // Gate check or public-surface publish); a trailing coalesced re-review after a webhook burst; an - // over-cap sibling wake; a linked-issue-change re-review. EXCEPT for the "regate-sweep:" prefix, every - // producer carries the real webhook/event deliveryId that caused it — current-HEAD contributor-PR-review - // work, never background maintenance (isScheduledRegateSweepJob / githubRateLimitAdmissionTargetForJob in + // over-cap sibling wake; a linked-issue-change re-review; an operator-forced re-gate via + // POST /v1/internal/jobs/agent-regate-pr (deliveryId prefixed "manual-regate:" — #8898, no real + // webhook/event to carry forward). EXCEPT for the "regate-sweep:" prefix, every producer carries the + // real webhook/event deliveryId that caused it — current-HEAD contributor-PR-review work, never + // background maintenance (isScheduledRegateSweepJob / githubRateLimitAdmissionTargetForJob in // ../selfhost/queue-common.ts, #selfhost-queue-liveness). type: "agent-regate-pr"; deliveryId: string; diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 4dd61c511e..797b5645e1 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -4415,6 +4415,31 @@ describe("api routes", () => { expect(sent).toEqual(expect.arrayContaining([expect.objectContaining({ message: expect.objectContaining({ type: "backfill-pr-details", installationId: 654, cursor: 5 }) })])); expect((await app.request("/v1/internal/jobs/backfill-pr-details/run", { method: "POST", headers: internalHeaders, body: "{}" }, env)).status).toBe(400); + // #8898: force-regate route -- validation, not-found, and success branches. + expect((await app.request("/v1/internal/jobs/agent-regate-pr", { method: "POST", headers: internalHeaders, body: "{}" }, env)).status).toBe(400); + expect((await app.request("/v1/internal/jobs/agent-regate-pr", { method: "POST", headers: internalHeaders, body: JSON.stringify({ repoFullName: "owner/repo" }) }, env)).status).toBe(400); + expect((await app.request("/v1/internal/jobs/agent-regate-pr", { method: "POST", headers: internalHeaders, body: JSON.stringify({ repoFullName: "owner/repo", prNumber: 404 }) }, env)).status).toBe(404); + expect((await app.request("/v1/internal/jobs/agent-regate-pr", { method: "POST", headers: internalHeaders, body: JSON.stringify({ repoFullName: "owner/other-missing", prNumber: 9 }) }, env)).status).toBe(404); + // PR row exists but its repo was never registered (e.g. uninstalled after sync) -- distinct 404 branch. + await upsertPullRequestFromGitHub(env, "owner/unregistered", { number: 1, title: "Orphan PR", state: "open", created_at: "2026-06-01T00:00:00Z", labels: [] }); + expect((await app.request("/v1/internal/jobs/agent-regate-pr", { method: "POST", headers: internalHeaders, body: JSON.stringify({ repoFullName: "owner/unregistered", prNumber: 1 }) }, env)).status).toBe(404); + await upsertPullRequestFromGitHub(env, "owner/repo", { number: 9, title: "Test PR", state: "open", created_at: "2026-06-01T00:00:00Z", labels: [] }); + const queuedRegate = await app.request( + "/v1/internal/jobs/agent-regate-pr", + { method: "POST", headers: internalHeaders, body: JSON.stringify({ repoFullName: "owner/repo", prNumber: 9 }) }, + env, + ); + expect(queuedRegate.status).toBe(202); + await expect(queuedRegate.json()).resolves.toMatchObject({ ok: true, status: "queued", repoFullName: "owner/repo", prNumber: 9, force: true }); + expect(sent).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.objectContaining({ type: "agent-regate-pr", repoFullName: "owner/repo", prNumber: 9, installationId: 654, force: true, prCreatedAt: "2026-06-01T00:00:00Z" }), + }), + ]), + ); + expect((await app.request("/v1/internal/jobs/agent-regate-pr", { method: "POST", body: JSON.stringify({ repoFullName: "owner/repo", prNumber: 9 }) }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/build-contributor-decision-packs/run", { method: "POST", headers: internalHeaders, body: "{}" }, env)).status).toBe(400); expect((await app.request("/v1/internal/jobs/refresh-contributor-activity", { method: "POST", headers: internalHeaders, body: "{}" }, env)).status).toBe(400); const queuedContributorRefresh = await app.request(