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
22 changes: 22 additions & 0 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9022,7 +9022,8 @@
"enum": [
"contributor_cap",
"blacklist",
"review_nag"
"review_nag",
"review_evasion"
]
}
},
Expand Down Expand Up @@ -9205,6 +9206,20 @@
"github",
"linear"
]
},
"reviewEvasionProtection": {
"type": "string",
"enum": [
"off",
"close"
]
},
"reviewEvasionLabel": {
"type": "string",
"nullable": true
},
"reviewEvasionComment": {
"type": "boolean"
}
},
"required": [
Expand Down
30 changes: 30 additions & 0 deletions migrations/0113_review_evasion_protection.sql
Original file line number Diff line number Diff line change
@@ -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;
99 changes: 98 additions & 1 deletion src/db/repositories.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -728,6 +735,9 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
moderationRules: settings.moderationRules,
moderationWarningLabel: normalizeModerationLabel(settings.moderationWarningLabel),
moderationBannedLabel: normalizeModerationLabel(settings.moderationBannedLabel),
reviewEvasionProtection: normalizeReviewEvasionProtection(settings.reviewEvasionProtection),
reviewEvasionLabel: settings.reviewEvasionLabel ?? DEFAULT_REVIEW_EVASION_LABEL,
reviewEvasionComment: settings.reviewEvasionComment ?? true,
} satisfies RepositorySettings;
const db = getDb(env.DB);
await db
Expand Down Expand Up @@ -801,6 +811,9 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
moderationRulesJson: resolved.moderationRules === undefined ? null : jsonString(resolved.moderationRules),
moderationWarningLabel: resolved.moderationWarningLabel ?? null,
moderationBannedLabel: resolved.moderationBannedLabel ?? null,
reviewEvasionProtection: resolved.reviewEvasionProtection,
reviewEvasionLabel: resolved.reviewEvasionLabel,
reviewEvasionComment: resolved.reviewEvasionComment,
updatedAt: nowIso(),
})
.onConflictDoUpdate({
Expand Down Expand Up @@ -875,6 +888,9 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
moderationRulesJson: resolved.moderationRules === undefined ? null : jsonString(resolved.moderationRules),
moderationWarningLabel: resolved.moderationWarningLabel ?? null,
moderationBannedLabel: resolved.moderationBannedLabel ?? null,
reviewEvasionProtection: resolved.reviewEvasionProtection,
reviewEvasionLabel: resolved.reviewEvasionLabel,
reviewEvasionComment: resolved.reviewEvasionComment,
updatedAt: nowIso(),
},
});
Expand Down Expand Up @@ -4546,6 +4562,80 @@ export async function getGateBlockOutcome(
return { headSha: row.headSha, blockerCodes: parseJson<string[]>(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<void> {
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<boolean> {
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<boolean> {
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 } = {},
Expand Down Expand Up @@ -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";
}
Expand Down
30 changes: 30 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
});
Expand Down Expand Up @@ -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(
Expand Down
19 changes: 19 additions & 0 deletions src/github/pr-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
5 changes: 4 additions & 1 deletion src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
Expand Down
Loading
Loading