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
10 changes: 10 additions & 0 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,16 @@ settings:
# mode: off # off | hold. Default: off.
# minConfidence: 0.85 # Number 0-1. Minimum AI-verifier confidence to treat a candidate as a real match.

# Before/after screenshot-table gate (#2006): deterministic check that a visual/frontend PR's body has a
# before/after image table. Scoped to whenLabels OR whenPaths (either matches); both empty = every PR. Off
# by default.
# screenshotTableGate:
# enabled: false # Default: false.
# whenLabels: [frontend, visual] # Default: [] (no label scoping).
# whenPaths: ["apps/ui/**", "src/**/*.tsx"] # Default: [] (no path scoping).
# action: close # close | request_changes | comment. Default: close.
# message: "Custom close reason..." # Default: null (built-in message).

# Maintainer AI review tuning (`.gittensory.yml` top-level `review:` block). These knobs shape the advisory AI
# review prompt and file selection only — gate/slop/secret-scan are unaffected.
# review:
Expand Down
47 changes: 42 additions & 5 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9242,15 +9242,52 @@
"type": "string",
"nullable": true
},
"publicQualityMetrics": {
"type": "boolean"
"screenshotTableGate": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"whenLabels": {
"type": "array",
"items": {
"type": "string"
}
},
"whenPaths": {
"type": "array",
"items": {
"type": "string"
}
},
"action": {
"type": "string",
"enum": [
"close",
"request_changes",
"comment"
]
},
"message": {
"type": "string"
}
},
"required": [
"enabled",
"whenLabels",
"whenPaths",
"action"
]
},
"regateSweepOrderMode": {
"type": "string",
"enum": [
"staleness",
"oldest-first"
]
},
"publicQualityMetrics": {
"type": "boolean"
}
},
"required": [
Expand Down Expand Up @@ -9971,15 +10008,15 @@
"commandOverrides"
]
},
"publicQualityMetrics": {
"type": "boolean"
},
"regateSweepOrderMode": {
"type": "string",
"enum": [
"staleness",
"oldest-first"
]
},
"publicQualityMetrics": {
"type": "boolean"
}
},
"required": [
Expand Down
10 changes: 10 additions & 0 deletions config/examples/gittensory.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,16 @@ settings:
# mode: off # off | hold. Default: off.
# minConfidence: 0.85 # Number 0-1. Minimum AI-verifier confidence to treat a candidate as a real match.

# Before/after screenshot-table gate (#2006): deterministic check that a visual/frontend PR's body has a
# before/after image table. Scoped to whenLabels OR whenPaths (either matches); both empty = every PR. Off
# by default.
# screenshotTableGate:
# enabled: false # Default: false.
# whenLabels: [frontend, visual] # Default: [] (no label scoping).
# whenPaths: ["apps/ui/**", "src/**/*.tsx"] # Default: [] (no path scoping).
# action: close # close | request_changes | comment. Default: close.
# message: "Custom close reason..." # Default: null (built-in message).

# Maintainer AI review tuning (`.gittensory.yml` top-level `review:` block). These knobs shape the advisory AI
# review prompt and file selection only — gate/slop/secret-scan are unaffected.
# review:
Expand Down
14 changes: 14 additions & 0 deletions migrations/0117_screenshot_table_gate.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Config-driven before/after screenshot-table gate (#2006): a contributor visual/frontend PR that lacks a
-- before/after screenshot table in its body is unreviewable at a glance. This mechanism was previously
-- documented-only (the contributing skill / PR template); this migration makes it a per-repo, config-as-code
-- gate, layered the same way as every other anti-abuse mechanism in this file (blacklist/review-nag/
-- review-evasion): off by default (zero behavior change for an install that hasn't opted in),
-- `screenshot_table_gate_action` NOT NULL with a "close" default (mirrors the existing hard requirement), and
-- the label/path scope lists stored as JSON (mirrors contributor_blacklist_json / auto_close_exempt_logins_json).
ALTER TABLE repository_settings ADD COLUMN screenshot_table_gate_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE repository_settings ADD COLUMN screenshot_table_gate_when_labels_json TEXT NOT NULL DEFAULT '[]';
ALTER TABLE repository_settings ADD COLUMN screenshot_table_gate_when_paths_json TEXT NOT NULL DEFAULT '[]';
ALTER TABLE repository_settings ADD COLUMN screenshot_table_gate_action TEXT NOT NULL DEFAULT 'close';
-- Nullable: null = use the built-in default templated contract message (DEFAULT_SCREENSHOT_CONTRACT_MESSAGE),
-- never "unset to empty" (mirrors moderation_rules_json's null-means-inherit-default shape).
ALTER TABLE repository_settings ADD COLUMN screenshot_table_gate_message TEXT;
38 changes: 38 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ import type {
ReviewSuppressionRecord,
ScorePreviewRecord,
ScoringModelSnapshotRecord,
ScreenshotTableGateConfig,
SignalSnapshotRecord,
UpstreamDriftArea,
UpstreamDriftReportRecord,
Expand All @@ -172,6 +173,7 @@ import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy, DEFAULT_AUTO_MAIN
import { DEFAULT_TYPE_LABELS, normalizeTypeLabelSet } from "../settings/pr-type-label";
import { DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION, normalizeLinkedIssueLabelPropagationConfig } from "../review/linked-issue-label-propagation";
import { DEFAULT_LINKED_ISSUE_HARD_RULES } from "../review/linked-issue-hard-rules-config";
import { DEFAULT_SCREENSHOT_TABLE_GATE, isScreenshotTableGateAction, normalizeScreenshotTableGateConfig } from "../review/screenshot-table-gate";
import { decryptSecret, encryptSecret, sha256Hex } from "../utils/crypto";
import { errorMessage, jsonString, nowIso, parseJson, repoParts } from "../utils/json";
import { PUBLIC_LOCAL_PATH_SCRUB_PATTERN } from "../signals/redaction";
Expand Down Expand Up @@ -564,6 +566,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
reviewEvasionProtection: "off",
reviewEvasionLabel: DEFAULT_REVIEW_EVASION_LABEL,
reviewEvasionComment: true,
screenshotTableGate: { ...DEFAULT_SCREENSHOT_TABLE_GATE, whenLabels: [], whenPaths: [] },
};
}
return {
Expand Down Expand Up @@ -641,6 +644,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
reviewEvasionProtection: normalizeReviewEvasionProtection(row.reviewEvasionProtection),
reviewEvasionLabel: row.reviewEvasionLabel,
reviewEvasionComment: row.reviewEvasionComment,
screenshotTableGate: parseScreenshotTableGateRow(row),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
Expand Down Expand Up @@ -760,6 +764,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
reviewEvasionProtection: normalizeReviewEvasionProtection(settings.reviewEvasionProtection),
reviewEvasionLabel: settings.reviewEvasionLabel ?? DEFAULT_REVIEW_EVASION_LABEL,
reviewEvasionComment: settings.reviewEvasionComment ?? true,
screenshotTableGate: normalizeScreenshotTableGateConfig(settings.screenshotTableGate, []),
} satisfies RepositorySettings;
const db = getDb(env.DB);
await db
Expand Down Expand Up @@ -838,6 +843,11 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
reviewEvasionProtection: resolved.reviewEvasionProtection,
reviewEvasionLabel: resolved.reviewEvasionLabel,
reviewEvasionComment: resolved.reviewEvasionComment,
screenshotTableGateEnabled: resolved.screenshotTableGate.enabled,
screenshotTableGateWhenLabelsJson: jsonString(resolved.screenshotTableGate.whenLabels),
screenshotTableGateWhenPathsJson: jsonString(resolved.screenshotTableGate.whenPaths),
screenshotTableGateAction: resolved.screenshotTableGate.action,
screenshotTableGateMessage: resolved.screenshotTableGate.message ?? null,
updatedAt: nowIso(),
})
.onConflictDoUpdate({
Expand Down Expand Up @@ -917,6 +927,11 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
reviewEvasionProtection: resolved.reviewEvasionProtection,
reviewEvasionLabel: resolved.reviewEvasionLabel,
reviewEvasionComment: resolved.reviewEvasionComment,
screenshotTableGateEnabled: resolved.screenshotTableGate.enabled,
screenshotTableGateWhenLabelsJson: jsonString(resolved.screenshotTableGate.whenLabels),
screenshotTableGateWhenPathsJson: jsonString(resolved.screenshotTableGate.whenPaths),
screenshotTableGateAction: resolved.screenshotTableGate.action,
screenshotTableGateMessage: resolved.screenshotTableGate.message ?? null,
updatedAt: nowIso(),
},
});
Expand Down Expand Up @@ -6782,6 +6797,29 @@ function normalizeReviewEvasionProtection(value: string | null | undefined): "of
return value === "close" ? "close" : "off";
}

// Config-driven before/after screenshot-table gate (#2006): the row stores whenLabels/whenPaths as JSON string
// arrays (mirroring contributorBlacklistJson's shape) across dedicated flat columns rather than one combined
// JSON blob, so a self-hoster can inspect/edit a single field (e.g. just the action) without round-tripping the
// whole object.
function parseScreenshotTableGateRow(row: typeof repositorySettings.$inferSelect): ScreenshotTableGateConfig {
return {
enabled: row.screenshotTableGateEnabled,
whenLabels: parseJsonStringArray(row.screenshotTableGateWhenLabelsJson),
whenPaths: parseJsonStringArray(row.screenshotTableGateWhenPathsJson),
action: isScreenshotTableGateAction(row.screenshotTableGateAction) ? row.screenshotTableGateAction : DEFAULT_SCREENSHOT_TABLE_GATE.action,
...(row.screenshotTableGateMessage ? { message: row.screenshotTableGateMessage } : {}),
};
}

// Generic "JSON array of non-empty strings" parse for a column with no additional per-item validation (labels
// and path globs have no fixed shape, unlike a GitHub login) -- any malformed/non-string entry is silently
// dropped, never throws, matching every other settings parse in this file.
function parseJsonStringArray(value: string): string[] {
const parsed = parseJson<unknown>(value, []);
if (!Array.isArray(parsed)) return [];
return parsed.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
}

function normalizeCommandRateLimitPolicy(value: string | null | undefined): "off" | "hold" {
return value === "hold" ? value : "off";
}
Expand Down
8 changes: 8 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ export const repositorySettings = sqliteTable("repository_settings", {
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),
// Config-driven before/after screenshot-table gate (#2006): off by default. whenLabels/whenPaths are JSON
// string arrays (mirrors contributorBlacklistJson's shape); screenshotTableGateMessage is nullable ("no
// override" is a `.gittensory.yml`-only concept -- null here means "use the built-in default message").
screenshotTableGateEnabled: integer("screenshot_table_gate_enabled", { mode: "boolean" }).notNull().default(false),
screenshotTableGateWhenLabelsJson: text("screenshot_table_gate_when_labels_json").notNull().default("[]"),
screenshotTableGateWhenPathsJson: text("screenshot_table_gate_when_paths_json").notNull().default("[]"),
screenshotTableGateAction: text("screenshot_table_gate_action").notNull().default("close"),
screenshotTableGateMessage: text("screenshot_table_gate_message"),
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
});
Expand Down
9 changes: 9 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,15 @@ export const RepositorySettingsSchema = z
reviewEvasionProtection: z.enum(["off", "close"]).optional(),
reviewEvasionLabel: z.string().nullable().optional(),
reviewEvasionComment: z.boolean().optional(),
screenshotTableGate: z
.object({
enabled: z.boolean(),
whenLabels: z.array(z.string()),
whenPaths: z.array(z.string()),
action: z.enum(["close", "request_changes", "comment"]),
message: z.string().optional(),
})
.optional(),
createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(),
})
Expand Down
20 changes: 20 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ import {
} from "../review/linked-issue-hard-rules";
import { DEFAULT_UNLINKED_ISSUE_GUARDRAIL } from "../review/unlinked-issue-guardrail-config";
import { resolveUnlinkedIssueMatchDisposition } from "../review/unlinked-issue-guardrail";
import { DEFAULT_SCREENSHOT_TABLE_GATE, evaluateScreenshotTableGate } from "../review/screenshot-table-gate";
import { isOpsEnabled, runOpsAlerts } from "../review/ops-wire";
import { isSweepWatchdogEnabled, runSweepLivenessWatchdog } from "../review/sweep-watchdog";
import { isSelfTuneEnabled, runSelfTune } from "../review/selftune-wire";
Expand Down Expand Up @@ -2708,6 +2709,24 @@ async function runAgentMaintenancePlanAndExecute(
settings.contributorBlacklist,
);

// Screenshot-table gate (#2006): a DETERMINISTIC check (no AI) that an in-scope (label/path-matched)
// contributor visual/frontend PR's body contains a before/after screenshot table. Off by default
// (settings.screenshotTableGate.enabled === false), so the pure evaluator below is effectively free for the
// common case. Only "close" is wired as an enforcement action here (the other configured actions stay
// advisory, matching the issue's phased rollout) -- the ternary below is the ONLY place that reads `.action`.
/* v8 ignore next -- defensive: resolveRepositorySettings always populates screenshotTableGate (getRepositorySettings's DB defaults), so this fallback is unreachable in practice. */
const screenshotTableGateConfig = settings.screenshotTableGate ?? DEFAULT_SCREENSHOT_TABLE_GATE;
const screenshotTableGateResult = evaluateScreenshotTableGate({
config: screenshotTableGateConfig,
prBody: pr.body,
prLabels: pr.labels,
changedFiles: changedPaths,
});
const screenshotTableMatch =
screenshotTableGateResult.violated && screenshotTableGateConfig.action === "close"
? { matched: true, reason: screenshotTableGateResult.reason }
: undefined;

// Account-age throttle (#2561, anti-abuse): a friction/visibility signal for the classic ban-evasion pattern
// (a banned login gets a fresh account the same day) — NEVER an automatic close on account age alone. Off
// (null accountAgeThresholdDays, the default) ⇒ this block is a no-op, no extra GitHub API call at all.
Expand Down Expand Up @@ -2862,6 +2881,7 @@ async function runAgentMaintenancePlanAndExecute(
: {}),
// Always threaded (the DB layer populates it, default "slop"); the planner applies its own fallback.
blacklistLabel: settings.blacklistLabel,
...(screenshotTableMatch !== undefined ? { screenshotTableMatch } : {}),
...(contributorCapMatch !== undefined ? { contributorCapMatch } : {}),
// Always threaded (the DB layer populates it, default "over-contributor-limit"); the planner applies its
// own fallback.
Expand Down
Loading
Loading