Skip to content

Commit aa1ffb8

Browse files
authored
feat(review): one-shot AI review cadence, configurable globally + per repo (#4657)
* feat(review): one-shot AI review cadence, configurable globally + per repo AI-generated review content (main review, slop advisory, linked-issue satisfaction) now freezes after its first pass by default -- no further automatic push/CI-completion/sweep trigger spends a fresh AI call, only an explicit maintainer retrigger does. Configurable via the new GITTENSORY_REVIEW_CONTINUOUS fleet-wide env default and the per-repo review.auto_review.cadence .gittensory.yml override (either direction), so self-hosters who want the traditional re-review-on-push behavior can opt back in. The deterministic gate is unaffected and always re-evaluates. Fixes #4636 * test(review): close linked-issue-skip coverage gap + mark unreachable headSha branches Add the missing positive-case test for the linked-issue-satisfaction one-shot skip actually firing (previously only the negative/never-skip case was covered), and mark the advisory.headSha ?? null fallback on the three new one-shot-cadence audit events as v8-ignored, mirroring the identical, already-established treatment of the same fallback shape elsewhere in maybePublishPrPublicSurface. * test(review): cover the one-shot-cadence fail-safe catch arms codecov flagged codecov/patch flagged 6 lines at 81.82% patch coverage: the .catch() fallback bodies for the three new existence-check lookups and their paired audit-event writes never fired in the initial test suite (the happy path never rejects). Adds one combined read-failure + audit-write-failure regression per feature (slop, linked-issue satisfaction, main-review reuse), mirroring the exact two-part pattern already used for the pre-existing frozen-reuse fail-safe test.
1 parent 450e314 commit aa1ffb8

16 files changed

Lines changed: 736 additions & 33 deletions

apps/gittensory-ui/src/routes/docs.privacy-security.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ GITTENSORY_REVIEW_SCREENSHOTS="true" # before/after visual capture f
9898
GITTENSORY_REVIEW_E2E_TESTS="true" # AI-generated E2E test coverage (needs features.e2eTests too)
9999
100100
# Global (cron / endpoint) flags, not scoped by GITTENSORY_REVIEW_REPOS.
101+
GITTENSORY_REVIEW_CONTINUOUS="true" # fleet-wide default: re-review on every push (else one-shot)
101102
GITTENSORY_REVIEW_OPS="true" # read-only anomaly scan + outcome stats endpoint
102103
GITTENSORY_REVIEW_SELFTUNE="true" # self-tightening tuning loop, never loosens
103104
GITTENSORY_REVIEW_PARITY_AUDIT="true" # shadow-record gate-decision parity readiness

apps/gittensory-ui/src/routes/docs.tuning.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,18 @@ function Tuning() {
138138
needs its own <code>features.e2eTests: true</code> override in{" "}
139139
<code>.gittensory.yml</code> before the feature is active for it. Per-PR.
140140
</li>
141+
<li>
142+
<code>GITTENSORY_REVIEW_CONTINUOUS</code> — fleet-wide default AI review re-trigger
143+
cadence. Off by default (one-shot): AI-generated content (main review, slop advisory,
144+
linked-issue satisfaction) is produced once per PR and never regenerated automatically
145+
afterward — only an explicit maintainer retrigger (the PR-panel checkbox, or{" "}
146+
<code>@gittensory review</code> as a maintainer) spends a fresh call. Truthy switches the
147+
fleet default to continuous — every push/CI-completion/sweep re-runs AI content
148+
generation. A repo's own <code>review.auto_review.cadence</code> in{" "}
149+
<code>.gittensory.yml</code> always overrides this default, in either direction. Never
150+
affects the deterministic gate (CI status, mergeability, static-rule blockers), which
151+
always re-evaluates regardless.
152+
</li>
141153
<li>
142154
<code>GITTENSORY_REVIEW_RAG</code> — retrieval-augmented context: queries the codebase
143155
vector index for related code and docs (callers, related modules, existing conventions)

packages/gittensory-engine/src/focus-manifest.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,10 +644,25 @@ export type LabelingRule = {
644644
descriptionContains: string | null;
645645
};
646646

647+
/** `review.auto_review.cadence` (#one-shot-review-cadence). `one_shot` = the AI-generated content (main review,
648+
* slop advisory, linked-issue satisfaction) is produced once per PR and never automatically regenerated
649+
* afterward — not on a new push, not on CI-check completion, not on a scheduled sweep tick; only an explicit
650+
* maintainer retrigger (the PR-panel checkbox or `@gittensory review` as a maintainer) spends a fresh call.
651+
* `continuous` = the traditional behavior — every trigger re-runs AI content generation, subject to each
652+
* feature's own head-SHA cache. Orthogonal to `aiReviewMode`'s enforcement-strictness axis (off/advisory/
653+
* block) — the deterministic gate (CI status, mergeability, static-rule blockers) is NEVER affected by this
654+
* and always re-evaluates on every pass regardless of cadence. */
655+
export const AI_REVIEW_CADENCES = ["one_shot", "continuous"] as const;
656+
export type AiReviewCadence = (typeof AI_REVIEW_CADENCES)[number];
657+
647658
/** Per-repo AI review eligibility knobs under `review.auto_review`. Unset fields are byte-identical defaults. */
648659
export type AutoReviewConfig = {
649660
/** `review.auto_review.skip_drafts`: when true, draft PRs skip AI review. null (default) ⇒ drafts reviewed as today. (#2038) */
650661
skipDrafts: boolean | null;
662+
/** `review.auto_review.cadence`: per-repo override of the AI review re-trigger cadence. null (default) ⇒
663+
* inherit the operator's fleet-wide GITTENSORY_REVIEW_CONTINUOUS default (itself "one_shot" when unset).
664+
* (#one-shot-review-cadence) */
665+
cadence: AiReviewCadence | null;
651666
/** `review.auto_review.ignore_authors`: author-login globs whose PRs skip AI review. Empty ⇒ every author. (#2039) */
652667
ignoreAuthors: string[];
653668
/** `review.auto_review.ignore_title_keywords`: case-insensitive title substrings that skip AI review. Empty ⇒ no skip. (#2040) */
@@ -677,6 +692,7 @@ export const EMPTY_MAX_FINDINGS_CONFIG: MaxFindingsConfig = { blockers: null, ni
677692

678693
export const EMPTY_AUTO_REVIEW_CONFIG: AutoReviewConfig = {
679694
skipDrafts: null,
695+
cadence: null,
680696
ignoreAuthors: [],
681697
ignoreTitleKeywords: [],
682698
skipLabels: [],
@@ -2299,6 +2315,7 @@ function overlayMaxFindingsConfig(base: MaxFindingsConfig, override: MaxFindings
22992315
function overlayAutoReviewConfig(base: AutoReviewConfig, override: AutoReviewConfig): AutoReviewConfig {
23002316
return {
23012317
skipDrafts: pickOverlayNullable(override.skipDrafts, base.skipDrafts),
2318+
cadence: pickOverlayNullable(override.cadence, base.cadence),
23022319
ignoreAuthors: pickOverlayStringList(override.ignoreAuthors, base.ignoreAuthors),
23032320
ignoreTitleKeywords: pickOverlayStringList(override.ignoreTitleKeywords, base.ignoreTitleKeywords),
23042321
skipLabels: pickOverlayStringList(override.skipLabels, base.skipLabels),
@@ -2493,6 +2510,7 @@ function parseReviewLabelingRules(value: JsonValue | undefined, warnings: string
24932510
function autoReviewPresent(config: AutoReviewConfig): boolean {
24942511
return (
24952512
config.skipDrafts !== null ||
2513+
config.cadence !== null ||
24962514
config.ignoreAuthors.length > 0 ||
24972515
config.ignoreTitleKeywords.length > 0 ||
24982516
config.skipLabels.length > 0 ||
@@ -2514,6 +2532,7 @@ function parseAutoReviewConfig(value: JsonValue | undefined, warnings: string[])
25142532
const record = value as Record<string, JsonValue>;
25152533
return {
25162534
skipDrafts: normalizeOptionalBoolean(record.skip_drafts, "review.auto_review.skip_drafts", warnings),
2535+
cadence: normalizeOptionalEnum(record.cadence, "review.auto_review.cadence", AI_REVIEW_CADENCES, warnings),
25172536
ignoreAuthors: parseManifestGlobList(record.ignore_authors, "review.auto_review.ignore_authors", warnings),
25182537
ignoreTitleKeywords: parseAutoReviewTitleKeywords(record.ignore_title_keywords, warnings),
25192538
skipLabels: parseAutoReviewSkipLabels(record.skip_labels, warnings),
@@ -2937,6 +2956,7 @@ export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue
29372956
if (autoReviewPresent(review.autoReview)) {
29382957
const autoReview: Record<string, JsonValue> = {};
29392958
if (review.autoReview.skipDrafts !== null) autoReview.skip_drafts = review.autoReview.skipDrafts;
2959+
if (review.autoReview.cadence !== null) autoReview.cadence = review.autoReview.cadence;
29402960
if (review.autoReview.ignoreAuthors.length > 0) autoReview.ignore_authors = [...review.autoReview.ignoreAuthors];
29412961
if (review.autoReview.ignoreTitleKeywords.length > 0) autoReview.ignore_title_keywords = [...review.autoReview.ignoreTitleKeywords];
29422962
if (review.autoReview.skipLabels.length > 0) autoReview.skip_labels = [...review.autoReview.skipLabels];

src/db/repositories.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4759,6 +4759,19 @@ export async function putCachedAiSlopAdvisory(
47594759
.run();
47604760
}
47614761

4762+
/** #one-shot-review-cadence: does at least one slop-advisory row exist for this PR, regardless of head SHA?
4763+
* Consulted ONLY when the resolved AI review cadence is "one_shot" — an existing row means this PR already
4764+
* had its one-shot slop pass, so a later automatic trigger (push/CI-completion/sweep) must not spend another
4765+
* LLM call. Existence-only (no reuse-for-display): mirrors the pre-existing commitThresholdReached silent-skip
4766+
* precedent for this same feature, which also does not resurface a prior finding once paused. */
4767+
export async function hasPublishedAiSlopAdvisory(env: Env, repoFullName: string, pullNumber: number): Promise<boolean> {
4768+
const row = await env.DB
4769+
.prepare("SELECT 1 AS present FROM ai_slop_cache WHERE repo_full_name = ? AND pull_number = ? LIMIT 1")
4770+
.bind(repoFullName, pullNumber)
4771+
.first<{ present: number }>();
4772+
return Boolean(row);
4773+
}
4774+
47624775
/** #linked-issue-satisfaction-cache: the stored linked-issue satisfaction result for (repo, pull, head SHA,
47634776
* linked issue number), or null on a miss. Mirrors getCachedAiSlopAdvisory -- every stored row is
47644777
* unconditionally durable (no cacheable/allowNonCacheable/maxAgeMs dimension). A nullish head SHA is always a
@@ -4814,6 +4827,25 @@ export async function putCachedLinkedIssueSatisfaction(
48144827
.run();
48154828
}
48164829

4830+
/** #one-shot-review-cadence: does a linked-issue satisfaction row exist for this PR + linked issue number,
4831+
* regardless of head SHA? Consulted ONLY when the resolved AI review cadence is "one_shot", mirroring
4832+
* hasPublishedAiSlopAdvisory. Scoped ADDITIONALLY to linkedIssueNumber (not just the PR) — a PR's primary
4833+
* linked issue can change between passes (see linked_issue_satisfaction_cache's own doc comment), and a
4834+
* newly-linked issue has never been assessed, so it must still get its own first pass under one-shot mode
4835+
* rather than being silently blocked by an unrelated issue's prior assessment. */
4836+
export async function hasPublishedLinkedIssueSatisfaction(
4837+
env: Env,
4838+
repoFullName: string,
4839+
pullNumber: number,
4840+
linkedIssueNumber: number,
4841+
): Promise<boolean> {
4842+
const row = await env.DB
4843+
.prepare("SELECT 1 AS present FROM linked_issue_satisfaction_cache WHERE repo_full_name = ? AND pull_number = ? AND linked_issue_number = ? LIMIT 1")
4844+
.bind(repoFullName, pullNumber, linkedIssueNumber)
4845+
.first<{ present: number }>();
4846+
return Boolean(row);
4847+
}
4848+
48174849
/** #4499 (grounding-file-content-cache): the stored file content for (repo, path, head SHA), or null on a
48184850
* miss. Unlike linked_issue_satisfaction_cache, every stored row is durable with NO input-fingerprint
48194851
* dimension -- file content at an immutable head SHA has exactly one correct value, so a hit is always safe

src/env.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,15 @@ declare global {
259259
* E2E test coverage feature. Default OFF — unset/false the feature is never active for any repo regardless
260260
* of a per-repo `features.e2eTests` override. */
261261
GITTENSORY_REVIEW_E2E_TESTS?: string;
262+
/** #one-shot-review-cadence: the operator's FLEET-WIDE default for AI review re-trigger cadence, consulted
263+
* only when a repo's `.gittensory.yml review.auto_review.cadence` is unset (a per-repo value always wins
264+
* regardless of this flag — see resolveAiReviewCadence). Default OFF (unset/false) ⇒ "one_shot": the
265+
* AI-generated content (main review, slop advisory, linked-issue satisfaction) freezes after its first
266+
* pass for every repo, and only an explicit maintainer retrigger spends a fresh call. Truthy ⇒
267+
* "continuous": the traditional behavior where every push/CI-completion/sweep trigger re-runs AI content
268+
* generation, for operators who prefer that over one-shot. Never affects the deterministic gate (CI
269+
* status, mergeability, static-rule blockers), which always re-evaluates regardless of this flag. */
270+
GITTENSORY_REVIEW_CONTINUOUS?: string;
262271
/** Convergence (reputation): when truthy, the INTERNAL-only ported submitter-reputation signal extends the
263272
* AI-spend gate — a new / burst / low-reputation submitter is downgraded to a deterministic-only review
264273
* (the AI neurons are skipped), and the per-(project, submitter) outcome is recorded after the gate

0 commit comments

Comments
 (0)