@@ -5905,6 +5905,208 @@ describe("queue processors", () => {
59055905 expect(seen.closed).toBe(false);
59065906 });
59075907
5908+ it("contributor open-PR cap (#2270): an author-less (ghost) open PR among the repo's others is excluded from the count and the sibling-wake scan, not crashed on", async () => {
5909+ const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
5910+ await upsertInstallation(env, {
5911+ installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] },
5912+ repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }],
5913+ });
5914+ // A ghost PR with no `user` at all (authorLogin ends up null) — must not match farmer99's count, and must
5915+ // not crash the sibling-wake scan, which runs the identical (authorLogin ?? "") fallback.
5916+ await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 50, title: "Ghost PR", state: "open", head: { sha: "ghost50" }, labels: [], body: "z" });
5917+ await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" });
5918+ await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" });
5919+ await upsertRepositorySettings(env, {
5920+ repoFullName: "JSONbored/gittensory",
5921+ commentMode: "all_prs",
5922+ publicSurface: "comment_only",
5923+ checkRunMode: "off",
5924+ gateCheckMode: "enabled",
5925+ aiReviewMode: "advisory",
5926+ autonomy: { close: "auto", label: "auto" },
5927+ contributorOpenPrCap: 2,
5928+ });
5929+ const seen = { closed: false };
5930+ vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
5931+ const url = input.toString();
5932+ const method = init?.method ?? "GET";
5933+ if (url === "https://api.gittensor.io/miners") return Response.json([]);
5934+ if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
5935+ if (url.includes("/pulls/55/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]);
5936+ if (url.includes("/pulls/55/reviews")) return Response.json([]);
5937+ if (url.includes("/pulls/55/commits")) return Response.json([]);
5938+ if (url.endsWith("/pulls/55") && method === "PATCH") { seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; return Response.json({ number: 55, state: "closed" }); }
5939+ if (url.endsWith("/pulls/55")) return Response.json({ number: 55, state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, mergeable_state: "clean" });
5940+ if (url.includes("/commits/f55/check-runs")) return Response.json({ total_count: 0, check_runs: [] });
5941+ if (url.includes("/commits/f55/status")) return Response.json({ state: "success", statuses: [] });
5942+ if (url.includes("/issues/55/labels") && method === "GET") return Response.json([]);
5943+ if (url.includes("/issues/55/labels") && method === "POST") return Response.json([]);
5944+ if (url.includes("/issues/55/comments") && method === "POST") return Response.json({ id: 1 }, { status: 201 });
5945+ if (url.includes("/issues/55/comments")) return Response.json([]);
5946+ return Response.json({});
5947+ });
5948+
5949+ await processJob(env, {
5950+ type: "github-webhook",
5951+ deliveryId: "contributor-cap-ghost-author",
5952+ eventName: "pull_request",
5953+ payload: {
5954+ action: "opened",
5955+ installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } },
5956+ repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } },
5957+ pull_request: { number: 55, title: "Farmer's 3rd PR", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" },
5958+ },
5959+ });
5960+
5961+ // Ghost PR's null authorLogin never matches "farmer99" — the count is still exactly 3 (farmer99's own).
5962+ expect(seen.closed).toBe(true);
5963+ });
5964+
5965+ it("contributor open-PR cap (#2270): out-of-order webhook delivery wakes and self-corrects the missed sibling (regression, gate finding on #2479)", async () => {
5966+ // PR56 (the NEWER PR) is delivered BEFORE PR55 exists in the DB — a real possibility under concurrent/
5967+ // retried webhook delivery. At that moment PR56 only sees {54, 56} (2 total, AT the cap of 2, not over),
5968+ // so it correctly stays open — but a naive "only ever check myself" implementation would leave it open
5969+ // FOREVER, since nothing else ever re-evaluates PR56 again. This pins the fix: once PR55's delivery later
5970+ // sees the COMPLETE set {54, 55, 56}, it must wake PR56 (not just decide for itself) so PR56 gets a fresh,
5971+ // fully-gated re-evaluation and self-corrects.
5972+ const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
5973+ await upsertInstallation(env, {
5974+ installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] },
5975+ repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }],
5976+ });
5977+ await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR zero", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "w" });
5978+ await upsertRepositorySettings(env, {
5979+ repoFullName: "JSONbored/gittensory",
5980+ commentMode: "all_prs",
5981+ publicSurface: "comment_only",
5982+ checkRunMode: "off",
5983+ gateCheckMode: "enabled",
5984+ aiReviewMode: "advisory",
5985+ autonomy: { close: "auto", label: "auto" },
5986+ contributorOpenPrCap: 2,
5987+ });
5988+ const closedNumbers = new Set<number>();
5989+ const fanned: import("../../src/types").JobMessage[] = [];
5990+ const realSend = env.JOBS.send.bind(env.JOBS);
5991+ env.JOBS.send = (async (message: import("../../src/types").JobMessage, options?: QueueSendOptions) => {
5992+ if (message.type === "agent-regate-pr") fanned.push(message);
5993+ return realSend(message, options);
5994+ }) as typeof env.JOBS.send;
5995+ vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
5996+ const url = input.toString();
5997+ const method = init?.method ?? "GET";
5998+ if (url === "https://api.gittensor.io/miners") return Response.json([]);
5999+ if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
6000+ for (const [n, sha] of [[55, "f55"], [56, "f56"]] as const) {
6001+ if (url.includes(`/pulls/${n}/files`)) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]);
6002+ if (url.includes(`/pulls/${n}/reviews`)) return Response.json([]);
6003+ if (url.includes(`/pulls/${n}/commits`)) return Response.json([]);
6004+ if (url.endsWith(`/pulls/${n}`) && method === "PATCH") { closedNumbers.add(n); return Response.json({ number: n, state: "closed" }); }
6005+ if (url.endsWith(`/pulls/${n}`)) return Response.json({ number: n, state: closedNumbers.has(n) ? "closed" : "open", user: { login: "farmer99" }, head: { sha }, mergeable_state: "clean" });
6006+ if (url.includes(`/commits/${sha}/check-runs`)) return Response.json({ total_count: 0, check_runs: [] });
6007+ if (url.includes(`/commits/${sha}/status`)) return Response.json({ state: "success", statuses: [] });
6008+ if (url.includes(`/issues/${n}/labels`) && method === "GET") return Response.json([]);
6009+ if (url.includes(`/issues/${n}/labels`) && method === "POST") return Response.json([]);
6010+ if (url.includes(`/issues/${n}/comments`) && method === "POST") return Response.json({ id: 1 }, { status: 201 });
6011+ if (url.includes(`/issues/${n}/comments`)) return Response.json([]);
6012+ }
6013+ return Response.json({});
6014+ });
6015+
6016+ // PR56 arrives FIRST — PR55 does not exist yet, so PR56 sees only {54, 56}: at the cap, not over.
6017+ await processJob(env, {
6018+ type: "github-webhook",
6019+ deliveryId: "burst-pr56-first",
6020+ eventName: "pull_request",
6021+ payload: {
6022+ action: "opened",
6023+ installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } },
6024+ repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } },
6025+ pull_request: { number: 56, title: "Farmer PR two (out of order)", state: "open", user: { login: "farmer99" }, head: { sha: "f56" }, labels: [], body: "y", mergeable_state: "clean", reviewDecision: "APPROVED" },
6026+ },
6027+ });
6028+ expect(closedNumbers.has(56)).toBe(false); // correctly not closed YET — the set looked complete at the time
6029+
6030+ // PR55 arrives SECOND — now the complete set {54, 55, 56} is visible. PR55 itself ranks within the cap
6031+ // (oldest 2 of 3), so it stays open — but PR56 is now discoverably over-cap and must be woken.
6032+ await processJob(env, {
6033+ type: "github-webhook",
6034+ deliveryId: "burst-pr55-second",
6035+ eventName: "pull_request",
6036+ payload: {
6037+ action: "opened",
6038+ installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } },
6039+ repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } },
6040+ pull_request: { number: 55, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" },
6041+ },
6042+ });
6043+ expect(closedNumbers.has(55)).toBe(false); // PR55 itself is within the cap
6044+ expect(fanned.some((job) => job.type === "agent-regate-pr" && job.prNumber === 56)).toBe(true); // sibling woken
6045+
6046+ // Drain the woken job — PR56's OWN fresh re-evaluation now sees the complete set and self-corrects.
6047+ env.JOBS.send = realSend;
6048+ for (const job of fanned) await processJob(env, job);
6049+ expect(closedNumbers.has(56)).toBe(true);
6050+ });
6051+
6052+ it("contributor open-PR cap (#2270): a re-delivered sibling-wake is coalesced — the second discovery does not re-enqueue", async () => {
6053+ const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
6054+ await upsertInstallation(env, {
6055+ installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] },
6056+ repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }],
6057+ });
6058+ // Pre-seed the coalescing key for PR56 exactly as wakeOverCapSiblingPullRequests itself would after a
6059+ // first, already-successful enqueue — proving the SECOND discovery within the window skips re-enqueueing.
6060+ await env.SELFHOST_TRANSIENT_CACHE?.set("contributor-cap-wake:jsonbored/gittensory#56", "1", 60);
6061+ await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR zero", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "w" });
6062+ await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 56, title: "Farmer PR two (already over cap)", state: "open", user: { login: "farmer99" }, head: { sha: "f56" }, labels: [], body: "y" });
6063+ await upsertRepositorySettings(env, {
6064+ repoFullName: "JSONbored/gittensory",
6065+ commentMode: "all_prs",
6066+ publicSurface: "comment_only",
6067+ checkRunMode: "off",
6068+ gateCheckMode: "enabled",
6069+ aiReviewMode: "advisory",
6070+ autonomy: { close: "auto", label: "auto" },
6071+ contributorOpenPrCap: 2,
6072+ });
6073+ const fanned: import("../../src/types").JobMessage[] = [];
6074+ const realSend = env.JOBS.send.bind(env.JOBS);
6075+ env.JOBS.send = (async (message: import("../../src/types").JobMessage, options?: QueueSendOptions) => {
6076+ if (message.type === "agent-regate-pr") fanned.push(message);
6077+ return realSend(message, options);
6078+ }) as typeof env.JOBS.send;
6079+ vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
6080+ const url = input.toString();
6081+ if (url === "https://api.gittensor.io/miners") return Response.json([]);
6082+ if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
6083+ if (url.includes("/pulls/55/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]);
6084+ if (url.includes("/pulls/55/reviews")) return Response.json([]);
6085+ if (url.includes("/pulls/55/commits")) return Response.json([]);
6086+ if (url.endsWith("/pulls/55")) return Response.json({ number: 55, state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, mergeable_state: "clean" });
6087+ if (url.includes("/commits/f55/check-runs")) return Response.json({ total_count: 0, check_runs: [] });
6088+ if (url.includes("/commits/f55/status")) return Response.json({ state: "success", statuses: [] });
6089+ if (url.includes("/issues/55/labels")) return Response.json([]);
6090+ if (url.includes("/issues/55/comments")) return Response.json([]);
6091+ return Response.json({});
6092+ });
6093+
6094+ // PR55 arrives and independently discovers PR56 is over cap — but the wake was already claimed.
6095+ await processJob(env, {
6096+ type: "github-webhook",
6097+ deliveryId: "wake-coalesce-second",
6098+ eventName: "pull_request",
6099+ payload: {
6100+ action: "opened",
6101+ installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } },
6102+ repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } },
6103+ pull_request: { number: 55, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" },
6104+ },
6105+ });
6106+
6107+ expect(fanned).toEqual([]); // coalesced — no duplicate wake enqueued
6108+ });
6109+
59086110 // #1092: prReadyForReview rebases a BEHIND-base PR through the agent executor (gated by update_branch autonomy
59096111 // + pull_requests:write) before reviewing, then defers — the synchronize on the new head re-runs review.
59106112 async function seedBehindRepo(env: Env, over: { autonomy?: Record<string, string>; agentPaused?: boolean; perms?: Record<string, string>; noInstall?: boolean } = {}) {
0 commit comments