diff --git a/.gittensory.yml b/.gittensory.yml index 04e81cc946..766d9ca714 100644 --- a/.gittensory.yml +++ b/.gittensory.yml @@ -63,6 +63,11 @@ gate: # formal GitHub assignee of the issue — our issues are almost always maintainer-authored for open pickup and # rarely formally assigned. priority intentionally omits the flag: it is the scarce, maintainer-hand-picked # reward label, and must still require the PR author to be the issue's actual author/assignee. +# +# Review-evasion protection: closing or converting-to-draft your OWN PR while gittensory has an active +# review pass running, a prior recorded gate failure, or a repeated ready<->draft cycle on this PR, is +# treated as dodging the one-shot review rather than an ordinary action (layered OVER the dashboard's +# own default of "off"). settings: linkedIssueLabelPropagation: enabled: true @@ -79,6 +84,7 @@ settings: - issueLabel: "gittensor:priority" prLabel: "gittensor:priority" removeOtherTypeLabels: true + reviewEvasionProtection: close # Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let Gittensory open a # PR generating AGENTS.md/CLAUDE.md from this repo's own profile. diff --git a/migrations/0118_pull_request_draft_conversion_count.sql b/migrations/0118_pull_request_draft_conversion_count.sql new file mode 100644 index 0000000000..b6924a280c --- /dev/null +++ b/migrations/0118_pull_request_draft_conversion_count.sql @@ -0,0 +1,5 @@ +-- Review-evasion: repeated ready<->draft cycling (#gaming-tactic-draft-cycle). A contributor converting their +-- OWN PR to draft more than once is using draft state as a repeated shield to harvest AI-review/CI feedback +-- for free while dodging the one-shot disposition. Counts every converted_to_draft webhook ever processed for +-- this PR NUMBER (not scoped to head SHA -- a new commit between draft cycles is still the same evasion shape). +ALTER TABLE pull_requests ADD COLUMN draft_conversion_count INTEGER NOT NULL DEFAULT 0; diff --git a/src/config/gittensory-repo-focus-manifest.ts b/src/config/gittensory-repo-focus-manifest.ts index a6a4883892..7436b5619c 100644 --- a/src/config/gittensory-repo-focus-manifest.ts +++ b/src/config/gittensory-repo-focus-manifest.ts @@ -67,6 +67,11 @@ gate: # formal GitHub assignee of the issue — our issues are almost always maintainer-authored for open pickup and # rarely formally assigned. priority intentionally omits the flag: it is the scarce, maintainer-hand-picked # reward label, and must still require the PR author to be the issue's actual author/assignee. +# +# Review-evasion protection: closing or converting-to-draft your OWN PR while gittensory has an active +# review pass running, a prior recorded gate failure, or a repeated ready<->draft cycle on this PR, is +# treated as dodging the one-shot review rather than an ordinary action (layered OVER the dashboard's +# own default of "off"). settings: linkedIssueLabelPropagation: enabled: true @@ -83,6 +88,7 @@ settings: - issueLabel: "gittensor:priority" prLabel: "gittensor:priority" removeOtherTypeLabels: true + reviewEvasionProtection: close # Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let Gittensory open a # PR generating AGENTS.md/CLAUDE.md from this repo's own profile. diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 7fed01f327..e06538f168 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -3387,6 +3387,25 @@ export async function bumpPullRequestMergeAttempt(env: Env, fullName: string, nu return Number(row?.count ?? 0); } +// Review-evasion: repeated ready<->draft cycling (#gaming-tactic-draft-cycle). + +/** Increment the ready<->draft conversion counter for a PR and return the new total. Deliberately NOT scoped + * to headSha (unlike bumpPullRequestMergeAttempt) -- a contributor pushing a new commit between draft cycles + * is still doing the same repeated-evasion shape, so a fresh head must not reset the count back to zero. */ +export async function bumpPullRequestDraftConversionCount(env: Env, fullName: string, number: number): Promise { + const db = getDb(env.DB); + await db + .update(pullRequests) + .set({ draftConversionCount: sql`${pullRequests.draftConversionCount} + 1`, updatedAt: nowIso() }) + .where(and(eq(pullRequests.repoFullName, fullName), eq(pullRequests.number, number))); + const [row] = await db + .select({ count: pullRequests.draftConversionCount }) + .from(pullRequests) + .where(and(eq(pullRequests.repoFullName, fullName), eq(pullRequests.number, number))) + .limit(1); + return Number(row?.count ?? 0); +} + /** Mark a PR terminally merge-blocked for its current head SHA: the planner skips the `merge` disposition while * merge_blocked_sha == headSha. Scoped to headSha so a later commit (a pushed fix) auto-clears the block (the * guard compares it to the live head). Records the human-readable terminal reason. */ diff --git a/src/db/schema.ts b/src/db/schema.ts index 2cd2b1b701..9da832425d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -428,6 +428,11 @@ export const pullRequests = sqliteTable( mergeAttemptCount: integer("merge_attempt_count").notNull().default(0), mergeBlockedSha: text("merge_blocked_sha"), mergeBlockedReason: text("merge_blocked_reason"), + // Review-evasion: repeated ready<->draft cycling (#gaming-tactic-draft-cycle). Counts every converted_to_draft + // webhook ever processed for this PR NUMBER -- deliberately NOT scoped to head SHA like mergeAttemptCount, + // since cycling back to draft after a fresh push is exactly the same evasion shape a new commit must not + // reset. gittensory-computed (webhook-written), omitted from the GitHub-sync SET clause. + draftConversionCount: integer("draft_conversion_count").notNull().default(0), // Re-approval idempotency: the head SHA the bot last auto-approved. The planner skips the `approve` // disposition while approved_head_sha == headSha (this commit is already approved). Keyed to head SHA → a // new commit makes the bot re-approve the new code. gittensory-computed (executor-written), omitted from diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 2d93f3a412..72e0e2e033 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -66,6 +66,7 @@ import { markGateOutcomeOverridden, startActiveReviewTracking, terminalizeActiveReviewTracking, + bumpPullRequestDraftConversionCount, recordProductUsageEvent, persistSignalSnapshot, recordWebhookEvent, @@ -5816,6 +5817,27 @@ async function processGitHubWebhook( settings, ); } + // Review-evasion protection: repeated ready<->draft cycling (#gaming-tactic-draft-cycle). Always counts + // the conversion (cheap, no side effects) so the count is accurate from the very first converted_to_draft + // event this repo ever sees, even before reviewEvasionProtection is turned on for it -- only the + // ENFORCEMENT below is gated on that setting. Runs after both guards above so a PR already closed by + // either of them fails this guard's own freshness re-check instead of being redundantly re-closed. + if (payload.action === "converted_to_draft" && installationId) { + const draftConversionCount = await bumpPullRequestDraftConversionCount(env, repoFullName, pr.number).catch( + /* v8 ignore next -- fail-safe: a counter-write failure only means this ONE cycle isn't detected. */ + () => 0, + ); + await maybeCloseRepeatedDraftCycling( + env, + deliveryId, + installationId, + repoFullName, + pr, + payload, + settings, + draftConversionCount, + ); + } // 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 @@ -11815,6 +11837,213 @@ async function closeReviewEvasionDraftConversionIfActive( } } +/** Review-evasion protection (#gaming-tactic-draft-cycle): a contributor who converts their OWN PR to draft + * more than once is using draft state as a repeated shield to harvest AI-review/CI feedback for free while + * dodging the one-shot disposition -- distinct from the two EXISTING draft guards above, which key off a + * SPECIFIC head's review/gate state (an active pass, or a prior recorded gate failure) and can both be + * legitimately absent on a fast cycle (e.g. converting to draft before either has recorded anything for the + * new head at all). This guard instead keys purely on REPETITION: the second (and every later) ready->draft + * conversion on the same PR is enforced regardless of the current review/gate state, since the pattern + * itself -- not any one head's verdict -- is the abuse signal. A single, first-time draft conversion is + * never enforced here (ordinary WIP behavior). Per-PR actuation-locked like its siblings. */ +async function maybeCloseRepeatedDraftCycling( + env: Env, + deliveryId: string, + installationId: number, + repoFullName: string, + pr: PullRequestRecord, + payload: GitHubWebhookPayload, + settings: RepositorySettings, + draftConversionCount: number, +): Promise { + const actuationLock = await claimPrActuationLock(env, repoFullName, pr.number); + if (!actuationLock.acquired) { + throw new PrActuationLockContendedError(repoFullName, pr.number, "review-evasion-draft-cycle"); + } + try { + await closeRepeatedDraftCyclingIfDetected(env, deliveryId, installationId, repoFullName, pr, payload, settings, draftConversionCount); + } finally { + await releasePrActuationLock(env, repoFullName, pr.number, actuationLock.ownerToken); + } +} + +async function closeRepeatedDraftCyclingIfDetected( + env: Env, + deliveryId: string, + installationId: number, + repoFullName: string, + pr: PullRequestRecord, + payload: GitHubWebhookPayload, + settings: RepositorySettings, + draftConversionCount: number, +): Promise { + if ((settings.reviewEvasionProtection ?? "off") !== "close") return; + if (draftConversionCount < 2) 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 cycling-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 two sibling guards above). + if (!converter || !authorLogin || converter !== authorLogin) return; + if (isProtectedAutomationAuthor(pr.authorLogin)) return; + if (!pr.headSha) return; + if (await hasMaintainerOrOwnerPermission(env, installationId, repoFullName, authorLogin)) 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 -- repeated draft-cycling not enforced for ${pr.authorLogin} (conversion #${draftConversionCount})`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha, draftConversionCount }, + }).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 repeated draft-cycling by ${pr.authorLogin} -- conversion #${draftConversionCount}`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha, mode: "dry_run", draftConversionCount }, + }).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 -- repeated draft-cycling not enforced for ${pr.authorLogin} (conversion #${draftConversionCount})`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha, draftConversionCount }, + }).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 repeated-draft-cycling enforcement for ${pr.authorLogin} -- pull_requests: write not granted`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha, draftConversionCount }, + }).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 two sibling guards' identical fix (#2130). A PR + // already closed moments ago by one of the sibling guards also fails this (status !== "current"), so it is + // never redundantly re-closed here. + 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)} -- repeated-draft-cycling enforcement not executed`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha, draftConversionCount }, + }).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 repeated draft-cycling by ${pr.authorLogin} -- the close API call did not succeed; the PR may still be open`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha, draftConversionCount, 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 shouldPostComment = settings.reviewEvasionComment ?? true; + if (shouldPostComment) { + await createIssueComment( + env, + installationId, + repoFullName, + pr.number, + `Gittensory detected this pull request has been converted to draft ${draftConversionCount} times — repeatedly cycling between ready and draft to solicit review feedback without a real one-shot attempt 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 repeated draft-cycling by ${pr.authorLogin} -- conversion #${draftConversionCount} on this PR`, + metadata: { deliveryId, repoFullName, headSha: pr.headSha, draftConversionCount }, + }).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/test/unit/queue.test.ts b/test/unit/queue.test.ts index 9c5c926cea..30b50e93e8 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -24523,6 +24523,11 @@ describe("review-evasion protection (#review-evasion-protection)", () => { 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([]); + // A .gittensory.yml content fetch (raw.githubusercontent.com) must resolve to SOMETHING with no opinion + // on reviewEvasionProtection -- otherwise a miss here falls through to the bundled JSONbored/gittensory + // fallback manifest (gittensory-repo-focus-manifest.ts), whose OWN checked-in reviewEvasionProtection: + // close would silently outrank every test below's DB-level override (yml > DB precedence, #config-as-code). + if (url.includes("raw.githubusercontent.com") && url.includes("gittensory.y")) return new Response("source: repo_file\n", { status: 200 }); return new Response("not found", { status: 404 }); }); } @@ -25502,6 +25507,488 @@ describe("review-evasion protection (#review-evasion-protection)", () => { expect(labelPostBodies.some((b) => b.includes("review-evasion"))).toBe(true); }); }); + + describe("repeated ready<->draft cycling (#gaming-tactic-draft-cycle)", () => { + it("does nothing on the FIRST draft conversion, then closes on the SECOND -- independent of active-review/gate-block state", 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.upsertGlobalModerationConfig(env, { enabled: true, rules: ["review_evasion"] }); + // Deliberately NO startActiveReviewTracking / recordGateBlockOutcome call -- neither sibling guard's own + // trigger condition is present, so any close observed below can only be this new, count-based guard. + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-2", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + const patches = calls.filter((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42")); + expect(patches).toHaveLength(1); + 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 = ? order by rowid desc limit 1") + .bind("github_app.review_evasion_closed") + .first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("completed"); + expect(audit?.detail).toContain("repeated draft-cycling"); + expect(audit?.detail).toContain("#2"); + 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("does nothing when reviewEvasionProtection is off, even after a repeated cycle", 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 processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-off-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-off-2", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing when reviewEvasionProtection is unset (undefined, not just 'off'), even after a repeated cycle", 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); + const baseSettings = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory"); + vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue({ ...baseSettings, reviewEvasionProtection: undefined }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-unset-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-unset-2", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("REGRESSION (gate-flagged): does not enforce against a THIRD PARTY repeatedly converting someone else's PR to draft", 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); + const payload = draftEvasionPayload("contributor"); + payload.sender = { login: "a-maintainer", type: "User" }; + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-third-party-1", eventName: "pull_request", payload }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-third-party-2", 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]), even after a repeated cycle", 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 processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-bot-1", eventName: "pull_request", payload: draftEvasionPayload("dependabot[bot]") }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-bot-2", 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, even after a repeated cycle", 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); + const payload = draftEvasionPayload("contributor"); + payload.pull_request.head = null; + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-no-head-1", eventName: "pull_request", payload }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-no-head-2", eventName: "pull_request", payload }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing when the author holds write collaborator permission, even after a repeated cycle", 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 processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-maintainer-1", eventName: "pull_request", payload: draftEvasionPayload("write-collaborator") }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-maintainer-2", eventName: "pull_request", payload: draftEvasionPayload("write-collaborator") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + 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 processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-observe-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-observe-2", 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 = ? order by rowid desc limit 1") + .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("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.upsertGlobalModerationConfig(env, { enabled: true, rules: ["review_evasion"] }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-dry-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-dry-2", 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 = ? order by rowid desc limit 1") + .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 paused for this repo", 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, { agentPaused: true }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-paused-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-paused-2", 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 = ? order by rowid desc limit 1") + .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 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 processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-no-write-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-no-write-2", 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 = ? order by rowid desc limit 1") + .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 processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-fresh-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + vi.mocked(fetchPullRequestFreshness).mockResolvedValueOnce({ status: "stale", reason: "no_longer_draft", expectedHeadSha: "abc123", liveHeadSha: "abc123", liveState: "open" }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-fresh-2", 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 = ? order by rowid desc limit 1") + .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 }); + if (url.includes("raw.githubusercontent.com") && url.includes("gittensory.y")) return new Response("source: repo_file\n", { status: 200 }); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + await repositoriesModule.upsertGlobalModerationConfig(env, { enabled: true, rules: ["review_evasion"] }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-close-fail-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-close-fail-2", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ? order by rowid desc limit 1") + .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 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 processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-mod-off-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-mod-off-2", 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, detail from audit_events where event_type = ? order by rowid desc limit 1") + .bind("github_app.review_evasion_closed") + .first<{ outcome: string; detail: 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: the third (and every later) conversion is enforced too, not just exactly the second", 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; + return Response.json({ state: "open" }); // simulate the close failing to stick / a reopen between cycles + } + 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" }]); + if (url.includes("/pulls/42/files")) return Response.json([]); + if (url.includes("raw.githubusercontent.com") && url.includes("gittensory.y")) return new Response("source: repo_file\n", { status: 200 }); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-third-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-third-2", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-third-3", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(patchCount).toBe(2); // enforced on the 2nd AND the 3rd -- >= 2, not === 2. + const completed = await env.DB.prepare("select count(*) as n from audit_events where event_type = ? and outcome = 'completed'") + .bind("github_app.review_evasion_closed") + .first<{ n: number }>(); + expect(completed?.n).toBe(2); + }); + + 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: {} -- draft-dodge's own outer dispatch condition (isAgentConfigured) is false, so + // ITS lock claim never fires. The remaining sibling (review-evasion-active-review) has no settings gate at + // its OWN lock claim, so it claims+releases the lock normally first (mocked to succeed); THIS guard's own + // subsequent claim -- the second and only other .claim() call in this pass -- is the one mocked to fail, + // isolating its throw from the sibling's identically-shaped one. + await setupEvasionRepo(env, { autonomy: {} }); + const claimSpy = vi.spyOn(env.SELFHOST_TRANSIENT_CACHE!, "claim").mockResolvedValueOnce(true).mockResolvedValueOnce(false); + + await expect( + processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-lock-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }), + ).rejects.toThrow("during review-evasion-draft-cycle"); + + expect(claimSpy).toHaveBeenCalledTimes(2); + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing when the webhook payload has no sender, even after a repeated cycle", 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); + const payload = draftEvasionPayload("contributor"); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-no-sender-1", eventName: "pull_request", payload: { ...payload, sender: undefined } }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-no-sender-2", eventName: "pull_request", payload: { ...payload, sender: undefined } }); + + 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), even after a repeated cycle", 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); + const payload = draftEvasionPayload("contributor"); + payload.pull_request.user = null; + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-no-author-1", eventName: "pull_request", payload }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-no-author-2", 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 processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-no-install-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + vi.spyOn(repositoriesModule, "getInstallation").mockResolvedValue(null); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-no-install-2", 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 = ? order by rowid desc limit 1") + .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 explicitly false", 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, { reviewEvasionComment: false }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-comment-false-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-comment-false-2", eventName: "pull_request", payload: draftEvasionPayload("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(false); + }); + + it("posts the courtesy comment when reviewEvasionComment is unset (undefined, not just a stored 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); + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-comment-unset-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + const baseSettings = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory"); + vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue({ ...baseSettings, reviewEvasionComment: undefined }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-comment-unset-2", 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([]); + 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 processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-label-null-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + const baseSettings = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory"); + vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue({ ...baseSettings, reviewEvasionLabel: null }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-label-null-2", 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 = ? order by rowid desc limit 1").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 (undefined, not just a stored default)", 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([]); + 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 processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-label-unset-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + const baseSettings = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory"); + vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue({ ...baseSettings, reviewEvasionLabel: undefined }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-label-unset-2", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(labelPostBodies.some((b) => b.includes("review-evasion"))).toBe(true); + }); + }); + + describe("bumpPullRequestDraftConversionCount", () => { + it("increments across repeated calls for the same PR and is independent of head SHA", async () => { + const env = createTestEnv({}); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await repositoriesModule.upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { + id: 4242, + number: 77, + state: "open", + title: "Some PR", + user: { login: "contributor" }, + head: { sha: "sha-1", ref: "fix", repo: { full_name: "contributor/gittensory", owner: { login: "contributor" } } }, + base: { sha: "base123", ref: "main", repo: { full_name: "JSONbored/gittensory", owner: { login: "JSONbored" } } }, + draft: false, + merged: false, + created_at: "2026-05-27T00:00:00Z", + updated_at: "2026-05-27T00:00:00Z", + } as never); + + expect(await repositoriesModule.bumpPullRequestDraftConversionCount(env, "JSONbored/gittensory", 77)).toBe(1); + expect(await repositoriesModule.bumpPullRequestDraftConversionCount(env, "JSONbored/gittensory", 77)).toBe(2); + // A fresh push (new head SHA) between cycles must NOT reset the counter -- unlike mergeAttemptCount. + await repositoriesModule.upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { + id: 4242, + number: 77, + state: "open", + title: "Some PR", + user: { login: "contributor" }, + head: { sha: "sha-2", ref: "fix", repo: { full_name: "contributor/gittensory", owner: { login: "contributor" } } }, + base: { sha: "base123", ref: "main", repo: { full_name: "JSONbored/gittensory", owner: { login: "JSONbored" } } }, + draft: false, + merged: false, + created_at: "2026-05-27T00:00:00Z", + updated_at: "2026-05-27T00:00:00Z", + } as never); + expect(await repositoriesModule.bumpPullRequestDraftConversionCount(env, "JSONbored/gittensory", 77)).toBe(3); + }); + + it("returns 0 for a PR that does not exist (no row to increment)", async () => { + const env = createTestEnv({}); + expect(await repositoriesModule.bumpPullRequestDraftConversionCount(env, "JSONbored/gittensory", 999999)).toBe(0); + }); + }); }); describe("recordAgentCommandUsage (signal-snapshot fail-safe)", () => { diff --git a/test/unit/slop.test.ts b/test/unit/slop.test.ts index d43dc895d6..4c77f50556 100644 --- a/test/unit/slop.test.ts +++ b/test/unit/slop.test.ts @@ -113,16 +113,18 @@ describe("buildSlopAssessment", () => { expect(buildDuplicateClusterFinding({ inDuplicateCluster: false })).toBeNull(); }); - it("stacks the duplicate-cluster weight with another signal into the expected band (#563)", () => { + it("stacks the duplicate-cluster weight with two other signals into the expected band (#563, #3939 recalibration)", () => { const result = buildSlopAssessment({ - // code file with no test evidence → missing_test_evidence (30); non-empty description suppresses empty_description. + // code file with no test evidence → missing_test_evidence (15); non-empty description suppresses empty_description. changedFiles: [{ path: "src/parser.ts", additions: 10, deletions: 1 }], description: "Refactor the parser.", inDuplicateCluster: true, // → duplicate_cluster_membership (15) + hasLinkedIssue: false, // → no_linked_issue_without_rationale (15) -- a third weak signal, needed post-#3939: + // two weak signals alone (30) now land in `low` (1-30), not `elevated` (31-59); three reaches 45. }); - expect(result.slopRisk).toBe(SLOP_WEIGHTS.missingTestEvidence + SLOP_WEIGHTS.duplicateClusterMembership); + expect(result.slopRisk).toBe(SLOP_WEIGHTS.missingTestEvidence + SLOP_WEIGHTS.duplicateClusterMembership + SLOP_WEIGHTS.noLinkedIssueWithoutRationale); expect(result.band).toBe("elevated"); - expect(result.findings.map((finding) => finding.code).sort()).toEqual(["duplicate_cluster_membership", "missing_test_evidence"]); + expect(result.findings.map((finding) => finding.code).sort()).toEqual(["duplicate_cluster_membership", "missing_test_evidence", "no_linked_issue_without_rationale"]); expect(JSON.stringify(result)).not.toMatch(FORBIDDEN_PUBLIC_TERMS); }); @@ -196,7 +198,8 @@ describe("buildSlopAssessment", () => { }); expect(result.slopRisk).toBe(SLOP_WEIGHTS.trivialWhitespaceChurn); - expect(result.band).toBe("elevated"); + // A single strong signal (30) alone is `low` (1-30), not `elevated` (31-59) — post-#3939 recalibration. + expect(result.band).toBe("low"); expect(result.findings).toEqual([ expect.objectContaining({ code: "trivial_whitespace_churn", @@ -307,9 +310,10 @@ describe("buildSlopAssessment", () => { }); it("reaches the high band when multiple strong signals stack", () => { - // Code change, no tests, no description: missing-test-evidence (15) + empty-description (15) = 30 = elevated. - const elevated = buildSlopAssessment({ changedFiles: [{ path: "src/x.ts", additions: 10, deletions: 1 }], description: "" }); - expect(elevated.band).toBe("elevated"); + // Code change, no tests, no description: missing-test-evidence (15) + empty-description (15) = 30 = low + // (post-#3939 recalibration: two weak signals alone no longer reach `elevated`, which now needs ≥31). + const twoWeakSignals = buildSlopAssessment({ changedFiles: [{ path: "src/x.ts", additions: 10, deletions: 1 }], description: "" }); + expect(twoWeakSignals.band).toBe("low"); // High-whitespace-churn code change + no tests + no description: 30 + 15 + 15 = 60 -> high (>=60). const high = buildSlopAssessment({ @@ -625,7 +629,8 @@ describe("buildNonSubstantivePaddingFinding (#561 path-matcher signal)", () => { }); expect(result.findings.map((finding) => finding.code)).toEqual(["non_substantive_padding"]); expect(result.slopRisk).toBe(SLOP_WEIGHTS.nonSubstantivePadding); - expect(result.band).toBe("elevated"); + // A single strong signal (30) alone is `low` (1-30), not `elevated` (31-59) — post-#3939 recalibration. + expect(result.band).toBe("low"); expect(JSON.stringify(result)).not.toMatch(FORBIDDEN); }); }); @@ -643,11 +648,18 @@ describe("slop golden fixtures & determinism (#565)", () => { codes: ["missing_test_evidence"], }, { - name: "elevated — untested code change inside a duplicate cluster", - input: { changedFiles: [{ path: "src/svc.ts", additions: 12, deletions: 3 }], description: "Add retry logic to the sync client.", inDuplicateCluster: true }, - slopRisk: 30, + // Three weak signals (45), not two (30): post-#3939 recalibration, two weak signals alone land in `low` + // (1-30) -- `elevated` (31-59) now needs genuine multi-signal evidence. + name: "elevated — untested, unlinked code change inside a duplicate cluster", + input: { + changedFiles: [{ path: "src/svc.ts", additions: 12, deletions: 3 }], + description: "Add retry logic to the sync client.", + inDuplicateCluster: true, + hasLinkedIssue: false, + }, + slopRisk: 45, band: "elevated", - codes: ["duplicate_cluster_membership", "missing_test_evidence"], + codes: ["duplicate_cluster_membership", "missing_test_evidence", "no_linked_issue_without_rationale"], }, { name: "high — whitespace churn, untested code, and empty description",