Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gittensory.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions migrations/0118_pull_request_draft_conversion_count.sql
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions src/config/gittensory-repo-focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
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. */
Expand Down
5 changes: 5 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
229 changes: 229 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
markGateOutcomeOverridden,
startActiveReviewTracking,
terminalizeActiveReviewTracking,
bumpPullRequestDraftConversionCount,
recordProductUsageEvent,
persistSignalSnapshot,
recordWebhookEvent,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
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<void> {
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";
Expand Down
Loading
Loading