diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 325dfacf27..a09ce2859e 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -540,6 +540,28 @@ settings: # commandRateLimitAiMaxPerWindow: 5 # Positive integer. Tighter limit for an AI-cost-bearing command (ask/blockers/preflight/reviewability/packet/duplicate-check/next-action/repo-fit). Default: 5. # commandRateLimitWindowHours: 24 # Positive integer. Rolling window (hours) both limits above count against. Default: 24. + # Moderation-rules engine (#selfhost-mod-engine): a single shared, cross-repo violation tally across the + # anti-abuse mechanisms below that already short-circuit a PR/issue's disposition (contributor cap, + # blacklist, review-nag, review-evasion). Off by default; the global config (dashboard/API, not this file) + # holds the master switch, threshold, and label text. This per-repo override only opts THIS repo in/out and + # narrows which mechanisms feed the tally for it. + # moderationGateMode: inherit # inherit | off | enabled. Default: inherit (defer to the global master switch). + # moderationRules: [contributor_cap, blacklist, review_nag, review_evasion] # Replaces (not unions with) the global rule set for this repo. Default: inherit the global list. + # moderationWarningLabel: mod:warning # Label applied at >=1 lifetime violation. Default: the global config's warningLabel. + # moderationBannedLabel: mod:banned # Label applied at >= the ban threshold. Default: the global config's bannedLabel. + + # Review-evasion protection (#review-evasion-protection, anti-abuse): a contributor closing or converting + # their own PR to draft while gittensory has an ACTIVE review pass running against it is dodging the + # one-shot review process, not making an ordinary close. When enabled, gittensory reopens (if needed) and + # re-closes the PR as the App -- a close the contributor cannot themselves reopen (#one-shot-reopen) -- + # posts an explanation comment, applies the configured label, and records a `review_evasion` moderation + # strike (subject to moderationRules above including it). Off by default. + # reviewEvasionProtection: off # off | close. Default: off. + # reviewEvasionLabel: review-evasion # Label applied alongside the enforcement close. Gated on autonomy.close + # # (#label-scoping); set to explicit `null` to close without any label. + # # Default: review-evasion. + # reviewEvasionComment: true # Post the public explanation comment before the enforcement close. Default: true. + # Per-repo activation overrides for the converged review features that ship behind a deployment-wide # GITTENSORY_REVIEW_* env kill-switch (rag/reputation/unifiedComment/safety). Each key is `true` (force on # for this repo, subject to the env flag still being enabled), `false` (force off), or omitted (falls back diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 5d4b99988a..2309175886 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -9022,7 +9022,8 @@ "enum": [ "contributor_cap", "blacklist", - "review_nag" + "review_nag", + "review_evasion" ] } }, @@ -9205,6 +9206,20 @@ "github", "linear" ] + }, + "reviewEvasionProtection": { + "type": "string", + "enum": [ + "off", + "close" + ] + }, + "reviewEvasionLabel": { + "type": "string", + "nullable": true + }, + "reviewEvasionComment": { + "type": "boolean" } }, "required": [ diff --git a/migrations/0113_review_evasion_protection.sql b/migrations/0113_review_evasion_protection.sql new file mode 100644 index 0000000000..c8e02baed5 --- /dev/null +++ b/migrations/0113_review_evasion_protection.sql @@ -0,0 +1,30 @@ +-- Review-evasion protection (#review-evasion-protection): a contributor closing or converting their OWN PR +-- to draft while gittensory has an ACTIVE review pass running against it is dodging the one-shot review +-- process, not making an ordinary close. active_review_tracking durably records that a fresh review pass +-- started for a specific repo/PR/headSha BEFORE any cost-bearing AI-review work begins, so the closed/ +-- converted_to_draft webhook handlers can tell evasion (a close during an active pass) apart from an +-- ordinary close after the review already concluded. One row per (repo, PR); status flips +-- active -> terminal once the pass concludes (published, PR closed/merged, head moved, or evasion +-- enforcement completed) so a later, unrelated close is never mistaken for evasion. +CREATE TABLE IF NOT EXISTS active_review_tracking ( + id TEXT PRIMARY KEY, + repo_full_name TEXT NOT NULL, + pull_number INTEGER NOT NULL, + head_sha TEXT NOT NULL, + author_login TEXT, + delivery_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE UNIQUE INDEX IF NOT EXISTS active_review_tracking_pr_unique ON active_review_tracking (repo_full_name, pull_number); + +-- Per-repo review-evasion settings, layered the same way as every other anti-abuse mechanism in this file +-- (contributorCap/blacklist/reviewNag): reviewEvasionProtection is off by default (zero behavior change for +-- an install that hasn't opted in); reviewEvasionLabel is NOT NULL with a string default (mirrors +-- blacklist_label/review_nag_label -- the "no label" case is a `.gittensory.yml`-only override, never +-- persisted); reviewEvasionComment defaults to posting the explanation comment, matching the existing +-- draft-dodge/reopen-reclose guards' unconditional explanation comment. +ALTER TABLE repository_settings ADD COLUMN review_evasion_protection TEXT NOT NULL DEFAULT 'off'; +ALTER TABLE repository_settings ADD COLUMN review_evasion_label TEXT NOT NULL DEFAULT 'review-evasion'; +ALTER TABLE repository_settings ADD COLUMN review_evasion_comment INTEGER NOT NULL DEFAULT 1; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 2bb36a11bb..e413d24e32 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -1,6 +1,7 @@ import { and, asc, desc, eq, gte, inArray, not, or, sql, type SQL } from "drizzle-orm"; import { getDb } from "./client"; import { + activeReviewTracking, advisories, aiUsageEvents, agentActions, @@ -58,7 +59,7 @@ import { upstreamSourceSnapshots, webhookEvents, } from "./schema"; -import { MAX_REVIEW_NAG_COOLDOWN_DAYS } from "../settings/agent-actions"; +import { DEFAULT_REVIEW_EVASION_LABEL, MAX_REVIEW_NAG_COOLDOWN_DAYS } from "../settings/agent-actions"; import type { Advisory, AdvisoryFinding, @@ -542,6 +543,9 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise moderationRules: undefined, moderationWarningLabel: undefined, moderationBannedLabel: undefined, + reviewEvasionProtection: "off", + reviewEvasionLabel: DEFAULT_REVIEW_EVASION_LABEL, + reviewEvasionComment: true, }; } return { @@ -614,6 +618,9 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise moderationRules: parseModerationRulesColumn(row.moderationRulesJson), moderationWarningLabel: normalizeModerationLabel(row.moderationWarningLabel), moderationBannedLabel: normalizeModerationLabel(row.moderationBannedLabel), + reviewEvasionProtection: normalizeReviewEvasionProtection(row.reviewEvasionProtection), + reviewEvasionLabel: row.reviewEvasionLabel, + reviewEvasionComment: row.reviewEvasionComment, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -728,6 +735,9 @@ export async function upsertRepositorySettings(env: Env, settings: Partial(row.blockerCodesJson, []), overridden: row.overridden }; } +// Review-evasion protection (#review-evasion-protection): idempotently mark that gittensory started a fresh +// review pass for repoFullName#pullNumber at headSha, BEFORE any cost-bearing AI-review work begins. A +// redelivery/retry for the SAME headSha while the row is still active is a true no-op (startedAt/deliveryId +// are preserved); a NEW headSha (a fresh commit) or a previously-terminalized row is overwritten with fresh +// values, since a new review pass genuinely restarts the active window. +export async function startActiveReviewTracking( + env: Env, + input: { repoFullName: string; pullNumber: number; headSha: string; authorLogin?: string | null | undefined; deliveryId: string }, +): Promise { + const repoFullName = boundedString(input.repoFullName, 200); + const values = { + id: `active-review:${repoFullName}#${input.pullNumber}`, + repoFullName, + pullNumber: input.pullNumber, + headSha: input.headSha, + authorLogin: input.authorLogin ?? null, + deliveryId: input.deliveryId, + status: "active", + }; + const sameActiveHead = sql`${activeReviewTracking.headSha} = ${values.headSha} AND ${activeReviewTracking.status} = 'active'`; + await getDb(env.DB) + .insert(activeReviewTracking) + .values(values) + .onConflictDoUpdate({ + target: [activeReviewTracking.repoFullName, activeReviewTracking.pullNumber], + set: { + headSha: values.headSha, + authorLogin: values.authorLogin, + deliveryId: sql`CASE WHEN ${sameActiveHead} THEN ${activeReviewTracking.deliveryId} ELSE ${values.deliveryId} END`, + status: "active", + startedAt: sql`CASE WHEN ${sameActiveHead} THEN ${activeReviewTracking.startedAt} ELSE ${nowIso()} END`, + updatedAt: nowIso(), + }, + }); +} + +// Review-evasion protection: whether gittensory has an ACTIVE review pass recorded for this EXACT +// repo/PR/headSha -- the read side the closed/converted_to_draft evasion guards check before treating a +// contributor's action as evasion. A row for a DIFFERENT headSha (or a terminalized row) does not count -- +// the active window is scoped to the specific commit under review. +export async function hasActiveReviewForHeadSha(env: Env, repoFullName: string, pullNumber: number, headSha: string): Promise { + const row = await getDb(env.DB) + .select({ headSha: activeReviewTracking.headSha, status: activeReviewTracking.status }) + .from(activeReviewTracking) + .where(and(eq(activeReviewTracking.repoFullName, boundedString(repoFullName, 200)), eq(activeReviewTracking.pullNumber, pullNumber))) + .get(); + return row !== undefined && row.status === "active" && row.headSha === headSha; +} + +// Review-evasion protection: guarded status transition -- terminalize the active-review row for +// repoFullName#pullNumber ONLY if it is still 'active' (and, when given, still pinned to headSha), the same +// CAS shape as claimPendingAgentActionDecision, so a stale/already-terminalized row is never double-processed. +// Called when the review pass concludes (published), the PR closes/merges, the head moves, or evasion +// enforcement completes. Returns whether this call's write actually changed a row. +export async function terminalizeActiveReviewTracking( + env: Env, + repoFullName: string, + pullNumber: number, + opts?: { onlyIfHeadSha?: string | undefined }, +): Promise { + const conditions = [ + eq(activeReviewTracking.repoFullName, boundedString(repoFullName, 200)), + eq(activeReviewTracking.pullNumber, pullNumber), + eq(activeReviewTracking.status, "active"), + ]; + if (opts?.onlyIfHeadSha !== undefined) conditions.push(eq(activeReviewTracking.headSha, opts.onlyIfHeadSha)); + const result = await getDb(env.DB) + .update(activeReviewTracking) + .set({ status: "terminal", updatedAt: nowIso() }) + .where(and(...conditions)); + /* v8 ignore next -- D1 update metadata normally includes changes; the ?? 0 fallback protects driver anomalies. */ + return Number(result.meta.changes ?? 0) > 0; +} + export async function listGateOutcomes( env: Env, options: { repoFullName?: string; windowDays?: number; now?: string; limit?: number } = {}, @@ -6464,6 +6554,13 @@ function normalizeReviewNagPolicy(value: string | null | undefined): "off" | "ho return value === "hold" || value === "close" ? value : "off"; } +// Review-evasion protection (#review-evasion-protection): binary off|close, mirroring reviewNagPolicy's +// shape minus the "hold" tier (an evasion attempt is always re-closed as the App when enabled, never merely +// held -- there is no partial-enforcement mode). +function normalizeReviewEvasionProtection(value: string | null | undefined): "off" | "close" { + return value === "close" ? "close" : "off"; +} + function normalizeCommandRateLimitPolicy(value: string | null | undefined): "off" | "hold" { return value === "hold" ? value : "off"; } diff --git a/src/db/schema.ts b/src/db/schema.ts index cd41e7f3ac..f4d7765a40 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -147,6 +147,12 @@ export const repositorySettings = sqliteTable("repository_settings", { moderationRulesJson: text("moderation_rules_json"), moderationWarningLabel: text("moderation_warning_label"), moderationBannedLabel: text("moderation_banned_label"), + // Review-evasion protection (#review-evasion-protection): off by default. reviewEvasionLabel mirrors + // blacklistLabel/reviewNagLabel's shape -- NOT NULL with a string default; "no label" is a + // `.gittensory.yml`-only override, never persisted here. + reviewEvasionProtection: text("review_evasion_protection").notNull().default("off"), + reviewEvasionLabel: text("review_evasion_label").notNull().default("review-evasion"), + reviewEvasionComment: integer("review_evasion_comment", { mode: "boolean" }).notNull().default(true), createdAt: text("created_at").notNull().$defaultFn(() => nowIso()), updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), }); @@ -734,6 +740,30 @@ export const gateOutcomes = sqliteTable( }), ); +// Review-evasion active-review tracking (#review-evasion-protection): one row per (repo, PR), recording that +// gittensory started a fresh review pass against a specific headSha before any cost-bearing AI-review work +// begins. Read by the closed/converted_to_draft webhook handlers to tell a contributor evading the one-shot +// review mid-pass apart from an ordinary close/draft conversion after the review already concluded. `status` +// flips 'active' -> 'terminal' once the pass concludes (published, PR closed/merged, head moved, or evasion +// enforcement completed) so a later, unrelated close is never mistaken for evasion. +export const activeReviewTracking = sqliteTable( + "active_review_tracking", + { + id: text("id").primaryKey(), + repoFullName: text("repo_full_name").notNull(), + pullNumber: integer("pull_number").notNull(), + headSha: text("head_sha").notNull(), + authorLogin: text("author_login"), + deliveryId: text("delivery_id").notNull(), + status: text("status").notNull().default("active"), + startedAt: text("started_at").notNull().$defaultFn(() => nowIso()), + updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), + }, + (table) => ({ + pr: uniqueIndex("active_review_tracking_pr_unique").on(table.repoFullName, table.pullNumber), + }), +); + // Agent-layer approval queue (#779). An `auto_with_approval` action the write-actions layer (#778) staged for // a one-tap maintainer accept/reject. At most one row per (repo, pull, action_class). export const agentPendingActions = sqliteTable( diff --git a/src/github/pr-actions.ts b/src/github/pr-actions.ts index 1411552d2f..0e7b852ef8 100644 --- a/src/github/pr-actions.ts +++ b/src/github/pr-actions.ts @@ -203,6 +203,25 @@ export async function closePullRequest(env: Env, installationId: number, repoFul }); } +/** Reopen a pull request (sets state=open). Review-evasion protection (#review-evasion-protection): a + * contributor may reopen a PR they closed THEMSELVES, but not one closed by a maintainer or the App + * (#one-shot-reopen) -- so the enforcement handler reopens the PR as the App (this call) and immediately + * re-closes it (closePullRequest), converting the contributor's own close into an App-authored, terminal + * close the contributor cannot reopen. */ +export async function reopenPullRequest(env: Env, installationId: number, repoFullName: string, pullNumber: number): Promise<{ state: string }> { + const { owner, repo } = splitRepo(repoFullName); + return withInstallationTokenRetry(env, installationId, async (token) => { + const octokit = makeInstallationOctokit(env, token, "live", githubRateLimitAdmissionKeyForInstallation(installationId)); + const response = await octokit.request("PATCH /repos/{owner}/{repo}/pulls/{pull_number}", { + owner, + repo, + pull_number: pullNumber, + state: "open", + }); + return { state: (response.data as { state: string }).state }; + }); +} + /** Close a plain issue (sets state=closed). #2270's first issue-side actuation: unlike closePullRequest, this * hits the generic Issues API (`PATCH /issues/{issue_number}`), not the Pulls API — a plain issue number is not * a valid `pull_number`, so closePullRequest cannot be reused here. */ diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 8dcf39b0b2..277793fdb5 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -705,9 +705,12 @@ export const RepositorySettingsSchema = z commandRateLimitAiMaxPerWindow: z.number().int().positive().optional(), commandRateLimitWindowHours: z.number().int().positive().optional(), moderationGateMode: z.enum(["inherit", "off", "enabled"]).optional(), - moderationRules: z.array(z.enum(["contributor_cap", "blacklist", "review_nag"])).optional(), + moderationRules: z.array(z.enum(["contributor_cap", "blacklist", "review_nag", "review_evasion"])).optional(), moderationWarningLabel: z.string().optional(), moderationBannedLabel: z.string().optional(), + reviewEvasionProtection: z.enum(["off", "close"]).optional(), + reviewEvasionLabel: z.string().nullable().optional(), + reviewEvasionComment: z.boolean().optional(), createdAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(), }) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 839386eeda..0d7b39b68e 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -59,8 +59,11 @@ import { hasAuditEventForDelivery, recordGateBlockOutcome, getGateBlockOutcome, + hasActiveReviewForHeadSha, isGlobalAgentFrozen, markGateOutcomeOverridden, + startActiveReviewTracking, + terminalizeActiveReviewTracking, recordProductUsageEvent, persistSignalSnapshot, recordWebhookEvent, @@ -233,6 +236,7 @@ import { import { DEFAULT_AUTO_MAINTAIN_POLICY, autonomyRequiresApproval, + isActingAutonomyLevel, isAgentConfigured, resolveAutonomy, } from "../settings/autonomy"; @@ -262,6 +266,7 @@ import { import { aiReviewCacheInputFingerprint } from "../review/ai-review-cache-input"; import { AGENT_LABEL_NEEDS_REVIEW, + DEFAULT_REVIEW_EVASION_LABEL, downgradeCloseToHold, downgradeMergeToHold, MAX_REVIEW_NAG_COOLDOWN_DAYS, @@ -275,6 +280,7 @@ import { resolveGlobalContributorOpenItemCap } from "../settings/global-contribu import { detectMigrationCollisions, extractMigrationNumber, KNOWN_MIGRATION_DUPLICATES } from "../db/migration-collisions"; import { listMigrationFilenamesAtRef } from "../github/migration-tree"; import { + applyModerationEscalationForRule, executeAgentMaintenanceActions, executeIssueMaintenanceActions, pendingClosureLabelApplied, @@ -440,6 +446,7 @@ import { createIssueComment, getLastCloserLogin, getLastReopenerLogin, + reopenPullRequest, } from "../github/pr-actions"; import { loadLinkedIssueHardRules, @@ -5316,6 +5323,15 @@ async function processGitHubWebhook( if (eventName === "pull_request" && (payload.action === "synchronize" || payload.action === "closed" || payload.action === "reopened")) { await invalidatePrStateCache(env, repoFullName, pr.number).catch(() => undefined); } + // Review-evasion protection (#review-evasion-protection): a head change (synchronize) invalidates any + // active-review tracking for the OLD head immediately -- a fresh pass starts its own tracking later in + // this same handler. Best-effort; the guarded CAS update is a safe no-op when nothing is active. The + // "closed" case is handled AFTER the self-close/converted_to_draft evasion checks below, not here -- + // those checks must read the row before this general cleanup would otherwise clear it out from under + // them. + if (eventName === "pull_request" && payload.action === "synchronize") { + await terminalizeActiveReviewTracking(env, repoFullName, pr.number).catch(() => undefined); + } // Reopen-prevention (#one-shot-reopen): a CONTRIBUTOR may not reopen a PR that gittensory or a maintainer // closed — closes are one-shot (resubmit, don't reopen). If a non-maintainer reopened a PR whose last close // was by the bot / repo owner / admin, re-close it and skip the re-review. Self-closes (the contributor @@ -5401,6 +5417,21 @@ async function processGitHubWebhook( eventName, action: payload.action, }); + // Review-evasion protection (#review-evasion-protection): a contributor closing their OWN PR while + // gittensory has an ACTIVE review pass running is dodging the one-shot review, not making an ordinary + // close. Runs regardless of the general draft-dodge/reopen-reclose gates above -- it is its own + // independent enforcement, config-gated on settings.reviewEvasionProtection (off by default). + if (payload.action === "closed" && installationId) { + await maybeCloseReviewEvasionSelfClose( + env, + deliveryId, + installationId, + repoFullName, + pr, + payload, + settings, + ); + } // Draft-dodge guard (#converted-to-draft): a contributor converting an OPEN PR to draft cannot use // draft state to keep a gate-rejected PR alive. When a prior gate failure exists for the PR's current // headSha (and the block has not been maintainer-overridden), close the PR immediately — the gate @@ -5428,6 +5459,32 @@ async function processGitHubWebhook( settings, ); } + // Review-evasion protection: the active-review sibling of the draft-dodge guard above -- fires + // regardless of whether a PRIOR gate failure exists (draft-dodge's own trigger), as long as a review + // pass is CURRENTLY active for this head. Naturally near-mutually-exclusive with draft-dodge in + // practice: the same pass that records a gate-block-outcome also terminalizes the active-review row it + // was tracking, so by the time draft-dodge's prior-gate-failure condition is true, this guard's + // active-review condition is normally already false. + if (payload.action === "converted_to_draft" && installationId) { + await maybeCloseReviewEvasionDraftConversion( + env, + deliveryId, + installationId, + repoFullName, + pr, + payload, + settings, + ); + } + // Review-evasion protection: the "closed" half of the active-review-tracking cleanup (the + // "synchronize" half runs earlier, alongside invalidatePrStateCache). Deliberately placed AFTER the + // self-close-evasion check above so that check reads the tracking row before this general cleanup + // would otherwise clear it out from under it -- a normal close and this repo's own evasion-enforcement + // close (which already terminalizes internally, scoped to its own head) both land here too; the + // guarded CAS update is a safe no-op in both of those already-terminal cases. + if (eventName === "pull_request" && payload.action === "closed") { + await terminalizeActiveReviewTracking(env, repoFullName, pr.number).catch(() => undefined); + } if ( installationId && shouldProcessPullRequestPublicSurface(eventName, payload.action) @@ -7831,6 +7888,28 @@ async function maybePublishPrPublicSurface( }).catch(() => undefined); } } + // Review-evasion protection (#review-evasion-protection): durably record that a review pass is starting + // for this EXACT head BEFORE any cost-bearing AI-review work begins (including the reviewing placeholder + // below), so a contributor who closes/converts-to-draft their PR from this point until the pass concludes + // is dodging an ACTIVE review, not making an ordinary close. Gated on aiReviewWillRun (not the narrower + // shouldPostPlaceholder below, which also requires willComment -- a check-run-only repo still runs a real + // review and must still be protected); aiReviewWillRun already folds in !isFrozenForManualReview, so a PR + // held for manual review (reusing a frozen prior verdict, not doing fresh work) never starts tracking here + // -- there is no active pass for a contributor to evade in that case. Best-effort: a failed write only + // means this ONE pass is not evasion-protected, never a mutation failure. Terminalized once the gate + // decision concludes (below). + if (aiReviewWillRun && pr.headSha) { + await startActiveReviewTracking(env, { + repoFullName, + pullNumber: pr.number, + headSha: pr.headSha, + authorLogin: author, + deliveryId: webhook.deliveryId, + }).catch( + /* v8 ignore next -- fail-safe: a failed tracking write only means this ONE pass is not evasion-protected. */ + () => undefined, + ); + } // Post a transient "🟪 reviewing…" placeholder BEFORE the review refresh runs so contributors never see a // stale green/yellow/red verdict while the current head is being recomputed. In-place upsert: once the final // verdict is ready it overwrites this comment. GitHub rate-limits still abort so the queue can retry instead @@ -8342,6 +8421,14 @@ async function maybePublishPrPublicSurface( reasonCode, }); } + // Review-evasion protection (#review-evasion-protection): the cost-bearing review pass for this head has + // now concluded (the gate decision is made) -- terminalize the active-review row so a close/draft-convert + // AFTER this point is treated as an ordinary action, not evasion of a still-running review. Scoped to this + // head so a slower, superseded pass can never clear a NEWER pass's still-active tracking. Symmetric with + // the startActiveReviewTracking call above (same aiReviewWillRun gate). + if (aiReviewWillRun && pr.headSha) { + await terminalizeActiveReviewTracking(env, repoFullName, pr.number, { onlyIfHeadSha: pr.headSha }).catch(() => undefined); + } // #regate-churn (req 6/7): a public-surface no-op guard, deliberately narrow. markPullRequestSurfacePublished's // own doc comment warns lastPublishedSurfaceSha is "reporting/diagnostic state, not a hard scheduled-sweep // skip" because a comment can be stale or partial even when the head marker matches — so this ONLY applies to @@ -10375,6 +10462,455 @@ async function recloseDisallowedReopenIfNeeded( return true; } +// Audit eventType for every review-evasion enforcement outcome (#review-evasion-protection). Shared by both +// the self-close and converted_to_draft evasion handlers below so a cross-repo query can scope to exactly +// this family, mirroring github_app.draft_dodge_closed / github_app.reopen_reclosed. +const REVIEW_EVASION_CLOSED_EVENT_TYPE = "github_app.review_evasion_closed"; + +// Whether `login` holds a maintainer-equivalent permission on repoFullName -- the owner, an ADMIN_GITHUB_LOGINS +// entry, or a collaborator with admin/maintain/write access. Shared by both review-evasion guards below; +// mirrors recloseDisallowedReopenIfNeeded's identical `hasMaintainerPermission` closure (kept as a standalone +// function here since the two guards below do not share an enclosing scope to close over). +async function hasMaintainerOrOwnerPermission(env: Env, installationId: number, repoFullName: string, login: string): Promise { + // The ": \"\"" fallback is unreachable via the real webhook path: repoFullName is always the + // "owner/repo"-formatted payload.repository.full_name, and the surrounding pipeline already requires a + // repository match on that exact format before any review-evasion handler runs. + /* v8 ignore next */ + const repoOwner = repoFullName.includes("/") ? repoFullName.slice(0, repoFullName.indexOf("/")).toLowerCase() : ""; + if (login === repoOwner || parseGitHubLoginList(env.ADMIN_GITHUB_LOGINS).has(login)) return true; + const permission = await getRepositoryCollaboratorPermission(env, installationId, repoFullName, login).catch(() => null); + return permission === "admin" || permission === "maintain" || permission === "write"; +} + +/** Review-evasion protection (#review-evasion-protection): a CONTRIBUTOR closing their OWN PR while + * gittensory has an ACTIVE review pass running against its current headSha is dodging the one-shot review, + * not making an ordinary close. GitHub lets a contributor reopen a PR they closed themselves but NOT one + * closed by a maintainer or the App (#one-shot-reopen) -- so this reopens the PR (as the App) and + * immediately re-closes it (as the App), converting the contributor's own close into an App-authored, + * terminal one they cannot reopen; any later reopen attempt is caught by the EXISTING + * maybeRecloseDisallowedReopen guard above. Per-PR actuation-locked like its draft-dodge/reopen-reclose + * siblings. The strike only counts once the enforcement close actually succeeds. */ +async function maybeCloseReviewEvasionSelfClose( + env: Env, + deliveryId: string, + installationId: number, + repoFullName: string, + pr: PullRequestRecord, + payload: GitHubWebhookPayload, + settings: RepositorySettings, +): Promise { + const actuationLock = await claimPrActuationLock(env, repoFullName, pr.number); + if (!actuationLock.acquired) { + throw new PrActuationLockContendedError(repoFullName, pr.number, "review-evasion-self-close"); + } + try { + await closeReviewEvasionSelfCloseIfActive(env, deliveryId, installationId, repoFullName, pr, payload, settings); + } finally { + await releasePrActuationLock(env, repoFullName, pr.number, actuationLock.ownerToken); + } +} + +async function closeReviewEvasionSelfCloseIfActive( + env: Env, + deliveryId: string, + installationId: number, + repoFullName: string, + pr: PullRequestRecord, + payload: GitHubWebhookPayload, + settings: RepositorySettings, +): Promise { + if ((settings.reviewEvasionProtection ?? "off") !== "close") return; + const closer = (payload.sender?.login ?? "").toLowerCase(); + const authorLogin = (pr.authorLogin ?? "").toLowerCase(); + // Only the PR's OWN author closing their OWN PR is a self-close-evasion candidate -- a third party (e.g. a + // maintainer) closing someone else's PR is an ordinary maintainer action, not evasion. + if (!closer || !authorLogin || closer !== authorLogin) return; + if (isProtectedAutomationAuthor(pr.authorLogin)) return; + if (!pr.headSha) return; + if (await hasMaintainerOrOwnerPermission(env, installationId, repoFullName, authorLogin)) return; + if (!(await hasActiveReviewForHeadSha(env, repoFullName, pr.number, pr.headSha))) return; + + const targetKey = `${repoFullName}#${pr.number}`; + const evasionMode = resolveAgentActionMode({ + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + agentPaused: settings.agentPaused, + agentDryRun: settings.agentDryRun, + }); + if (!isActingAutonomyLevel(resolveAutonomy(settings.autonomy, "close"))) { + await recordAuditEvent(env, { + eventType: REVIEW_EVASION_CLOSED_EVENT_TYPE, + actor: "gittensory", + targetKey, + outcome: "denied", + detail: `autonomy for close is not acting -- review-evasion self-close not enforced for ${pr.authorLogin}`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + return; + } + if (evasionMode === "dry_run") { + await recordAuditEvent(env, { + eventType: REVIEW_EVASION_CLOSED_EVENT_TYPE, + actor: "gittensory", + targetKey, + outcome: "completed", + detail: `dry-run: would reopen + re-close review-evasion self-close by ${pr.authorLogin} -- active review on headSha ${pr.headSha}`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha, mode: "dry_run" }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + return; + } + if (evasionMode !== "live") { + // paused/frozen -- a complete stop, matching the draft-dodge/reopen-reclose siblings' identical gate. + await recordAuditEvent(env, { + eventType: REVIEW_EVASION_CLOSED_EVENT_TYPE, + actor: "gittensory", + targetKey, + outcome: "denied", + detail: `agent actions paused -- review-evasion self-close not enforced for ${pr.authorLogin}`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + return; + } + // Write-permission readiness (#2134-style): this enforcement bypasses executeAgentMaintenanceActions + // entirely (like its draft-dodge/reopen-reclose siblings), so it never got the standard pipeline's + // PR_WRITE_CLASSES guard. + const installation = await getInstallation(env, installationId); + const installationPermissions = installation?.permissions ?? null; + if (resolveAgentPermissionReadiness({ autonomy: settings.autonomy, installationPermissions, actionClass: "close" }) !== "ready") { + await recordAuditEvent(env, { + eventType: REVIEW_EVASION_CLOSED_EVENT_TYPE, + actor: "gittensory", + targetKey, + outcome: "denied", + detail: `denied review-evasion enforcement for ${pr.authorLogin} -- pull_requests: write not granted`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + return; + } + // Live re-check (#2130-style): the PR's live head/state may have moved since the webhook was ingested. + const freshness = await fetchPullRequestFreshness(env, { installationId, repoFullName, pullNumber: pr.number, expectedHeadSha: pr.headSha }); + if (freshness.status !== "current") { + await recordAuditEvent(env, { + eventType: REVIEW_EVASION_CLOSED_EVENT_TYPE, + actor: "gittensory", + targetKey, + outcome: "denied", + detail: `${pullRequestFreshnessDetail(freshness)} -- review-evasion enforcement not executed`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + return; + } + + const reopenError = await reopenPullRequest(env, installationId, repoFullName, pr.number) + .then(() => null) + .catch((error: unknown) => error); + if (reopenError !== null) { + await recordAuditEvent(env, { + eventType: REVIEW_EVASION_CLOSED_EVENT_TYPE, + actor: "gittensory", + targetKey, + outcome: "error", + detail: `FAILED to reopen ${pr.authorLogin}'s self-close for review-evasion enforcement -- the reopen API call did not succeed`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha, error: errorMessage(reopenError) }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + return; // the strike only counts once the enforcement close actually succeeds. + } + const closeError = await closePullRequest(env, installationId, repoFullName, pr.number) + .then(() => null) + .catch((error: unknown) => error); + if (closeError !== null) { + await recordAuditEvent(env, { + eventType: REVIEW_EVASION_CLOSED_EVENT_TYPE, + actor: "gittensory", + targetKey, + outcome: "error", + detail: `FAILED to re-close review-evasion self-close by ${pr.authorLogin} -- the reopen already succeeded, so the PR is live on GitHub as OPEN; retrying via the queue rather than leaving it that way`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha, error: errorMessage(closeError) }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + // Deliberately UNCAUGHT: the reopen above already succeeded, so returning normally here would silently + // leave the PR OPEN on GitHub -- worse than the contributor's original close, and the whole point of + // this enforcement. Propagate so the queue's own retry/backoff re-processes this job; on retry, the live + // freshness check earlier in this function will see the PR as open (current, since we just reopened it) + // and this handler will attempt the re-close again, converging once closePullRequest actually succeeds. + throw closeError instanceof Error ? closeError : new Error(errorMessage(closeError)); + } + + // The close succeeded: post the public explanation, apply the configured label, record the strike -- in + // that order, after the enforcement close is confirmed (never before). + const shouldPostSelfCloseComment = settings.reviewEvasionComment ?? true; + if (shouldPostSelfCloseComment) { + await createIssueComment( + env, + installationId, + repoFullName, + pr.number, + "Gittensory had already started reviewing this pull request — closing it to dodge the one-shot review process is not allowed. Please open a new pull request with the issues addressed.", + ).catch( + /* v8 ignore next -- fail-safe: a courtesy-comment failure never blocks the handler. */ + () => undefined, + ); + } + const label = settings.reviewEvasionLabel === null ? null : (settings.reviewEvasionLabel ?? DEFAULT_REVIEW_EVASION_LABEL); + if (label !== null) { + await ensurePullRequestLabel(env, installationId, repoFullName, pr.number, label, { createMissingLabel: true }).catch(() => undefined); + } + await recordAuditEvent(env, { + eventType: REVIEW_EVASION_CLOSED_EVENT_TYPE, + actor: "gittensory", + targetKey, + outcome: "completed", + detail: `re-closed a review-evasion self-close by ${pr.authorLogin} -- active review on headSha ${pr.headSha} was in progress`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + await terminalizeActiveReviewTracking(env, repoFullName, pr.number, { onlyIfHeadSha: pr.headSha }).catch(() => undefined); + // unreachable implicit-else: the actor guard above already proved pr.authorLogin is a non-empty string + // (closer/authorLogin are both derived from it and must be truthy to reach this point); the check only + // exists to narrow the type for applyModerationEscalationForRule's non-nullable authorLogin param. + /* v8 ignore else */ + if (pr.authorLogin) { + await applyModerationEscalationForRule(env, { + installationId, + repoFullName, + number: pr.number, + authorLogin: pr.authorLogin, + rule: "review_evasion", + moderationSettings: { + moderationGateMode: settings.moderationGateMode, + moderationRules: settings.moderationRules, + moderationWarningLabel: settings.moderationWarningLabel, + moderationBannedLabel: settings.moderationBannedLabel, + }, + }).catch( + /* v8 ignore next -- fail-safe: an escalation failure never blocks the (already-completed) close. */ + () => undefined, + ); + } +} + +/** Review-evasion protection (#review-evasion-protection): a contributor converting their OWN OPEN PR to + * draft while gittensory has an ACTIVE review pass running against its current headSha is dodging the + * one-shot review, distinct from the EXISTING draft-dodge guard above (which only fires after a PRIOR gate + * FAILURE on this head). Unlike the self-close sibling, converting to draft never closes the PR on GitHub, + * so no reopen step is needed -- a direct close, exactly like the draft-dodge guard's own close step, + * suffices. Per-PR actuation-locked like its draft-dodge/reopen-reclose/self-close siblings. */ +async function maybeCloseReviewEvasionDraftConversion( + env: Env, + deliveryId: string, + installationId: number, + repoFullName: string, + pr: PullRequestRecord, + payload: GitHubWebhookPayload, + settings: RepositorySettings, +): Promise { + const actuationLock = await claimPrActuationLock(env, repoFullName, pr.number); + if (!actuationLock.acquired) { + throw new PrActuationLockContendedError(repoFullName, pr.number, "review-evasion-draft"); + } + try { + await closeReviewEvasionDraftConversionIfActive(env, deliveryId, installationId, repoFullName, pr, payload, settings); + } finally { + await releasePrActuationLock(env, repoFullName, pr.number, actuationLock.ownerToken); + } +} + +async function closeReviewEvasionDraftConversionIfActive( + env: Env, + deliveryId: string, + installationId: number, + repoFullName: string, + pr: PullRequestRecord, + payload: GitHubWebhookPayload, + settings: RepositorySettings, +): Promise { + if ((settings.reviewEvasionProtection ?? "off") !== "close") return; + const converter = (payload.sender?.login ?? "").toLowerCase(); + const authorLogin = (pr.authorLogin ?? "").toLowerCase(); + // Only the PR's OWN author converting their OWN PR to draft is a draft-conversion-evasion candidate -- a + // third party (e.g. a maintainer converting a contributor's PR to draft) is an ordinary maintainer action, + // not evasion, and must never be enforced against the AUTHOR who didn't do it (mirrors the self-close + // sibling's identical actor check). + if (!converter || !authorLogin || converter !== authorLogin) return; + if (isProtectedAutomationAuthor(pr.authorLogin)) return; + if (!pr.headSha) return; + if (await hasMaintainerOrOwnerPermission(env, installationId, repoFullName, authorLogin)) return; + if (!(await hasActiveReviewForHeadSha(env, repoFullName, pr.number, pr.headSha))) return; + + const targetKey = `${repoFullName}#${pr.number}`; + const evasionMode = resolveAgentActionMode({ + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + agentPaused: settings.agentPaused, + agentDryRun: settings.agentDryRun, + }); + if (!isActingAutonomyLevel(resolveAutonomy(settings.autonomy, "close"))) { + await recordAuditEvent(env, { + eventType: REVIEW_EVASION_CLOSED_EVENT_TYPE, + actor: "gittensory", + targetKey, + outcome: "denied", + detail: `autonomy for close is not acting -- review-evasion draft-conversion not enforced for ${pr.authorLogin}`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + return; + } + if (evasionMode === "dry_run") { + await recordAuditEvent(env, { + eventType: REVIEW_EVASION_CLOSED_EVENT_TYPE, + actor: "gittensory", + targetKey, + outcome: "completed", + detail: `dry-run: would close review-evasion draft-conversion by ${pr.authorLogin} -- active review on headSha ${pr.headSha}`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha, mode: "dry_run" }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + return; + } + if (evasionMode !== "live") { + await recordAuditEvent(env, { + eventType: REVIEW_EVASION_CLOSED_EVENT_TYPE, + actor: "gittensory", + targetKey, + outcome: "denied", + detail: `agent actions paused -- review-evasion draft-conversion not enforced for ${pr.authorLogin}`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + return; + } + const installation = await getInstallation(env, installationId); + const installationPermissions = installation?.permissions ?? null; + if (resolveAgentPermissionReadiness({ autonomy: settings.autonomy, installationPermissions, actionClass: "close" }) !== "ready") { + await recordAuditEvent(env, { + eventType: REVIEW_EVASION_CLOSED_EVENT_TYPE, + actor: "gittensory", + targetKey, + outcome: "denied", + detail: `denied review-evasion enforcement for ${pr.authorLogin} -- pull_requests: write not granted`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + return; + } + // requireDraft: the justification evaporates if the author converted the PR BACK to ready_for_review in + // the window between ingestion and this check, mirroring the draft-dodge guard's identical fix (#2130). + const freshness = await fetchPullRequestFreshness(env, { installationId, repoFullName, pullNumber: pr.number, expectedHeadSha: pr.headSha, requireDraft: true }); + if (freshness.status !== "current") { + await recordAuditEvent(env, { + eventType: REVIEW_EVASION_CLOSED_EVENT_TYPE, + actor: "gittensory", + targetKey, + outcome: "denied", + detail: `${pullRequestFreshnessDetail(freshness)} -- review-evasion enforcement not executed`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + return; + } + + const closeError = await closePullRequest(env, installationId, repoFullName, pr.number) + .then(() => null) + .catch((error: unknown) => error); + if (closeError !== null) { + await recordAuditEvent(env, { + eventType: REVIEW_EVASION_CLOSED_EVENT_TYPE, + actor: "gittensory", + targetKey, + outcome: "error", + detail: `FAILED to close review-evasion draft-conversion by ${pr.authorLogin} -- the close API call did not succeed; the PR may still be open`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha, error: errorMessage(closeError) }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + return; // the strike only counts once the enforcement close actually succeeds. + } + + const shouldPostDraftConversionComment = settings.reviewEvasionComment ?? true; + if (shouldPostDraftConversionComment) { + await createIssueComment( + env, + installationId, + repoFullName, + pr.number, + "Gittensory had already started reviewing this pull request — converting it to draft to dodge the one-shot review process is not allowed. Please open a new pull request with the issues addressed.", + ).catch( + /* v8 ignore next -- fail-safe: a courtesy-comment failure never blocks the handler. */ + () => undefined, + ); + } + const label = settings.reviewEvasionLabel === null ? null : (settings.reviewEvasionLabel ?? DEFAULT_REVIEW_EVASION_LABEL); + if (label !== null) { + await ensurePullRequestLabel(env, installationId, repoFullName, pr.number, label, { createMissingLabel: true }).catch(() => undefined); + } + await recordAuditEvent(env, { + eventType: REVIEW_EVASION_CLOSED_EVENT_TYPE, + actor: "gittensory", + targetKey, + outcome: "completed", + detail: `closed a review-evasion draft-conversion by ${pr.authorLogin} -- active review on headSha ${pr.headSha} was in progress`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + await terminalizeActiveReviewTracking(env, repoFullName, pr.number, { onlyIfHeadSha: pr.headSha }).catch(() => undefined); + // unreachable implicit-else: the actor guard above already proved pr.authorLogin is a non-empty string + // (converter/authorLogin are both derived from it and must be truthy to reach this point); the check only + // exists to narrow the type for applyModerationEscalationForRule's non-nullable authorLogin param. + /* v8 ignore else */ + if (pr.authorLogin) { + await applyModerationEscalationForRule(env, { + installationId, + repoFullName, + number: pr.number, + authorLogin: pr.authorLogin, + rule: "review_evasion", + moderationSettings: { + moderationGateMode: settings.moderationGateMode, + moderationRules: settings.moderationRules, + moderationWarningLabel: settings.moderationWarningLabel, + moderationBannedLabel: settings.moderationBannedLabel, + }, + }).catch( + /* v8 ignore next -- fail-safe: an escalation failure never blocks the (already-completed) close. */ + () => undefined, + ); + } +} + // Audit eventType for one recorded @gittensory ping (#2463). Shared between the recorder below and the // cooldown-window count query so a naming drift can't silently under/over-count. const REVIEW_NAG_PING_EVENT_TYPE = "github_app.review_nag_ping"; diff --git a/src/services/agent-action-executor.ts b/src/services/agent-action-executor.ts index 9052a544f6..62a5d97e67 100644 --- a/src/services/agent-action-executor.ts +++ b/src/services/agent-action-executor.ts @@ -429,31 +429,26 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE const MODERATION_RULE_TYPES = new Set(Object.keys(MODERATION_VIOLATION_EVENT_TYPE)); /** - * Moderation-rules engine (#selfhost-mod-engine): a SINGLE convergence point for all three anti-abuse - * mechanisms (blacklist, contributor cap, review-nag) that already tag their `close` action with a matching - * `closeKind` -- rather than duplicating this wiring at every one of their several call sites in - * `queue/processors.ts`, this scans the JUST-EXECUTED plan for a moderation-tracked close that actually - * COMPLETED (not denied/queued/dry-run -- an action that didn't really happen must not count as a violation) - * and, if so, records one violation + escalates. Never throws: every write here is best-effort, matching how - * the rest of this file treats CI-cancellation/notification side effects as non-critical to the close itself. - * A no-op in `dry_run`/`paused` mode (no label/ban side effects for a mutation that didn't really happen). + * Moderation-rules engine (#selfhost-mod-engine / #review-evasion-protection): given that a moderation- + * tracked enforcement action for `rule` ALREADY COMPLETED against `authorLogin` on `repoFullName#number`, + * record the violation (idempotent), count the actor's currently-effective-rule violations, and apply the + * warning/banned label + auto-blacklist -- the SAME escalation every anti-abuse mechanism in this codebase + * shares. Extracted so the planner-driven path below (`maybeEscalateModeration`) and the direct webhook- + * driven review-evasion enforcement handlers in `queue/processors.ts` -- which bypass the planner/executor + * pipeline entirely, mirroring the existing draft-dodge/reopen-reclose direct-handler shape -- both reach the + * SAME escalation behavior once their own enforcement close succeeds. Never throws: every write here is + * best-effort, matching how the rest of this file treats CI-cancellation/notification side effects as + * non-critical to the close itself. A no-op when the moderation layer (global or per-repo) does not + * currently count `rule`. */ -async function maybeEscalateModeration( +export async function applyModerationEscalationForRule( env: Env, - args: { installationId: number; repoFullName: string; number: number; authorLogin?: string | null | undefined; mode: AgentActionMode; moderationSettings: ModerationContextSettings | undefined }, - planned: PlannedAgentAction[], - outcomes: AgentActionOutcome[], + args: { installationId: number; repoFullName: string; number: number; authorLogin: string; rule: ModerationRuleType; moderationSettings: ModerationContextSettings | undefined }, ): Promise { - if (!args.authorLogin || args.mode !== "live") return; - const index = planned.findIndex((action, i) => action.actionClass === "close" && action.closeKind !== undefined && MODERATION_RULE_TYPES.has(action.closeKind) && outcomes[i]?.outcome === "completed"); - const closeKind = index === -1 ? undefined : planned[index]?.closeKind; - if (closeKind === undefined) return; - const rule = closeKind as ModerationRuleType; - const globalConfig = await getGlobalModerationConfig(env); if (!resolveModerationGateEnabled(globalConfig.enabled, args.moderationSettings?.moderationGateMode ?? "inherit")) return; const effectiveRules = resolveEffectiveModerationRules(globalConfig.rules, args.moderationSettings?.moderationRules); - if (!effectiveRules.includes(rule)) return; + if (!effectiveRules.includes(args.rule)) return; const targetKey = `${args.repoFullName}#${args.number}`; // #gate-flagged: idempotent per (actor, eventType, targetKey) -- a webhook redelivery or queue retry that @@ -461,7 +456,7 @@ async function maybeEscalateModeration( // (re-labeling/re-checking the ban threshold off a stale "nothing new happened" pass is redundant, not just // harmless). A write failure fails OPEN (treated as "new"), matching this function's existing best-effort // philosophy elsewhere -- a lost write should not also silently suppress the escalation it was recording for. - const isNewViolation = await recordModerationViolation(env, { eventType: MODERATION_VIOLATION_EVENT_TYPE[rule], actor: args.authorLogin, targetKey, repoFullName: args.repoFullName, ruleReason: `${rule} violation` }).catch(() => true); + const isNewViolation = await recordModerationViolation(env, { eventType: MODERATION_VIOLATION_EVENT_TYPE[args.rule], actor: args.authorLogin, targetKey, repoFullName: args.repoFullName, ruleReason: `${args.rule} violation` }).catch(() => true); if (!isNewViolation) return; // #gate-flagged: count only the CURRENTLY-effective rule types, not every rule type ever recorded. A rule @@ -493,6 +488,35 @@ async function maybeEscalateModeration( } } +/** + * Moderation-rules engine (#selfhost-mod-engine): a SINGLE convergence point for the three planner-staged + * anti-abuse mechanisms (blacklist, contributor cap, review-nag) that already tag their `close` action with a + * matching `closeKind` -- rather than duplicating this wiring at every one of their several call sites in + * `queue/processors.ts`, this scans the JUST-EXECUTED plan for a moderation-tracked close that actually + * COMPLETED (not denied/queued/dry-run -- an action that didn't really happen must not count as a violation) + * and, if so, delegates to {@link applyModerationEscalationForRule}. A no-op in `dry_run`/`paused` mode (no + * label/ban side effects for a mutation that didn't really happen). + */ +async function maybeEscalateModeration( + env: Env, + args: { installationId: number; repoFullName: string; number: number; authorLogin?: string | null | undefined; mode: AgentActionMode; moderationSettings: ModerationContextSettings | undefined }, + planned: PlannedAgentAction[], + outcomes: AgentActionOutcome[], +): Promise { + if (!args.authorLogin || args.mode !== "live") return; + const index = planned.findIndex((action, i) => action.actionClass === "close" && action.closeKind !== undefined && MODERATION_RULE_TYPES.has(action.closeKind) && outcomes[i]?.outcome === "completed"); + const closeKind = index === -1 ? undefined : planned[index]?.closeKind; + if (closeKind === undefined) return; + await applyModerationEscalationForRule(env, { + installationId: args.installationId, + repoFullName: args.repoFullName, + number: args.number, + authorLogin: args.authorLogin, + rule: closeKind as ModerationRuleType, + moderationSettings: args.moderationSettings, + }); +} + /** CI-run cancellation on a contributor_cap close (#2462): runs cancelInFlightWorkflowRunsForHeadSha and * records exactly one of two audit outcomes, mirroring the established `github_app.*_permission_missing` * convention (processors.ts's check-run/gate-check permission-missing audits) so a fleet-wide actions:write diff --git a/src/settings/agent-actions.ts b/src/settings/agent-actions.ts index 76c5b65752..fd1b41f28d 100644 --- a/src/settings/agent-actions.ts +++ b/src/settings/agent-actions.ts @@ -32,6 +32,13 @@ export const DEFAULT_CONTRIBUTOR_CAP_LABEL = "over-contributor-limit"; // configurable per-repo via `.gittensory.yml` (`settings.reviewNagLabel`); the planner uses the resolved label // and falls back to this default, mirroring DEFAULT_BLACKLIST_LABEL's shape. export const DEFAULT_REVIEW_NAG_LABEL = "review-nag-cooldown"; +// Default label applied to a PR re-closed for review-evasion (#review-evasion-protection): a contributor +// closing/converting-to-draft their own PR while an active review pass is running. NOT hardcoded -- a repo +// can override it via `.gittensory.yml` (`settings.reviewEvasionLabel`); this is only the fallback when unset. +// Applied by the direct webhook-driven enforcement handlers in queue/processors.ts, which bypass the planner +// (mirroring the existing draft-dodge/reopen-reclose guards' shape), so it is not consumed by +// planAgentMaintenanceActions -- it lives alongside its siblings here purely for discoverability. +export const DEFAULT_REVIEW_EVASION_LABEL = "review-evasion"; // Keep the review-nag lookback operationally bounded so repo-controlled config cannot overflow Date arithmetic. export const MAX_REVIEW_NAG_COOLDOWN_DAYS = 365; // Default label for a PR that PASSES the gate but is intentionally held for manual review. This is only the diff --git a/src/settings/moderation-rules.ts b/src/settings/moderation-rules.ts index 2a1bf9a135..4a6afc3ebf 100644 --- a/src/settings/moderation-rules.ts +++ b/src/settings/moderation-rules.ts @@ -11,11 +11,13 @@ // that can turn the layer off/on for just that repo and override which rules feed IT specifically. NEVER // hard-coded for any one repo -- a self-hoster's own `.gittensory.yml`/dashboard settings choose everything. -/** The three EXISTING anti-abuse mechanisms this engine can count violations from. Kept as a closed union - * (not an open string) so an unrecognized value is always a normalization error, never silently accepted. */ -export type ModerationRuleType = "contributor_cap" | "blacklist" | "review_nag"; +/** The anti-abuse mechanisms this engine can count violations from -- the three ORIGINAL mechanisms + * (contributor cap, blacklist, review-nag) plus review-evasion (#review-evasion-protection: a contributor + * closing/converting-to-draft their own PR to dodge an active review). Kept as a closed union (not an open + * string) so an unrecognized value is always a normalization error, never silently accepted. */ +export type ModerationRuleType = "contributor_cap" | "blacklist" | "review_nag" | "review_evasion"; -const ALL_MODERATION_RULE_TYPES: readonly ModerationRuleType[] = ["contributor_cap", "blacklist", "review_nag"]; +const ALL_MODERATION_RULE_TYPES: readonly ModerationRuleType[] = ["contributor_cap", "blacklist", "review_nag", "review_evasion"]; /** The `audit_events.event_type` recorded for each rule's violation -- namespaced under `moderation.violation.*` * so a cross-eventType, cross-repo count query (see `db/repositories.ts`) can scope to exactly this family. */ @@ -23,6 +25,7 @@ export const MODERATION_VIOLATION_EVENT_TYPE: Record contributor_cap: "moderation.violation.contributor_cap", blacklist: "moderation.violation.blacklist", review_nag: "moderation.violation.review_nag", + review_evasion: "moderation.violation.review_evasion", }; export const DEFAULT_MODERATION_WARNING_LABEL = "mod:warning"; diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 9b97f18086..3da82606df 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -249,6 +249,9 @@ export type FocusManifestSettings = Partial< | "moderationRules" | "moderationWarningLabel" | "moderationBannedLabel" + | "reviewEvasionProtection" + | "reviewEvasionLabel" + | "reviewEvasionComment" > > & { // `typeLabels`/`linkedIssueLabelPropagation`/`linkedIssueHardRules` are declared PARTIAL here (not via the `Pick=1 lifetime violation. `undefined` ⇒ * the global config's `warningLabel` (itself defaulting to `"mod:warning"`). */ moderationWarningLabel?: string | undefined; /** Moderation-rules engine: per-repo override of the label applied at >= the ban threshold. `undefined` ⇒ * the global config's `bannedLabel` (itself defaulting to `"mod:banned"`). */ moderationBannedLabel?: string | undefined; + /** Review-evasion protection (#review-evasion-protection): a contributor closing or converting their OWN + * PR to draft while gittensory has an ACTIVE review pass running against it is dodging the one-shot + * review process. `"off"` (the default) disables detection entirely; `"close"` reopens (if needed) and + * re-closes as the App -- a close the contributor cannot themselves reopen (#one-shot-reopen) -- applies + * the configured label/comment, and records a `review_evasion` moderation strike. */ + reviewEvasionProtection?: "off" | "close" | undefined; + /** Review-evasion protection: label applied alongside the enforcement close, gated on `close` autonomy + * like every other anti-abuse label (#label-scoping), mirroring {@link blacklistLabel}'s shape. `undefined` + * ⇒ the `"review-evasion"` default; explicit `null` ⇒ close without any label. */ + reviewEvasionLabel?: string | null | undefined; + /** Review-evasion protection: whether to post the public explanation comment before the enforcement close. + * Default true. */ + reviewEvasionComment?: boolean | undefined; createdAt?: string | null | undefined; updatedAt?: string | null | undefined; }; diff --git a/test/unit/agent-action-executor.test.ts b/test/unit/agent-action-executor.test.ts index a99d02ed12..e9eae409d9 100644 --- a/test/unit/agent-action-executor.test.ts +++ b/test/unit/agent-action-executor.test.ts @@ -47,6 +47,7 @@ import { createInstallationToken } from "../../src/github/app"; import { fetchLiveCiAggregate, refreshInstallationHealthForInstallation } from "../../src/github/backfill"; import { actionParams, + applyModerationEscalationForRule, clearInstallationHealthRefreshCooldownForTest, clearWritePermissionDenialCooldownForTest, writePermissionDenialCooldownSizeForTest, @@ -1292,6 +1293,78 @@ describe("moderation-rules engine escalation (#selfhost-mod-engine)", () => { }); }); +describe("applyModerationEscalationForRule (#review-evasion-protection)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const call = (env: Env, over: Partial[1]> = {}) => + applyModerationEscalationForRule(env, { + installationId: 123, + repoFullName: "owner/repo", + number: 7, + authorLogin: "farmer99", + rule: "review_evasion", + moderationSettings: undefined, + ...over, + }); + + const GLOBAL_RULES_WITH_EVASION = ["contributor_cap", "blacklist", "review_nag", "review_evasion"] as const; + + it("review_evasion is NOT in the global config's default rule set -- it must be explicitly opted into (global or per-repo)", async () => { + const env = createTestEnv({}); + await upsertGlobalModerationConfig(env, { enabled: true }); // rules left at the DB default (the original three). + await call(env); + expect(ensurePullRequestLabel).not.toHaveBeenCalled(); + }); + + it("is the SAME escalation the planner-driven path uses -- a direct call for review_evasion applies the warning label once the global config explicitly includes it", async () => { + const env = createTestEnv({}); + await upsertGlobalModerationConfig(env, { enabled: true, rules: [...GLOBAL_RULES_WITH_EVASION] }); + await call(env); + expect(ensurePullRequestLabel).toHaveBeenCalledWith(env, 123, "owner/repo", 7, "mod:warning", { createMissingLabel: true }); + }); + + it("OFF by default: no label/ban when the global moderation config is disabled, even though reviewEvasionProtection has its own separate config", async () => { + const env = createTestEnv({}); + await call(env); + expect(ensurePullRequestLabel).not.toHaveBeenCalled(); + }); + + it("escalates to mod:banned + auto-blacklists at the configured threshold", async () => { + const env = createTestEnv({}); + await upsertGlobalModerationConfig(env, { enabled: true, rules: [...GLOBAL_RULES_WITH_EVASION], banThreshold: 1 }); + await call(env); + expect(ensurePullRequestLabel).toHaveBeenCalledWith(env, 123, "owner/repo", 7, "mod:banned", { createMissingLabel: true }); + const blacklist = await getGlobalContributorBlacklist(env); + expect(blacklist?.map((entry) => entry.login)).toContain("farmer99"); + }); + + it("per-repo moderationRules EXCLUDING review_evasion means a review_evasion call on THIS repo does not count as a violation", async () => { + const env = createTestEnv({}); + await upsertGlobalModerationConfig(env, { enabled: true, rules: [...GLOBAL_RULES_WITH_EVASION] }); + await call(env, { moderationSettings: { moderationRules: ["blacklist"] } }); + expect(ensurePullRequestLabel).not.toHaveBeenCalled(); + }); + + it("per-repo moderationRules INCLUDING review_evasion applies the escalation even when the global default excludes it", async () => { + const env = createTestEnv({}); + await upsertGlobalModerationConfig(env, { enabled: true }); // global rules default (no review_evasion). + await call(env, { moderationSettings: { moderationRules: ["review_evasion"] } }); + expect(ensurePullRequestLabel).toHaveBeenCalledWith(env, 123, "owner/repo", 7, "mod:warning", { createMissingLabel: true }); + }); + + it("REGRESSION: a webhook redelivery / retry that calls again for the SAME repo+number+actor does not double-count the violation (idempotent per targetKey)", async () => { + const env = createTestEnv({}); + await upsertGlobalModerationConfig(env, { enabled: true, rules: [...GLOBAL_RULES_WITH_EVASION], banThreshold: 2 }); + await call(env); + expect(ensurePullRequestLabel).toHaveBeenCalledWith(env, 123, "owner/repo", 7, "mod:warning", { createMissingLabel: true }); + vi.clearAllMocks(); + await call(env); // same repoFullName#number -- a redelivered enforcement, not a second real violation. + expect(ensurePullRequestLabel).not.toHaveBeenCalledWith(env, 123, "owner/repo", 7, "mod:banned", expect.anything()); + }); +}); + function issueCtx(over: Partial = {}): IssueActionExecutionContext { return { installationId: 123, diff --git a/test/unit/db-persistence.test.ts b/test/unit/db-persistence.test.ts index 6f2a925b5c..423b47f006 100644 --- a/test/unit/db-persistence.test.ts +++ b/test/unit/db-persistence.test.ts @@ -2,12 +2,15 @@ import { describe, expect, it } from "vitest"; import { getContributorScoringProfile, getOpenUpstreamDriftReportByFingerprint, + hasActiveReviewForHeadSha, listContributorRepoStats, listLatestRepoGithubTotalsSnapshots, listRepoPullRequestFilePaths, persistBountyLifecycleEvent, persistRegistryDriftEvents, persistRepoGithubTotalsSnapshot, + startActiveReviewTracking, + terminalizeActiveReviewTracking, updateUpstreamDriftReportIssue, upsertContributorRepoStat, upsertContributorScoringProfile, @@ -313,3 +316,85 @@ function contributorStat(login: string, repoFullName: string, pullRequests: numb lastActivityAt, }; } + +describe("active-review tracking (#review-evasion-protection)", () => { + async function rawRow(env: Env, repoFullName: string, pullNumber: number) { + return env.DB.prepare("select head_sha, author_login, delivery_id, status, started_at from active_review_tracking where repo_full_name = ? and pull_number = ?") + .bind(repoFullName, pullNumber) + .first<{ head_sha: string; author_login: string | null; delivery_id: string; status: string; started_at: string }>(); + } + + it("hasActiveReviewForHeadSha is false when no row exists at all", async () => { + const env = createTestEnv(); + expect(await hasActiveReviewForHeadSha(env, "owner/repo", 1, "sha1")).toBe(false); + }); + + it("starts tracking and reads it back for the exact head, but not a different head or PR", async () => { + const env = createTestEnv(); + await startActiveReviewTracking(env, { repoFullName: "owner/repo", pullNumber: 1, headSha: "sha1", authorLogin: "farmer99", deliveryId: "delivery-1" }); + expect(await hasActiveReviewForHeadSha(env, "owner/repo", 1, "sha1")).toBe(true); + expect(await hasActiveReviewForHeadSha(env, "owner/repo", 1, "sha-different")).toBe(false); + expect(await hasActiveReviewForHeadSha(env, "owner/repo", 2, "sha1")).toBe(false); + const row = await rawRow(env, "owner/repo", 1); + expect(row).toMatchObject({ head_sha: "sha1", author_login: "farmer99", delivery_id: "delivery-1", status: "active" }); + }); + + it("is idempotent for a redelivery/retry of the SAME head while still active -- startedAt/deliveryId are preserved, not clobbered", async () => { + const env = createTestEnv(); + await startActiveReviewTracking(env, { repoFullName: "owner/repo", pullNumber: 1, headSha: "sha1", authorLogin: "farmer99", deliveryId: "delivery-1" }); + const firstRow = await rawRow(env, "owner/repo", 1); + await startActiveReviewTracking(env, { repoFullName: "owner/repo", pullNumber: 1, headSha: "sha1", authorLogin: "farmer99", deliveryId: "delivery-RETRY" }); + const secondRow = await rawRow(env, "owner/repo", 1); + expect(secondRow?.delivery_id).toBe("delivery-1"); // NOT "delivery-RETRY" -- the original start wins. + expect(secondRow?.started_at).toBe(firstRow?.started_at); + }); + + it("a NEW head restarts the active window -- overwrites the row and clears the old head's match", async () => { + const env = createTestEnv(); + await startActiveReviewTracking(env, { repoFullName: "owner/repo", pullNumber: 1, headSha: "sha1", deliveryId: "delivery-1" }); + await startActiveReviewTracking(env, { repoFullName: "owner/repo", pullNumber: 1, headSha: "sha2", deliveryId: "delivery-2" }); + expect(await hasActiveReviewForHeadSha(env, "owner/repo", 1, "sha1")).toBe(false); + expect(await hasActiveReviewForHeadSha(env, "owner/repo", 1, "sha2")).toBe(true); + const row = await rawRow(env, "owner/repo", 1); + expect(row).toMatchObject({ head_sha: "sha2", delivery_id: "delivery-2", status: "active" }); + }); + + it("restarting on a PREVIOUSLY TERMINALIZED row for the SAME head still refreshes startedAt/deliveryId -- a terminal row is not treated as still-active", async () => { + const env = createTestEnv(); + await startActiveReviewTracking(env, { repoFullName: "owner/repo", pullNumber: 1, headSha: "sha1", deliveryId: "delivery-1" }); + await terminalizeActiveReviewTracking(env, "owner/repo", 1); + await startActiveReviewTracking(env, { repoFullName: "owner/repo", pullNumber: 1, headSha: "sha1", deliveryId: "delivery-2" }); + const row = await rawRow(env, "owner/repo", 1); + expect(row).toMatchObject({ head_sha: "sha1", delivery_id: "delivery-2", status: "active" }); + expect(await hasActiveReviewForHeadSha(env, "owner/repo", 1, "sha1")).toBe(true); + }); + + it("terminalizeActiveReviewTracking clears an active row and reports true; a second call is a no-op reporting false", async () => { + const env = createTestEnv(); + await startActiveReviewTracking(env, { repoFullName: "owner/repo", pullNumber: 1, headSha: "sha1", deliveryId: "delivery-1" }); + expect(await terminalizeActiveReviewTracking(env, "owner/repo", 1)).toBe(true); + expect(await hasActiveReviewForHeadSha(env, "owner/repo", 1, "sha1")).toBe(false); + expect(await terminalizeActiveReviewTracking(env, "owner/repo", 1)).toBe(false); + }); + + it("terminalizeActiveReviewTracking on a nonexistent row is a no-op reporting false", async () => { + const env = createTestEnv(); + expect(await terminalizeActiveReviewTracking(env, "owner/repo", 999)).toBe(false); + }); + + it("onlyIfHeadSha guards the terminalize: a mismatched head does not clear the row; a matching head does", async () => { + const env = createTestEnv(); + await startActiveReviewTracking(env, { repoFullName: "owner/repo", pullNumber: 1, headSha: "sha1", deliveryId: "delivery-1" }); + expect(await terminalizeActiveReviewTracking(env, "owner/repo", 1, { onlyIfHeadSha: "sha-wrong" })).toBe(false); + expect(await hasActiveReviewForHeadSha(env, "owner/repo", 1, "sha1")).toBe(true); // still active -- guarded, not cleared. + expect(await terminalizeActiveReviewTracking(env, "owner/repo", 1, { onlyIfHeadSha: "sha1" })).toBe(true); + expect(await hasActiveReviewForHeadSha(env, "owner/repo", 1, "sha1")).toBe(false); + }); + + it("startActiveReviewTracking without an authorLogin persists a null author (defensive -- a deleted-account PR yields a null login)", async () => { + const env = createTestEnv(); + await startActiveReviewTracking(env, { repoFullName: "owner/repo", pullNumber: 1, headSha: "sha1", deliveryId: "delivery-1" }); + const row = await rawRow(env, "owner/repo", 1); + expect(row?.author_login).toBeNull(); + }); +}); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index de1d87e527..17df669d3a 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -1643,6 +1643,7 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = changesRequestedLabel: null, migrationCollisionLabel: null, pendingClosureLabel: null, + reviewEvasionLabel: null, }, }); expect(cleared.settings.blacklistLabel).toBeNull(); @@ -1651,6 +1652,7 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = expect(cleared.settings.manualReviewLabel).toBeNull(); expect(cleared.settings.readyToMergeLabel).toBeNull(); expect(cleared.settings.changesRequestedLabel).toBeNull(); + expect(cleared.settings.reviewEvasionLabel).toBeNull(); expect(cleared.settings.migrationCollisionLabel).toBeNull(); expect(cleared.settings.pendingClosureLabel).toBeNull(); // Overlays (clears) a DB-configured label name. @@ -1844,6 +1846,38 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = expect(invalid.warnings.some((w) => /settings\.moderationGateMode/.test(w))).toBe(true); }); + it("moderationRules accepts review_evasion alongside the original three rule types (#review-evasion-protection)", () => { + const manifest = parseFocusManifest({ settings: { moderationRules: ["review_evasion", "not-a-rule" as never] } }); + expect(manifest.settings.moderationRules).toEqual(["review_evasion"]); + }); + + it("parses + resolves review-evasion protection settings from the settings: block, overlaying the DB (#review-evasion-protection)", () => { + const manifest = parseFocusManifest({ settings: { reviewEvasionProtection: "close", reviewEvasionLabel: "repo:evasion", reviewEvasionComment: false } }); + expect(manifest.settings.reviewEvasionProtection).toBe("close"); + expect(manifest.settings.reviewEvasionLabel).toBe("repo:evasion"); + expect(manifest.settings.reviewEvasionComment).toBe(false); + // yml overlays (replaces) the DB-configured values. + const eff = resolveEffectiveSettings( + { reviewEvasionProtection: "off", reviewEvasionLabel: "db:evasion", reviewEvasionComment: true } as unknown as RepositorySettings, + manifest, + ); + expect(eff.reviewEvasionProtection).toBe("close"); + expect(eff.reviewEvasionLabel).toBe("repo:evasion"); + expect(eff.reviewEvasionComment).toBe(false); + // Omitted in yml ⇒ the DB-configured values survive untouched. + const noOverride = resolveEffectiveSettings( + { reviewEvasionProtection: "close", reviewEvasionComment: false } as unknown as RepositorySettings, + parseFocusManifest({}), + ); + expect(noOverride.reviewEvasionProtection).toBe("close"); + expect(noOverride.reviewEvasionComment).toBe(false); + // An invalid enum / blank label is dropped with a warning rather than silently coerced. + const invalid = parseFocusManifest({ settings: { reviewEvasionProtection: "sometimes" as never, reviewEvasionLabel: " " } }); + expect(invalid.settings.reviewEvasionProtection).toBeUndefined(); + expect(invalid.settings.reviewEvasionLabel).toBeUndefined(); + expect(invalid.warnings.some((w) => /settings\.reviewEvasionProtection/.test(w))).toBe(true); + }); + describe("reviewCheckMode precedence (#2852)", () => { it("parses settings.reviewCheckMode and drops an invalid value with a warning", () => { const m = parseFocusManifest({ settings: { reviewCheckMode: "visible" } }); diff --git a/test/unit/github-pr-actions.test.ts b/test/unit/github-pr-actions.test.ts index ef60cf924e..f7a3d7f83e 100644 --- a/test/unit/github-pr-actions.test.ts +++ b/test/unit/github-pr-actions.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { generateKeyPairSync } from "node:crypto"; -import { closeIssue, closePullRequest, createIssueComment, createPullRequestReview, createPullRequestReviewComments, dismissLatestBotApproval, getLastCloserLogin, getLastReopenerLogin, mergePullRequest, updatePullRequestBranch } from "../../src/github/pr-actions"; +import { closeIssue, closePullRequest, createIssueComment, createPullRequestReview, createPullRequestReviewComments, dismissLatestBotApproval, getLastCloserLogin, getLastReopenerLogin, mergePullRequest, reopenPullRequest, updatePullRequestBranch } from "../../src/github/pr-actions"; import { clearInstallationTokenCacheForTest } from "../../src/github/app"; import { createTestEnv } from "../helpers/d1"; @@ -146,6 +146,24 @@ describe("GitHub PR action primitives (#778)", () => { expect(calls[0]?.url).toMatch(/\/repos\/owner\/repo\/pulls\/7$/); }); + it("reopens a PR via PATCH state=open (#review-evasion-protection)", async () => { + const calls: Array<{ method: string; url: string; body: Record }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + calls.push({ method: init?.method ?? "GET", url, body: init?.body ? JSON.parse(String(init.body)) : {} }); + return Response.json({ state: "open" }); + }); + const result = await reopenPullRequest(envWithKey(), 123, "owner/repo", 7); + expect(result).toEqual({ state: "open" }); + expect(calls[0]).toMatchObject({ method: "PATCH", body: { state: "open" } }); + expect(calls[0]?.url).toMatch(/\/repos\/owner\/repo\/pulls\/7$/); + }); + + it("reopenPullRequest validates the repo name before any GitHub call", async () => { + await expect(reopenPullRequest(createTestEnv(), 1, "invalid", 4)).rejects.toThrow(/Invalid repository full name/); + }); + it("closes an ISSUE via the issues endpoint, not the pulls endpoint (#2270)", async () => { const calls: Array<{ method: string; url: string; body: Record }> = []; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { diff --git a/test/unit/moderation-config-db.test.ts b/test/unit/moderation-config-db.test.ts index 22762c356e..7329f95c04 100644 --- a/test/unit/moderation-config-db.test.ts +++ b/test/unit/moderation-config-db.test.ts @@ -12,9 +12,13 @@ import { createTestEnv } from "../helpers/d1"; import { DEFAULT_GLOBAL_MODERATION_CONFIG, MAX_MODERATION_VIOLATION_DECAY_DAYS, MODERATION_VIOLATION_EVENT_TYPE } from "../../src/settings/moderation-rules"; describe("global moderation config DB round-trip (#selfhost-mod-engine)", () => { - it("defaults to DEFAULT_GLOBAL_MODERATION_CONFIG (off) for a fresh install", async () => { + it("defaults to the migrated singleton row's values (off, the original three rules) for a fresh install", async () => { const env = createTestEnv(); - expect(await getGlobalModerationConfig(env)).toEqual(DEFAULT_GLOBAL_MODERATION_CONFIG); + // The migrated singleton row's rules_json literal predates review_evasion (#review-evasion-protection) + // and is intentionally NOT auto-upgraded -- opting a NEW rule type into every existing install's shared + // tally is an explicit config change, not a silent default. DEFAULT_GLOBAL_MODERATION_CONFIG.rules (the + // MISSING-ROW fallback, asserted separately below) legitimately differs from this migrated default. + expect(await getGlobalModerationConfig(env)).toEqual({ ...DEFAULT_GLOBAL_MODERATION_CONFIG, rules: ["contributor_cap", "blacklist", "review_nag"] }); }); it("returns the default when the singleton row is missing", async () => { @@ -223,4 +227,58 @@ describe("per-repo moderation settings DB round-trip (#selfhost-mod-engine)", () expect(settings.moderationGateMode).toBe("enabled"); expect(settings.moderationWarningLabel).toBe("updated:warn"); }); + + it("moderationRules accepts review_evasion alongside the original three (#review-evasion-protection)", async () => { + const env = createTestEnv(); + await upsertRepositorySettings(env, { repoFullName: "owner/repo", moderationRules: ["review_evasion", "blacklist"] }); + const settings = await getRepositorySettings(env, "owner/repo"); + expect(settings.moderationRules).toEqual(["review_evasion", "blacklist"]); + }); +}); + +describe("per-repo review-evasion protection settings DB round-trip (#review-evasion-protection)", () => { + it("defaults to off/review-evasion/true for an unconfigured repo", async () => { + const settings = await getRepositorySettings(createTestEnv(), "owner/none"); + expect(settings.reviewEvasionProtection).toBe("off"); + expect(settings.reviewEvasionLabel).toBe("review-evasion"); + expect(settings.reviewEvasionComment).toBe(true); + }); + + it("persists an explicit protection mode + custom label + comment toggle", async () => { + const env = createTestEnv(); + await upsertRepositorySettings(env, { + repoFullName: "owner/repo", + reviewEvasionProtection: "close", + reviewEvasionLabel: "repo:evasion", + reviewEvasionComment: false, + }); + const settings = await getRepositorySettings(env, "owner/repo"); + expect(settings.reviewEvasionProtection).toBe("close"); + expect(settings.reviewEvasionLabel).toBe("repo:evasion"); + expect(settings.reviewEvasionComment).toBe(false); + }); + + it("an explicit null label falls back to the default (#label-scoping: the DB column has no true-null state, mirroring blacklistLabel)", async () => { + const env = createTestEnv(); + await upsertRepositorySettings(env, { repoFullName: "owner/repo", reviewEvasionLabel: null }); + const settings = await getRepositorySettings(env, "owner/repo"); + expect(settings.reviewEvasionLabel).toBe("review-evasion"); + }); + + it("round-trips through an UPDATE (not just the initial INSERT)", async () => { + const env = createTestEnv(); + await upsertRepositorySettings(env, { repoFullName: "owner/repo", reviewEvasionProtection: "off" }); + await upsertRepositorySettings(env, { repoFullName: "owner/repo", reviewEvasionProtection: "close", reviewEvasionComment: false }); + const settings = await getRepositorySettings(env, "owner/repo"); + expect(settings.reviewEvasionProtection).toBe("close"); + expect(settings.reviewEvasionComment).toBe(false); + }); + + it("a malformed raw DB value for review_evasion_protection normalizes to 'off' on read (defensive against a direct SQL write bypassing app-level validation)", async () => { + const env = createTestEnv(); + await upsertRepositorySettings(env, { repoFullName: "owner/repo" }); + await env.DB.prepare("UPDATE repository_settings SET review_evasion_protection = 'bogus' WHERE repo_full_name = ?").bind("owner/repo").run(); + const settings = await getRepositorySettings(env, "owner/repo"); + expect(settings.reviewEvasionProtection).toBe("off"); + }); }); diff --git a/test/unit/moderation-rules.test.ts b/test/unit/moderation-rules.test.ts index 0b78677559..01da9921c7 100644 --- a/test/unit/moderation-rules.test.ts +++ b/test/unit/moderation-rules.test.ts @@ -27,6 +27,12 @@ describe("normalizeModerationRules (#selfhost-mod-engine)", () => { expect(warnings).toEqual([]); }); + it("accepts review_evasion (#review-evasion-protection)", () => { + const { rules, warnings } = normalizeModerationRules(["review_evasion"]); + expect(rules).toEqual(["review_evasion"]); + expect(warnings).toEqual([]); + }); + it("drops unrecognized entries with a warning, keeping the valid ones", () => { const { rules, warnings } = normalizeModerationRules(["contributor_cap", "not-a-rule", 42, null]); expect(rules).toEqual(["contributor_cap"]); @@ -133,4 +139,8 @@ describe("constants + event-type map (#selfhost-mod-engine)", () => { expect(new Set(values).size).toBe(values.length); for (const eventType of values) expect(eventType).toMatch(/^moderation\.violation\./); }); + + it("review_evasion has its own namespaced event type (#review-evasion-protection)", () => { + expect(MODERATION_VIOLATION_EVENT_TYPE.review_evasion).toBe("moderation.violation.review_evasion"); + }); }); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 5ff7470c3e..094e938a72 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -21140,6 +21140,1060 @@ describe("converted_to_draft gate-close (draft-dodge prevention)", () => { }); }); +function draftEvasionPayload(author: string, headSha = "abc123"): any { + return { + action: "converted_to_draft", + installation: { id: 123 }, + repository: { id: 1, name: "gittensory", full_name: "JSONbored/gittensory", private: false, default_branch: "main", owner: { login: "JSONbored" } }, + sender: { login: author, type: "User" }, + pull_request: { + id: 4242, + number: 42, + state: "open", + title: "Some PR", + body: "Body.", + user: { login: author }, + head: { sha: headSha, ref: "fix", repo: { full_name: `${author}/gittensory`, owner: { login: author } } }, + base: { sha: "base123", ref: "main", repo: { full_name: "JSONbored/gittensory", owner: { login: "JSONbored" } } }, + draft: true, + merged: false, + mergeable_state: "clean", + created_at: "2026-05-27T00:00:00Z", + updated_at: "2026-05-27T00:00:00Z", + }, + }; +} + +function closedPayload(sender: string, author = sender, headSha = "abc123"): any { + return { + action: "closed", + installation: { id: 123 }, + repository: { id: 1, name: "gittensory", full_name: "JSONbored/gittensory", private: false, default_branch: "main", owner: { login: "JSONbored" } }, + sender: { login: sender, type: "User" }, + pull_request: { + id: 4242, + number: 42, + state: "closed", + title: "Some PR", + body: "Body.", + user: { login: author }, + head: { sha: headSha, ref: "fix", repo: { full_name: `${author}/gittensory`, owner: { login: author } } }, + base: { sha: "base123", ref: "main", repo: { full_name: "JSONbored/gittensory", owner: { login: "JSONbored" } } }, + draft: false, + merged: false, + mergeable_state: "clean", + created_at: "2026-05-27T00:00:00Z", + updated_at: "2026-05-27T00:00:00Z", + }, + }; +} + +describe("review-evasion protection (#review-evasion-protection)", () => { + beforeEach(() => clearInstallationTokenCacheForTest()); + afterEach(() => { + clearInstallationTokenCacheForTest(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + async function setupEvasionRepo(env: ReturnType, overrides: Record = {}): Promise { + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertInstallation(env, { + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + 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", + publicSurface: "off", + commentMode: "off", + checkRunMode: "off", + autonomy: { close: "auto" }, + agentPaused: false, + reviewEvasionProtection: "close", + ...overrides, + }); + } + + // Generic GitHub fetch stub covering every endpoint the evasion handlers (and the surrounding webhook + // pipeline they run inside) can call. `collaboratorPermission` controls what a non-owner/non-admin closer's + // permission check reports (default "read" — an ordinary contributor). + function stubEvasionFetch(calls: Array<{ url: string; method: string }>, opts: { collaboratorPermission?: string; onPatch?: (url: string) => Response | null } = {}) { + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + calls.push({ url, method }); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + if (url.includes("/collaborators/")) return Response.json({ permission: opts.collaboratorPermission ?? "read" }); + if (method === "PATCH" && url.endsWith("/pulls/42")) { + const custom = opts.onPatch?.(url); + if (custom) return custom; + return Response.json({ state: url === "open" ? "open" : "closed" }); + } + if (method === "POST" && url.endsWith("/issues/42/comments")) return Response.json({ id: 1 }, { status: 201 }); + if (url.includes("/check-runs")) return Response.json({ id: 900 }, { status: 201 }); + if (url.includes("/labels")) return Response.json([{ name: "review-evasion" }]); + if (url.includes("/pulls/42/files")) return Response.json([]); + return new Response("not found", { status: 404 }); + }); + } + + describe("self-close during an active review", () => { + it("reopens then re-closes as the App, posts the explanation comment, applies the label, and records a review_evasion strike", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", authorLogin: "contributor", deliveryId: "review-start-1" }); + await repositoriesModule.upsertGlobalModerationConfig(env, { enabled: true, rules: ["review_evasion"] }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-1", eventName: "pull_request", payload: closedPayload("contributor") }); + + const patches = calls.filter((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42")); + expect(patches.length).toBeGreaterThanOrEqual(2); // reopen (state=open) then re-close (state=closed) + expect(calls.some((c) => c.method === "POST" && c.url.endsWith("/issues/42/comments"))).toBe(true); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("completed"); + expect(audit?.detail).toContain("contributor"); + expect(await repositoriesModule.hasActiveReviewForHeadSha(env, "JSONbored/gittensory", 42, "abc123")).toBe(false); // terminalized + const strike = await env.DB.prepare("select outcome from audit_events where event_type = ?").bind("moderation.violation.review_evasion").first<{ outcome: string }>(); + expect(strike?.outcome).toBe("completed"); + }); + + it("retries (via a thrown lock-contended error) when a concurrent delivery already holds the per-PR actuation lock", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + await env.SELFHOST_TRANSIENT_CACHE?.set("pr-actuation-lock:jsonbored/gittensory#42", "1", 60); + + await expect( + processJob(env, { type: "github-webhook", deliveryId: "self-close-lock-contended", eventName: "pull_request", payload: closedPayload("contributor") }), + ).rejects.toThrow("during review-evasion-self-close"); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ n: number }>(); + expect(audit?.n).toBe(0); // no decision recorded either way -- the queue retry owns the deferred decision + }); + + it("does nothing when reviewEvasionProtection is off (the default)", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env, { reviewEvasionProtection: "off" }); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-off", eventName: "pull_request", payload: closedPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ n: number }>(); + expect(audit?.n).toBe(0); + }); + + it("does nothing when NO active review is tracked for this head (an ordinary close, nothing to evade)", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + // No startActiveReviewTracking call at all. + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-no-active-review", eventName: "pull_request", payload: closedPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing when a THIRD PARTY closed someone else's PR (not the author) — an ordinary maintainer close, not self-close evasion", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls, { collaboratorPermission: "write" }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-third-party", eventName: "pull_request", payload: closedPayload("a-maintainer", "contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing when the closer is the repo owner", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-owner", eventName: "pull_request", payload: closedPayload("JSONbored") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing when the closer is an ADMIN_GITHUB_LOGINS fleet-operator", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory", ADMIN_GITHUB_LOGINS: "admin-user" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-admin", eventName: "pull_request", payload: closedPayload("admin-user") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing when the closer holds write/maintain/admin collaborator permission", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls, { collaboratorPermission: "write" }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-maintainer", eventName: "pull_request", payload: closedPayload("write-collaborator") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing for a protected automation author (e.g. dependabot[bot])", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-bot", eventName: "pull_request", payload: closedPayload("dependabot[bot]") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("dry-run: audits the would-be enforcement without mutating GitHub or recording a live strike", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env, { agentDryRun: true }); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + await repositoriesModule.upsertGlobalModerationConfig(env, { enabled: true, rules: ["review_evasion"] }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-dry-run", eventName: "pull_request", payload: closedPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("completed"); + expect(audit?.detail).toContain("dry-run"); + const strike = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("moderation.violation.review_evasion").first<{ n: number }>(); + expect(strike?.n).toBe(0); + }); + + it("denies enforcement when the agent is globally frozen", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + await repositoriesModule.setGlobalAgentFrozen(env, true, "test"); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-frozen", eventName: "pull_request", payload: closedPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("denied"); + expect(audit?.detail).toContain("paused"); + }); + + it("denies enforcement when close autonomy is not acting (observe)", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env, { autonomy: { close: "observe" } }); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-observe", eventName: "pull_request", payload: closedPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("denied"); + expect(audit?.detail).toContain("autonomy for close is not acting"); + }); + + it("denies enforcement when pull_requests: write is not granted", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertInstallation(env, { + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", issues: "write" }, + events: ["pull_request"], + }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", publicSurface: "off", commentMode: "off", checkRunMode: "off", autonomy: { close: "auto" }, agentPaused: false, reviewEvasionProtection: "close" }); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-no-write", eventName: "pull_request", payload: closedPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("denied"); + expect(audit?.detail).toContain("pull_requests: write not granted"); + }); + + it("denies enforcement when live PR state has moved since the webhook was received", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + vi.mocked(fetchPullRequestFreshness).mockResolvedValueOnce({ status: "stale", reason: "head_changed", expectedHeadSha: "abc123", liveHeadSha: "def456", liveState: "closed" }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-stale", eventName: "pull_request", payload: closedPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("denied"); + expect(audit?.detail).toContain("review-evasion enforcement not executed"); + }); + + it("audits an error and does NOT record a strike when the reopen API call fails", async () => { + const calls: Array<{ url: string; method: string }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + calls.push({ url, method }); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + if (url.includes("/collaborators/")) return Response.json({ permission: "read" }); + if (method === "PATCH" && url.endsWith("/pulls/42")) return new Response("server error", { status: 500 }); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + await repositoriesModule.upsertGlobalModerationConfig(env, { enabled: true, rules: ["review_evasion"] }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-reopen-fail", eventName: "pull_request", payload: closedPayload("contributor") }); + + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("error"); + expect(audit?.detail).toContain("FAILED to reopen"); + const strike = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("moderation.violation.review_evasion").first<{ n: number }>(); + expect(strike?.n).toBe(0); + // The PR is closed either way (our reopen attempt failing doesn't reopen it) -- the general + // "closed"-action cleanup still terminalizes the tracking row, independent of enforcement success. + expect(await repositoriesModule.hasActiveReviewForHeadSha(env, "JSONbored/gittensory", 42, "abc123")).toBe(false); + }); + + it("REGRESSION (gate-flagged): throws (never silently leaves the PR open) when reopen succeeds but the re-close API call fails, so the queue retries the job", async () => { + const calls: Array<{ url: string; method: string }> = []; + let patchCount = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + calls.push({ url, method }); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + if (url.includes("/collaborators/")) return Response.json({ permission: "read" }); + if (method === "PATCH" && url.endsWith("/pulls/42")) { + patchCount += 1; + if (patchCount === 1) return Response.json({ state: "open" }); // reopen succeeds + return new Response("server error", { status: 500 }); // re-close fails + } + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + await repositoriesModule.upsertGlobalModerationConfig(env, { enabled: true, rules: ["review_evasion"] }); + + // Deliberately UNCAUGHT: leaving the reopened PR open and returning normally would be worse than the + // contributor's original close, so this must propagate for the queue's own retry mechanism instead of + // resolving quietly. + await expect( + processJob(env, { type: "github-webhook", deliveryId: "self-close-close-fail", eventName: "pull_request", payload: closedPayload("contributor") }), + ).rejects.toThrow(); + + expect(patchCount).toBe(2); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("error"); + expect(audit?.detail).toContain("FAILED to re-close"); + const strike = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("moderation.violation.review_evasion").first<{ n: number }>(); + expect(strike?.n).toBe(0); + // Still active -- enforcement never completed, so the active-review row must not have been cleared + // (the active-review-tracking cleanup below only fires on the "closed" webhook action's OWN pass, and + // this throw aborts that pass before it reaches the general terminalize hook). + expect(await repositoriesModule.hasActiveReviewForHeadSha(env, "JSONbored/gittensory", 42, "abc123")).toBe(true); + }); + + it("REGRESSION (gate-flagged): a retry after the re-close failure converges -- the PR ends up closed, and the strike is recorded exactly once", async () => { + const calls: Array<{ url: string; method: string }> = []; + let closeAttempts = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + calls.push({ url, method }); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + if (url.includes("/collaborators/")) return Response.json({ permission: "read" }); + if (method === "PATCH" && url.endsWith("/pulls/42")) { + const body = init?.body ? JSON.parse(String(init.body)) : {}; + if (body.state === "open") return Response.json({ state: "open" }); // reopen always succeeds + closeAttempts += 1; + if (closeAttempts === 1) return new Response("server error", { status: 500 }); // FIRST close attempt fails + return Response.json({ state: "closed" }); // retry's close attempt succeeds + } + if (method === "POST" && url.endsWith("/issues/42/comments")) return Response.json({ id: 1 }, { status: 201 }); + if (url.includes("/labels")) return Response.json([{ name: "review-evasion" }]); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", authorLogin: "contributor", deliveryId: "review-start-1" }); + await repositoriesModule.upsertGlobalModerationConfig(env, { enabled: true, rules: ["review_evasion"] }); + + const payload = closedPayload("contributor"); + await expect(processJob(env, { type: "github-webhook", deliveryId: "self-close-close-fail-retry", eventName: "pull_request", payload })).rejects.toThrow(); + // The queue's own retry mechanism re-delivers the SAME job after the first attempt threw. + await processJob(env, { type: "github-webhook", deliveryId: "self-close-close-fail-retry", eventName: "pull_request", payload }); + + expect(closeAttempts).toBe(2); + const audit = await env.DB.prepare("select outcome from audit_events where event_type = ? order by created_at desc limit 1").bind("github_app.review_evasion_closed").first<{ outcome: string }>(); + expect(audit?.outcome).toBe("completed"); + const strikeCount = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("moderation.violation.review_evasion").first<{ n: number }>(); + expect(strikeCount?.n).toBe(1); // exactly one strike, not one per attempt + expect(await repositoriesModule.hasActiveReviewForHeadSha(env, "JSONbored/gittensory", 42, "abc123")).toBe(false); + }); + + it("global moderation disabled: the evasion close/label/comment still happen, but no moderation strike/label is recorded", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + // Global moderation config left at its default (disabled). + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-mod-off", eventName: "pull_request", payload: closedPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(true); + expect(calls.some((c) => c.method === "POST" && c.url.endsWith("/issues/42/comments"))).toBe(true); + const audit = await env.DB.prepare("select outcome from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string }>(); + expect(audit?.outcome).toBe("completed"); + const strike = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("moderation.violation.review_evasion").first<{ n: number }>(); + expect(strike?.n).toBe(0); + }); + + it("REGRESSION: no duplicate strike or duplicate enforcement on a webhook redelivery/retry after the first enforcement already succeeded", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + await repositoriesModule.upsertGlobalModerationConfig(env, { enabled: true, rules: ["review_evasion"] }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-redelivery-1", eventName: "pull_request", payload: closedPayload("contributor") }); + const firstPatchCount = calls.filter((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42")).length; + expect(firstPatchCount).toBeGreaterThanOrEqual(2); + + // A SECOND, genuinely distinct delivery for the same underlying event (e.g. a queue retry after the first + // job's ack was lost) — the active-review row is already terminalized, so this must be a pure no-op. + await processJob(env, { type: "github-webhook", deliveryId: "self-close-redelivery-2", eventName: "pull_request", payload: closedPayload("contributor") }); + const secondPatchCount = calls.filter((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42")).length - firstPatchCount; + expect(secondPatchCount).toBe(0); + + const strikeCount = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("moderation.violation.review_evasion").first<{ n: number }>(); + expect(strikeCount?.n).toBe(1); + }); + + it("a subsequent contributor reopen after the App's evasion close is re-closed by the EXISTING one-shot reopen guard", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-then-reopen-1", eventName: "pull_request", payload: closedPayload("contributor") }); + expect((await env.DB.prepare("select outcome from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string }>())?.outcome).toBe("completed"); + + // getLastCloserLogin reads the issue-events timeline -- the App's own close (via the enforcement handler, + // NOT via the reopen-reclose guard) must be visible there for the existing guard to recognize it. + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + calls.push({ url, method }); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + if (url.includes("/collaborators/contributor/permission")) return Response.json({ permission: "read" }); + if (url.includes("/issues/42/events")) return Response.json([{ event: "closed", actor: { login: "gittensory[bot]" } }, { event: "reopened", actor: { login: "contributor" } }]); + if (method === "POST" && url.endsWith("/issues/42/comments")) return Response.json({ id: 2 }, { status: 201 }); + if (method === "PATCH" && url.endsWith("/pulls/42")) return Response.json({ state: "closed" }); + return new Response("not found", { status: 404 }); + }); + await processJob(env, { type: "github-webhook", deliveryId: "contributor-reopens-after-evasion-close", eventName: "pull_request", payload: reopenedPayload("contributor") }); + + const reopenAudit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.reopen_reclosed").first<{ outcome: string; detail: string }>(); + expect(reopenAudit?.outcome).toBe("completed"); + expect(reopenAudit?.detail).toContain("one-shot"); + }); + + it("does nothing when reviewEvasionProtection is unset (undefined, not just 'off')", async () => { + // upsertRepositorySettings coalesces undefined -> "off" at write time (mirrors reviewEvasionLabel/ + // reviewEvasionComment's own write-time defaulting below), so the only way to get `undefined` past that + // coalescing and into the handler is to mock the resolved-settings layer directly. + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + const baseSettings = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory"); + vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue({ ...baseSettings, reviewEvasionProtection: undefined }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-protection-unset", eventName: "pull_request", payload: closedPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing when the webhook payload has no sender", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + const payload = closedPayload("contributor"); + payload.sender = undefined; + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-no-sender", eventName: "pull_request", payload }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing when the PR record has no author (a deleted-account PR)", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + const payload = closedPayload("contributor"); + payload.pull_request.user = null; + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-no-author", eventName: "pull_request", payload }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing when the PR record has no headSha", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + const payload = closedPayload("contributor"); + payload.pull_request.head = null; + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-no-head-sha", eventName: "pull_request", payload }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("denies enforcement when the installation record is missing (uninstalled mid-flight)", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + vi.spyOn(repositoriesModule, "getInstallation").mockResolvedValue(null); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-no-installation", eventName: "pull_request", payload: closedPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("denied"); + expect(audit?.detail).toContain("pull_requests: write not granted"); + }); + + it("skips the courtesy comment when reviewEvasionComment is unset (defaults to true, but false is honored too)", async () => { + // Same write-time-coalescing note as the reviewEvasionProtection test above. + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + const baseSettings = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory"); + vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue({ ...baseSettings, reviewEvasionComment: undefined }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-comment-unset", eventName: "pull_request", payload: closedPayload("contributor") }); + + // reviewEvasionComment unset falls back to `true` -- the courtesy comment still posts. + expect(calls.some((c) => c.method === "POST" && c.url.endsWith("/issues/42/comments"))).toBe(true); + }); + + it("applies no label when reviewEvasionLabel is explicitly null (a .gittensory.yml-only 'no label' override)", async () => { + const calls: Array<{ url: string; method: string }> = []; + const labelPostBodies: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + calls.push({ url, method }); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + if (url.includes("/collaborators/")) return Response.json({ permission: "read" }); + if (method === "PATCH" && url.endsWith("/pulls/42")) return Response.json({ state: "closed" }); + if (method === "POST" && url.endsWith("/issues/42/comments")) return Response.json({ id: 1 }, { status: 201 }); + if (method === "POST" && url.endsWith("/issues/42/labels")) { + labelPostBodies.push(String(init?.body ?? "")); + return Response.json([], { status: 200 }); + } + if (url.includes("/labels")) return Response.json([]); // dedup probe: no labels on the issue yet + if (url.includes("/pulls/42/files")) return Response.json([]); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + // reviewEvasionLabel is a NOT NULL DB column (upsertRepositorySettings coalesces null -> the default at + // write time, per the migration's own "never persisted" comment) -- null only ever reaches this handler + // via the .gittensory.yml config-as-code layer, so the resolved-settings layer is mocked directly here. + const baseSettings = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory"); + vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue({ ...baseSettings, reviewEvasionLabel: null }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-label-null", eventName: "pull_request", payload: closedPayload("contributor") }); + + // Some OTHER unrelated feature (title-based type-labeling) may still post its own labels on a close -- + // what matters here is that the review-evasion label specifically was never requested. + expect(labelPostBodies.some((b) => b.includes("review-evasion"))).toBe(false); + const audit = await env.DB.prepare("select outcome from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string }>(); + expect(audit?.outcome).toBe("completed"); + }); + + it("falls back to the default label when reviewEvasionLabel is unset", async () => { + const calls: Array<{ url: string; method: string }> = []; + const labelPostBodies: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + calls.push({ url, method }); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + if (url.includes("/collaborators/")) return Response.json({ permission: "read" }); + if (method === "PATCH" && url.endsWith("/pulls/42")) return Response.json({ state: "closed" }); + if (method === "POST" && url.endsWith("/issues/42/comments")) return Response.json({ id: 1 }, { status: 201 }); + if (method === "POST" && url.endsWith("/issues/42/labels")) { + labelPostBodies.push(String(init?.body ?? "")); + return Response.json([], { status: 200 }); + } + if (url.includes("/labels")) return Response.json([]); // dedup probe: no labels on the issue yet + if (url.includes("/pulls/42/files")) return Response.json([]); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + const baseSettings = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory"); + vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue({ ...baseSettings, reviewEvasionLabel: undefined }); + + await processJob(env, { type: "github-webhook", deliveryId: "self-close-label-unset", eventName: "pull_request", payload: closedPayload("contributor") }); + + expect(labelPostBodies.some((b) => b.includes("review-evasion"))).toBe(true); + }); + }); + + describe("converted_to_draft during an active review", () => { + it("closes as the App (no reopen needed), posts the explanation comment, applies the label, and records a review_evasion strike", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", authorLogin: "contributor", deliveryId: "review-start-1" }); + await repositoriesModule.upsertGlobalModerationConfig(env, { enabled: true, rules: ["review_evasion"] }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + const patches = calls.filter((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42")); + expect(patches).toHaveLength(1); // no reopen needed -- a single close. + expect(calls.some((c) => c.method === "POST" && c.url.endsWith("/issues/42/comments"))).toBe(true); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("completed"); + expect(audit?.detail).toContain("draft-conversion"); + const strike = await env.DB.prepare("select outcome from audit_events where event_type = ?").bind("moderation.violation.review_evasion").first<{ outcome: string }>(); + expect(strike?.outcome).toBe("completed"); + }); + + it("retries (via a thrown lock-contended error) when a concurrent delivery already holds the per-PR actuation lock", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + // Deliberately autonomy: {} (not {close: "auto"}) -- this repo's OUTER dispatch condition for the + // SIBLING draft-dodge guard requires isAgentConfigured(settings.autonomy), so with no acting autonomy + // class at all, draft-dodge's OWN lock-claim attempt is skipped entirely and this test genuinely + // exercises THIS handler's own lock claim/throw, not draft-dodge's (both guards fire on + // converted_to_draft and would otherwise race for the identical lock key). + await setupEvasionRepo(env, { autonomy: {} }); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + await env.SELFHOST_TRANSIENT_CACHE?.set("pr-actuation-lock:jsonbored/gittensory#42", "1", 60); + + await expect( + processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-lock-contended", eventName: "pull_request", payload: draftEvasionPayload("contributor") }), + ).rejects.toThrow("during review-evasion-draft"); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ n: number }>(); + expect(audit?.n).toBe(0); + }); + + it("does nothing for a draft conversion BEFORE any active review has started", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + // No startActiveReviewTracking call -- no review has ever run for this PR. + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-no-active-review", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does NOT require a prior gate failure (unlike the draft-dodge guard) -- an active review alone is enough", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + // Deliberately NO recordGateBlockOutcome call -- the draft-dodge guard's own trigger condition is absent. + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-no-gate-failure", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(true); + const draftDodgeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("github_app.draft_dodge_closed").first<{ n: number }>(); + expect(draftDodgeAudit?.n).toBe(0); // the SIBLING guard never fired -- this is genuinely the new path. + const evasionAudit = await env.DB.prepare("select outcome from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string }>(); + expect(evasionAudit?.outcome).toBe("completed"); + }); + + it("does nothing when the author holds write collaborator permission", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls, { collaboratorPermission: "write" }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-maintainer", eventName: "pull_request", payload: draftEvasionPayload("write-collaborator") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("REGRESSION (gate-flagged): does nothing when a THIRD PARTY converts someone else's PR to draft (not the author) -- an ordinary maintainer action, not self-evasion, must never be enforced against the author who didn't do it", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls, { collaboratorPermission: "write" }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + const payload = draftEvasionPayload("contributor"); + payload.sender = { login: "a-maintainer", type: "User" }; // the CONVERTER, distinct from pull_request.user (the author) + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-third-party", eventName: "pull_request", payload }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ n: number }>(); + expect(audit?.n).toBe(0); + }); + + it("dry-run: audits the would-be enforcement without mutating GitHub or recording a live strike", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env, { agentDryRun: true }); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + await repositoriesModule.upsertGlobalModerationConfig(env, { enabled: true, rules: ["review_evasion"] }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-dry-run", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("completed"); + expect(audit?.detail).toContain("dry-run"); + const strike = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("moderation.violation.review_evasion").first<{ n: number }>(); + expect(strike?.n).toBe(0); + }); + + it("denies enforcement when the agent is globally frozen", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + await repositoriesModule.setGlobalAgentFrozen(env, true, "test"); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-frozen", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("denied"); + expect(audit?.detail).toContain("paused"); + }); + + it("denies enforcement when close autonomy is not acting (observe)", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env, { autonomy: { close: "observe" } }); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-observe", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("denied"); + expect(audit?.detail).toContain("autonomy for close is not acting"); + }); + + it("denies enforcement when pull_requests: write is not granted", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertInstallation(env, { + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", issues: "write" }, + events: ["pull_request"], + }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", publicSurface: "off", commentMode: "off", checkRunMode: "off", autonomy: { close: "auto" }, agentPaused: false, reviewEvasionProtection: "close" }); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-no-write", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("denied"); + expect(audit?.detail).toContain("pull_requests: write not granted"); + }); + + it("denies enforcement when the PR was converted back to ready_for_review before the close fires (requireDraft freshness)", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + vi.mocked(fetchPullRequestFreshness).mockResolvedValueOnce({ status: "stale", reason: "no_longer_draft", expectedHeadSha: "abc123", liveHeadSha: "abc123", liveState: "open" }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-no-longer-draft", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + expect(fetchPullRequestFreshness).toHaveBeenCalledWith(env, expect.objectContaining({ requireDraft: true })); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("denied"); + }); + + it("audits an error and does NOT record a strike when the close API call fails", async () => { + const calls: Array<{ url: string; method: string }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + calls.push({ url, method }); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + if (url.includes("/collaborators/")) return Response.json({ permission: "read" }); + if (method === "PATCH" && url.endsWith("/pulls/42")) return new Response("server error", { status: 500 }); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + await repositoriesModule.upsertGlobalModerationConfig(env, { enabled: true, rules: ["review_evasion"] }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-close-fail", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("error"); + const strike = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("moderation.violation.review_evasion").first<{ n: number }>(); + expect(strike?.n).toBe(0); + }); + + it("global moderation disabled: the evasion close/label/comment still happen, but no moderation strike is recorded", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-mod-off", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(true); + const audit = await env.DB.prepare("select outcome from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string }>(); + expect(audit?.outcome).toBe("completed"); + const strike = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("moderation.violation.review_evasion").first<{ n: number }>(); + expect(strike?.n).toBe(0); + }); + + it("does nothing when reviewEvasionProtection is unset (undefined, not just 'off')", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + const baseSettings = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory"); + vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue({ ...baseSettings, reviewEvasionProtection: undefined }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-protection-unset", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing when the webhook payload has no sender", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + const payload = draftEvasionPayload("contributor"); + payload.sender = undefined; + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-no-sender", eventName: "pull_request", payload }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing when the PR record has no author (a deleted-account PR)", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + const payload = draftEvasionPayload("contributor"); + payload.pull_request.user = null; + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-no-author", eventName: "pull_request", payload }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing for a protected automation author (e.g. dependabot[bot])", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-bot", eventName: "pull_request", payload: draftEvasionPayload("dependabot[bot]") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing when the PR record has no headSha", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + + const payload = draftEvasionPayload("contributor"); + payload.pull_request.head = null; + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-no-head-sha", eventName: "pull_request", payload }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("denies enforcement when the installation record is missing (uninstalled mid-flight)", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + vi.spyOn(repositoriesModule, "getInstallation").mockResolvedValue(null); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-no-installation", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("denied"); + expect(audit?.detail).toContain("pull_requests: write not granted"); + }); + + it("skips the courtesy comment when reviewEvasionComment is unset (defaults to true, but false is honored too)", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + const baseSettings = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory"); + vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue({ ...baseSettings, reviewEvasionComment: undefined }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-comment-unset", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(calls.some((c) => c.method === "POST" && c.url.endsWith("/issues/42/comments"))).toBe(true); + }); + + it("applies no label when reviewEvasionLabel is explicitly null (a .gittensory.yml-only 'no label' override)", async () => { + const calls: Array<{ url: string; method: string }> = []; + const labelPostBodies: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + calls.push({ url, method }); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + if (url.includes("/collaborators/")) return Response.json({ permission: "read" }); + if (method === "PATCH" && url.endsWith("/pulls/42")) return Response.json({ state: "closed" }); + if (method === "POST" && url.endsWith("/issues/42/comments")) return Response.json({ id: 1 }, { status: 201 }); + if (method === "POST" && url.endsWith("/issues/42/labels")) { + labelPostBodies.push(String(init?.body ?? "")); + return Response.json([], { status: 200 }); + } + if (url.includes("/labels")) return Response.json([]); // dedup probe: no labels on the issue yet + if (url.includes("/pulls/42/files")) return Response.json([]); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + const baseSettings = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory"); + vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue({ ...baseSettings, reviewEvasionLabel: null }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-label-null", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(labelPostBodies.some((b) => b.includes("review-evasion"))).toBe(false); + const audit = await env.DB.prepare("select outcome from audit_events where event_type = ?").bind("github_app.review_evasion_closed").first<{ outcome: string }>(); + expect(audit?.outcome).toBe("completed"); + }); + + it("falls back to the default label when reviewEvasionLabel is unset", async () => { + const calls: Array<{ url: string; method: string }> = []; + const labelPostBodies: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + calls.push({ url, method }); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + if (url.includes("/collaborators/")) return Response.json({ permission: "read" }); + if (method === "PATCH" && url.endsWith("/pulls/42")) return Response.json({ state: "closed" }); + if (method === "POST" && url.endsWith("/issues/42/comments")) return Response.json({ id: 1 }, { status: 201 }); + if (method === "POST" && url.endsWith("/issues/42/labels")) { + labelPostBodies.push(String(init?.body ?? "")); + return Response.json([], { status: 200 }); + } + if (url.includes("/labels")) return Response.json([]); // dedup probe: no labels on the issue yet + if (url.includes("/pulls/42/files")) return Response.json([]); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); + const baseSettings = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory"); + vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue({ ...baseSettings, reviewEvasionLabel: undefined }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-label-unset", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(labelPostBodies.some((b) => b.includes("review-evasion"))).toBe(true); + }); + }); +}); + describe("recordAgentCommandUsage (signal-snapshot fail-safe)", () => { afterEach(() => { vi.restoreAllMocks();