diff --git a/src/api/routes.ts b/src/api/routes.ts index c21cbe1199..6d4313b755 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -4749,6 +4749,33 @@ export function createApp() { return c.json(await refreshRegistry(c.env)); }); + // Operator-only manual re-gate trigger (#8898): enqueue an `agent-regate-pr` job with `force: true` for one + // repo+PR. This is the FIRST production producer of that job's `force` field (threaded through + // src/queue/job-dispatch.ts into regatePullRequest) -- every scheduled/webhook producer leaves it unset, so + // the force plumbing (a fresh AI opinion that bypasses the durable review cache and the non-cacheable-reuse + // cooldown) was built and tested but unreachable from any real caller until now. Bearer-gated by the + // `/v1/internal/*` middleware (INTERNAL_JOB_TOKEN). 400s a missing/blank repo or a non-positive-integer PR + // number; 404s a repo with no known installation (nothing to authenticate the re-gate against). + app.post("/v1/internal/jobs/regate-pr", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as { repoFullName?: unknown; prNumber?: unknown }; + const repoFullName = typeof body?.repoFullName === "string" ? body.repoFullName.trim() : ""; + if (!repoFullName) return c.json({ error: "repoFullName required" }, 400); + const prNumber = Number(body?.prNumber); + if (!Number.isInteger(prNumber) || prNumber <= 0) return c.json({ error: "prNumber required" }, 400); + const repo = await getRepository(c.env, repoFullName); + if (typeof repo?.installationId !== "number") return c.json({ error: "repo not installed" }, 404); + const message: JobMessage = { + type: "agent-regate-pr", + deliveryId: `manual-regate:${crypto.randomUUID()}`, + repoFullName: repo.fullName, + prNumber, + installationId: repo.installationId, + force: true, + }; + await c.env.JOBS.send(message); + return c.json({ ok: true, status: "queued", repoFullName: repo.fullName, prNumber, force: true }, 202); + }); + app.post("/v1/internal/jobs/backfill-registered-repos", async (c) => { const body = await c.req.json().catch(() => ({})); const repoFullName = typeof body?.repoFullName === "string" ? body.repoFullName : undefined; diff --git a/test/integration/regate-pr-admin-trigger.test.ts b/test/integration/regate-pr-admin-trigger.test.ts new file mode 100644 index 0000000000..a8aa1ffa1e --- /dev/null +++ b/test/integration/regate-pr-admin-trigger.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createApp } from "../../src/api/routes"; +import { createTestEnv } from "../helpers/d1"; +import { upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { processJob } from "../../src/queue/job-dispatch"; +import * as processors from "../../src/queue/processors"; +import type { JobMessage } from "../../src/types"; + +// #8898: the manual admin trigger that finally gives the `agent-regate-pr` job's `force` field a real producer. +describe("POST /v1/internal/jobs/regate-pr (#8898)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("enqueues an agent-regate-pr job with force:true and threads force into regatePullRequest end-to-end", async () => { + const app = createApp(); + const sent: JobMessage[] = []; + const env = createTestEnv({ + JOBS: { async send(message: unknown) { sent.push(message as JobMessage); } } as unknown as Queue, + }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 456); + const headers = { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}`, "content-type": "application/json" }; + + // A whitespace-padded repoFullName is trimmed and resolved to the stored casing before the job is built. + const res = await app.request( + "/v1/internal/jobs/regate-pr", + { method: "POST", headers, body: JSON.stringify({ repoFullName: " JSONbored/gittensory ", prNumber: 42 }) }, + env, + ); + expect(res.status).toBe(202); + await expect(res.json()).resolves.toEqual({ ok: true, status: "queued", repoFullName: "JSONbored/gittensory", prNumber: 42, force: true }); + const job = sent.at(-1); + expect(job).toMatchObject({ type: "agent-regate-pr", repoFullName: "JSONbored/gittensory", prNumber: 42, installationId: 456, force: true }); + // The job carries a manual-trigger delivery id so downstream rate-limit admission treats it as real work. + expect((job as { deliveryId: string }).deliveryId).toMatch(/^manual-regate:/); + + // End-to-end: dispatching the enqueued job hands `force` straight through to regatePullRequest. + const regateSpy = vi.spyOn(processors, "regatePullRequest").mockResolvedValue(undefined); + await processJob(env, job as JobMessage); + expect(regateSpy).toHaveBeenCalledTimes(1); + // regatePullRequest(env, repairHeadSha, repoFullName, prNumber, installationId, deliveryId, force, prCreatedAt) + expect(regateSpy.mock.calls[0]?.[6]).toBe(true); + expect(regateSpy.mock.calls[0]?.[2]).toBe("JSONbored/gittensory"); + expect(regateSpy.mock.calls[0]?.[3]).toBe(42); + }); + + it("400s a missing/blank repo, 400s a non-positive-integer PR number, 404s an uninstalled repo", async () => { + const app = createApp(); + const env = createTestEnv({ JOBS: { async send() {} } as unknown as Queue }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 456); + const headers = { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}`, "content-type": "application/json" }; + + // Malformed JSON body → parsed as {} → no repoFullName → 400 (covers the .catch fallback + the blank guard). + const noRepo = await app.request("/v1/internal/jobs/regate-pr", { method: "POST", headers, body: "{" }, env); + expect(noRepo.status).toBe(400); + await expect(noRepo.json()).resolves.toEqual({ error: "repoFullName required" }); + + // Non-numeric PR number → NaN → the left arm of the guard short-circuits → 400. + const nanPr = await app.request("/v1/internal/jobs/regate-pr", { method: "POST", headers, body: JSON.stringify({ repoFullName: "JSONbored/gittensory", prNumber: "not-a-number" }) }, env); + expect(nanPr.status).toBe(400); + await expect(nanPr.json()).resolves.toEqual({ error: "prNumber required" }); + + // Zero PR number → an integer, so the right arm (<= 0) is what rejects it → 400. + const zeroPr = await app.request("/v1/internal/jobs/regate-pr", { method: "POST", headers, body: JSON.stringify({ repoFullName: "JSONbored/gittensory", prNumber: 0 }) }, env); + expect(zeroPr.status).toBe(400); + + // A repo with no stored installation → nothing to authenticate the re-gate against → 404. + const uninstalled = await app.request("/v1/internal/jobs/regate-pr", { method: "POST", headers, body: JSON.stringify({ repoFullName: "acme/unknown", prNumber: 7 }) }, env); + expect(uninstalled.status).toBe(404); + await expect(uninstalled.json()).resolves.toEqual({ error: "repo not installed" }); + }); +});