From 83099b90771841cffde5e0b1650c10810695f35c Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:48:24 -0700 Subject: [PATCH] feat(engine): close blacklisted contributors' PRs ahead of merit (#1425) A PR from a blacklisted login short-circuits the agent disposition to a deterministic label (`blacklistLabel`, default "slop") + close, ahead of all merit/CI/gate/AI analysis, with a sanitized close comment and no AI call. The close wins over the normal gate disposition (`closeKind: "blacklist"", exempt from AI refutation like the linked-issue hard rule). Honors the autonomy dial and agentPaused/agentDryRun; the owner and automation bots are never auto-closed. The per-repo list is resolved today; the shared/global list unions in once its table lands. Advances #1425. --- docs/review-configuration.md | 6 +++ src/queue/processors.ts | 15 +++++- src/settings/agent-actions.ts | 41 +++++++++++++- src/settings/contributor-blacklist.ts | 9 ++-- test/unit/agent-actions.test.ts | 50 ++++++++++++++++- test/unit/contributor-blacklist.test.ts | 5 ++ test/unit/queue.test.ts | 72 +++++++++++++++++++++++++ 7 files changed, 191 insertions(+), 7 deletions(-) diff --git a/docs/review-configuration.md b/docs/review-configuration.md index 3188f2ed78..3daaceeed1 100644 --- a/docs/review-configuration.md +++ b/docs/review-configuration.md @@ -163,6 +163,12 @@ public data, so entries carry only public-safe metadata (a `reason`, `evidence` date) β€” never wallets, hotkeys, trust scores, or private values. `blacklistLabel` (default `slop`) is the label the engine applies to a blacklisted author's PR. +A PR from a **blacklisted login** is labeled (`blacklistLabel`) and **closed deterministically** β€” +ahead of any merit/CI/AI analysis, with a sanitized close comment and **no AI call**. The close +short-circuits and **wins over the normal gate disposition**; it honors the autonomy dial and +`agentPaused` / `agentDryRun` exactly like any other agent action, and the owner and automation bots +are never auto-closed. + ### Example `.gittensory.yml` ```yaml diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 4e3aee3479..1c35082652 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -114,6 +114,7 @@ import { import { executeAgentRun, explainBlockersWithAgent, planNextWork, preflightBranchWithAgent, preparePrPacketWithAgent } from "../services/agent-orchestrator"; import { isAuthorizedGitHubSessionLogin, parseGitHubLoginList } from "../auth/security"; import { commandAuthorizationAllowedRoles, commandAuthorizationNeedsMinerDetection, evaluateCommandAuthorization } from "../settings/command-authorization"; +import { findBlacklistEntry, isAuthorBlacklisted } from "../settings/contributor-blacklist"; import { autonomyRequiresApproval, isAgentConfigured, resolveAutonomy } from "../settings/autonomy"; import { isGlobalAgentPause, resolveAgentActionMode } from "../settings/agent-execution"; import { SWEEP_FANOUT_DEDUP_MS, isRegateSweepDraining, selectRegateCandidates } from "../settings/agent-sweep"; @@ -824,6 +825,11 @@ async function maybeRunAgentMaintenance( ciToken, }); + // Contributor blacklist (#1425): resolve whether the PR author is on the repo's blacklist (the shared/global + // list unions in once its table lands). A match short-circuits the planner to a deterministic label + close + // ahead of merit/CI/AI; the configured label (default "slop") and the entry's public-safe reason drive it. + const blacklistEntry = findBlacklistEntry(pr.authorLogin, settings.contributorBlacklist); + const planned = planAgentMaintenanceActions({ conclusion: gate.conclusion, blockerTitles: gate.blockers.map((blocker) => blocker.title), @@ -846,6 +852,9 @@ async function maybeRunAgentMaintenance( ciState: ciAggregate.ciState, failingCheckNames: ciAggregate.failingDetails.map((detail) => detail.name), ciRequiredContextsVerified: hasVerifiedRequiredContexts(requiredContexts), + ...(blacklistEntry !== null ? { blacklistMatch: { matched: true, reason: blacklistEntry.reason } } : {}), + // Always threaded (the DB layer populates it, default "slop"); the planner applies its own fallback. + blacklistLabel: settings.blacklistLabel, ...(linkedIssueHardRule !== undefined ? { linkedIssueHardRule } : {}), // Flag-then-close double-check: thread the loaded verify config so the planner FLAGS first then closes on // re-verification (default ON). Only passed when a rule is on (the planner reads it only for a violation). @@ -2673,7 +2682,11 @@ async function maybePublishPrPublicSurface( // files so the review (+ grounding + RAG) sees the REAL diff even on a pre-detail-sync first review (FIX B); // resolve only when the review will actually run (aiReviewMode !== off + a head SHA + not explicitly skipped) // to keep gate-only and advisory-sweep repos free of an extra file resolve. - const aiReviewWillRun = !webhook.skipAiReview && settings.aiReviewMode !== "off" && Boolean(advisory.headSha); + // Contributor blacklist (#1425): a blocked author's PR is closed by the deterministic disposition, so it must + // NEVER spend an AI call β€” skip the AI review entirely when the author is blacklisted (the gate + disposition + // still run; the close fires there). Per-repo list now; the shared/global list unions in once its table lands. + const authorBlacklisted = isAuthorBlacklisted(author, settings.contributorBlacklist); + const aiReviewWillRun = !webhook.skipAiReview && settings.aiReviewMode !== "off" && Boolean(advisory.headSha) && !authorBlacklisted; // Post a transient "πŸŸͺ reviewing…" placeholder BEFORE the AI runs so contributors see the bot // is actively working rather than silent. In-place upsert: once the final verdict is ready it // overwrites this comment. Best-effort β€” a failed post never aborts the review. (#reviewing-placeholder) diff --git a/src/settings/agent-actions.ts b/src/settings/agent-actions.ts index 917f00d99d..6dbfb33f89 100644 --- a/src/settings/agent-actions.ts +++ b/src/settings/agent-actions.ts @@ -3,6 +3,7 @@ import { AI_JUDGMENT_BLOCKER_CODES, type GateCheckConclusion } from "../rules/ad import { DEFAULT_AUTO_MAINTAIN_POLICY, autonomyRequiresApproval, isActingAutonomyLevel, resolveAutonomy } from "./autonomy"; import { isGuardrailHit } from "../signals/change-guardrail"; import { AGENT_LABEL_PENDING_CLOSURE } from "../review/linked-issue-hard-rules"; +import { sanitizePublicComment } from "../github/commands"; // High-slop threshold default when a repo hasn't set slopGateMinScore (mirrors the gate's `high` band). const DEFAULT_SLOP_GATE_MIN_SCORE = 60; @@ -18,6 +19,10 @@ const DEFAULT_SLOP_GATE_MIN_SCORE = 60; // them and they never collide with project labels. export const AGENT_LABEL_READY = "gittensory:ready-to-merge"; export const AGENT_LABEL_CHANGES = "gittensory:changes-requested"; +// Default label applied to a blacklisted contributor's PR (#1425). NOT hardcoded into the action β€” it is +// configurable per-repo via `.gittensory.yml` (`settings.blacklistLabel`); the planner uses the resolved label +// and falls back to this default, so the disposition works regardless of the label a repo sets. +export const DEFAULT_BLACKLIST_LABEL = "slop"; // A PR that PASSES the gate but touches a hard-guardrail path is NOT ready to auto-merge β€” it is withheld // for a human (the merge/approve/close dispositions are suppressed below). Labeling it `ready-to-merge` // would be misleading (the label promises an auto-merge that never happens), so a guarded passing PR gets @@ -56,7 +61,7 @@ export type PlannedAgentAction = { // duplicate / slop / CI). The breaker downgrades ONLY "heuristic" closes; the deterministic close is EXEMPT // (silently holding a close whose comment already promised closure would be incoherent). Absent on non-close // actions; treated as a heuristic close only when explicitly tagged "heuristic". - closeKind?: "linked-issue-hard-rule" | "heuristic"; + closeKind?: "linked-issue-hard-rule" | "blacklist" | "heuristic"; expectedHeadSha?: string; }; @@ -109,6 +114,16 @@ export type AgentActionPlanInput = { // AI verdicts). It still NEVER fires for the owner or automation bots (the `isContributor` guard). Absent / // not-violated β‡’ no effect. linkedIssueHardRule?: { violated: boolean; reason: string | null } | undefined; + // Contributor blacklist (#1425, anti-abuse): when the PR author is on the resolved blacklist (per-repo βˆͺ + // global), the disposition SHORT-CIRCUITS to a deterministic close ahead of ALL merit/CI/AI analysis β€” the + // banned account never gets merit-reviewed or auto-merged. Zero-hallucination (not an AI judgment), so its + // close is EXEMPT from the AI-refutation breaker (closeKind "blacklist"). Fires for a CONTRIBUTOR only + // (owner/automation bots are never auto-closed). `reason` is the entry's public-safe reason (or null). Absent / + // not-matched β‡’ no effect. The close comment is sanitized through the public-safe sanitizer before posting. + blacklistMatch?: { matched: boolean; reason: string | null | undefined } | undefined; + // The repo-configured label applied to a blacklisted author's PR (#1425), resolved from `.gittensory.yml`. + // Absent β‡’ the default (`DEFAULT_BLACKLIST_LABEL` = "slop"), so the disposition works regardless of the label set. + blacklistLabel?: string | undefined; // Flag-then-close double-check for the linked-issue hard rule (#linked-issue-verify-before-close). When // `verifyBeforeClose` is true (the default), a violation FLAGS the PR (pending-closure label + warning comment) // on first detection and only CLOSES on a LATER evaluation when the violation STILL holds AND the PR already @@ -215,6 +230,12 @@ function closeMessage(reasons: string[]): string { return `Gittensory is closing this pull request on the maintainer's behalf (${reasons.join("; ")}). This is an automated maintenance action β€” to pursue this change, please open a new pull request with the issues resolved. Closed PRs are re-reviewed automatically, so an inaccurate close may be reopened, but that does not guarantee it can merge (e.g. if conflicts or failing CI remain).`; } +// The close comment for a blacklisted author (#1425). The maintainer-supplied `reason` is sanitized by the caller +// through the public-safe sanitizer, so this never leaks a private term into the public PR thread. +function blacklistCloseMessage(reason: string): string { + return `Gittensory is closing this pull request on the maintainer's behalf: ${reason}. This account is blocked from contributing to this repository, so the change was not reviewed on its merits. This is an automated maintenance action.`; +} + /** * Plan the maintainer auto-maintain actions for one PR. Returns a COHERENT set (never both approve and * request-changes; never both merge and close), each entry already filtered to an acting autonomy class. @@ -232,6 +253,24 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne const acting = (actionClass: AgentActionClass) => isActingAutonomyLevel(level(actionClass)); const approval = (actionClass: AgentActionClass) => autonomyRequiresApproval(level(actionClass)); + // Contributor blacklist (#1425): a banned author's PR is a DETERMINISTIC short-circuit β€” it SHORT-CIRCUITS to a + // label + close AHEAD of all merit/CI/gate/AI analysis (this returns before any of it), so a blocked account is + // never merit-reviewed or auto-merged. Fires for a CONTRIBUTOR only (owner/automation bots are NEVER auto-closed, + // the standing rule). Zero-hallucination, so its close is `closeKind: "blacklist"` β€” exempt from the AI-refutation + // breaker like the linked-issue hard rule. The `acting`/`approval` gates here + the executor's pause/dry-run/ + // kill-switch gate make it dry-run-able and approval-gated exactly like every other action. The close comment is + // run through the public-safe sanitizer so a maintainer's reason can never leak a private term. + const blacklistContributor = !input.authorIsOwner && !input.authorIsAutomationBot; + if (input.blacklistMatch?.matched === true && blacklistContributor) { + const label = input.blacklistLabel ?? DEFAULT_BLACKLIST_LABEL; + if (acting("label")) actions.push({ actionClass: "label", requiresApproval: approval("label"), reason: "blacklisted contributor", label, labelOp: "add" }); + if (acting("close")) { + const reason = input.blacklistMatch.reason ?? "this account is blocked from contributing to this repository"; + actions.push({ actionClass: "close", requiresApproval: approval("close"), reason: "blacklisted contributor", closeComment: sanitizePublicComment(blacklistCloseMessage(reason)), closeKind: "blacklist" }); + } + return actions; + } + // Only a SKIPPED gate (genuinely not evaluated) drives no action. A NEUTRAL gate (first-time-contributor // grace, or eval-not-ready while state is still syncing) is gate-NON-BLOCKING: it flows to the disposition so // the PR is merged (clean+green) or HELD with a label β€” never left silently undecided. (#harm-stop neutral-silent-stuck) diff --git a/src/settings/contributor-blacklist.ts b/src/settings/contributor-blacklist.ts index 269ecf19b5..0263737e94 100644 --- a/src/settings/contributor-blacklist.ts +++ b/src/settings/contributor-blacklist.ts @@ -60,15 +60,16 @@ export function normalizeContributorBlacklist(input: unknown): { entries: Contri return { entries, warnings }; } -/** The blacklist entry matching `login` (case-insensitive), or null. */ -export function findBlacklistEntry(login: string | null | undefined, entries: ContributorBlacklistEntry[]): ContributorBlacklistEntry | null { +/** The blacklist entry matching `login` (case-insensitive), or null. Tolerates an absent list (treated as empty) + * so callers can pass the optional `settings.contributorBlacklist` directly. */ +export function findBlacklistEntry(login: string | null | undefined, entries: ContributorBlacklistEntry[] | undefined): ContributorBlacklistEntry | null { if (!login) return null; const key = login.toLowerCase(); - return entries.find((entry) => entry.login.toLowerCase() === key) ?? null; + return (entries ?? []).find((entry) => entry.login.toLowerCase() === key) ?? null; } /** True iff `login` is on the resolved blacklist. */ -export function isAuthorBlacklisted(login: string | null | undefined, entries: ContributorBlacklistEntry[]): boolean { +export function isAuthorBlacklisted(login: string | null | undefined, entries: ContributorBlacklistEntry[] | undefined): boolean { return findBlacklistEntry(login, entries) !== null; } diff --git a/test/unit/agent-actions.test.ts b/test/unit/agent-actions.test.ts index 3cf046a590..b4ec1d11e9 100644 --- a/test/unit/agent-actions.test.ts +++ b/test/unit/agent-actions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { AGENT_LABEL_CHANGES, AGENT_LABEL_NEEDS_REVIEW, AGENT_LABEL_READY, downgradeCloseToHold, downgradeMergeToHold, isProtectedAutomationAuthor, planAgentMaintenanceActions, type AgentActionPlanInput, type PlannedAgentAction } from "../../src/settings/agent-actions"; +import { AGENT_LABEL_CHANGES, AGENT_LABEL_NEEDS_REVIEW, AGENT_LABEL_READY, DEFAULT_BLACKLIST_LABEL, downgradeCloseToHold, downgradeMergeToHold, isProtectedAutomationAuthor, planAgentMaintenanceActions, type AgentActionPlanInput, type PlannedAgentAction } from "../../src/settings/agent-actions"; import { AGENT_LABEL_PENDING_CLOSURE } from "../../src/review/linked-issue-hard-rules"; import type { GateCheckConclusion } from "../../src/rules/advisory"; @@ -685,3 +685,51 @@ describe("downgradeCloseToHold β€” close-precision circuit-breaker (#close-preci expect(heldNullish.find((a) => a.actionClass === "label" && a.label === AGENT_LABEL_NEEDS_REVIEW)?.requiresApproval).toBe(false); }); }); + +describe("contributor blacklist short-circuit (#1425)", () => { + const blacklisted = (extra: Partial = {}) => + input({ conclusion: "success", autonomy: { label: "auto", close: "auto", approve: "auto", merge: "auto" }, blacklistMatch: { matched: true, reason: "plagiarism" }, ...extra }); + + it("labels + closes a blacklisted contributor's PR, winning over a passing gate (no merit review / merge)", () => { + const plan = planAgentMaintenanceActions(blacklisted()); + expect(classes(plan)).toEqual(["label", "close"]); // short-circuit: no approve/merge despite a SUCCESS gate + expect(plan[0]).toMatchObject({ actionClass: "label", label: DEFAULT_BLACKLIST_LABEL, labelOp: "add" }); + expect(plan[1]).toMatchObject({ actionClass: "close", closeKind: "blacklist" }); + expect(plan[1]?.closeComment).toContain("plagiarism"); + expect(plan[1]?.closeComment).toContain("blocked from contributing"); + }); + + it("uses the repo-configured blacklistLabel, defaulting to 'slop' when unset", () => { + expect(planAgentMaintenanceActions(blacklisted({ blacklistLabel: "abuse" }))[0]).toMatchObject({ label: "abuse" }); + expect(DEFAULT_BLACKLIST_LABEL).toBe("slop"); + expect(planAgentMaintenanceActions(blacklisted())[0]).toMatchObject({ label: "slop" }); + }); + + it("uses a default reason when the entry has none", () => { + const plan = planAgentMaintenanceActions(blacklisted({ blacklistMatch: { matched: true, reason: null } })); + expect(plan[1]?.closeComment).toContain("blocked from contributing"); + }); + + it("fires AHEAD of CI β€” closes even while CI is still pending (not the pending early-return)", () => { + expect(classes(planAgentMaintenanceActions(blacklisted({ ciState: "pending" })))).toEqual(["label", "close"]); + }); + + it("NEVER fires for the owner or an automation bot (standing rule) β€” the PR falls through to normal disposition", () => { + expect(classes(planAgentMaintenanceActions(blacklisted({ authorIsOwner: true })))).not.toContain("close"); + expect(classes(planAgentMaintenanceActions(blacklisted({ authorIsAutomationBot: true })))).not.toContain("close"); + }); + + it("no-ops when the author is not matched (normal disposition runs)", () => { + expect(classes(planAgentMaintenanceActions(blacklisted({ blacklistMatch: { matched: false, reason: null } })))).not.toContain("close"); + }); + + it("respects autonomy: observe plans nothing (still short-circuits); label-only labels but does not close", () => { + expect(planAgentMaintenanceActions(blacklisted({ autonomy: {} }))).toEqual([]); + expect(classes(planAgentMaintenanceActions(blacklisted({ autonomy: { label: "auto" } })))).toEqual(["label"]); + }); + + it("sanitizes the close comment β€” a forbidden term in the reason never reaches the public thread", () => { + const plan = planAgentMaintenanceActions(blacklisted({ blacklistMatch: { matched: true, reason: "leaked a wallet address" } })); + expect(plan[1]?.closeComment).not.toMatch(/wallet/i); + }); +}); diff --git a/test/unit/contributor-blacklist.test.ts b/test/unit/contributor-blacklist.test.ts index 8618f2ec91..0190fc7e87 100644 --- a/test/unit/contributor-blacklist.test.ts +++ b/test/unit/contributor-blacklist.test.ts @@ -88,6 +88,11 @@ describe("findBlacklistEntry / isAuthorBlacklisted", () => { expect(isAuthorBlacklisted("stranger", list)).toBe(false); expect(isAuthorBlacklisted(null, list)).toBe(false); }); + + it("tolerates an absent list (treated as empty) so callers can pass the optional setting directly", () => { + expect(findBlacklistEntry("anyone", undefined)).toBeNull(); + expect(isAuthorBlacklisted("anyone", undefined)).toBe(false); + }); }); describe("mergeContributorBlacklists (global βˆͺ per-repo)", () => { diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 454f7ca21e..5332e8e566 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -1793,6 +1793,78 @@ describe("queue processors", () => { expect(acted?.n).toBe(0); }); + it("blacklist (#1425): a banned author's PR is labeled + closed deterministically with NO AI call and no merit merge", async () => { + let aiCalls = 0; + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + AI: { run: async () => { aiCalls += 1; return { response: JSON.stringify({ assessment: "n/a", blockers: [], nits: [], suggestions: [] }) }; } } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "100000", + }); + await upsertInstallation(env, { + 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"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + // The banned login is per-repo DB config; the label is the configurable `.gittensory.yml` value below β€” + // nothing is hard-coded. + contributorBlacklist: [{ login: "baduser", reason: "plagiarism" }], + }); + // The label is configurable via `.gittensory.yml` (default "slop"); set a custom one to prove it's not hardcoded. + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { settings: { blacklistLabel: "spam" } }, "repo_file"); + const seen = { closed: false, labels: [] as string[], comments: [] as string[] }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + 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;" }]); + if (url.includes("/pulls/55/reviews")) return Response.json([]); + if (url.includes("/pulls/55/commits")) return Response.json([]); + if (url.endsWith("/pulls/55") && method === "PATCH") { seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; return Response.json({ number: 55, state: "closed" }); } + if (url.endsWith("/pulls/55")) return Response.json({ number: 55, state: "open", user: { login: "baduser" }, head: { sha: "bl55" }, mergeable_state: "clean" }); + if (url.includes("/commits/bl55/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/bl55/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/55/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/55/labels") && method === "POST") { seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); return Response.json([]); } + if (url.includes("/issues/55/comments") && method === "POST") { seen.comments.push(String(JSON.parse(String(init?.body ?? "{}")).body ?? "")); return Response.json({ id: 1 }, { status: 201 }); } + if (url.includes("/issues/55/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "blacklist-close", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Banned author PR", state: "open", user: { login: "baduser" }, head: { sha: "bl55" }, labels: [], body: "Closes #1", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + // Deterministic gate: closed + labeled (with the configured label), and the AI was NEVER called. + expect(aiCalls).toBe(0); + expect(seen.closed).toBe(true); + expect(seen.labels).toContain("spam"); + const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); + expect(closeAudit?.n).toBeGreaterThanOrEqual(1); + // No merit merge despite a clean+green+approved PR (the blacklist short-circuits ahead of merit). + const mergeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.merge'").first<{ n: number }>(); + expect(mergeAudit?.n).toBe(0); + // The close comment is public-safe and explains the block. + expect(seen.comments.some((c) => c.includes("blocked from contributing"))).toBe(true); + }); + // #1092: prReadyForReview rebases a BEHIND-base PR through the agent executor (gated by update_branch autonomy // + pull_requests:write) before reviewing, then defers β€” the synchronize on the new head re-runs review. async function seedBehindRepo(env: Env, over: { autonomy?: Record; agentPaused?: boolean; perms?: Record; noInstall?: boolean } = {}) {