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
14 changes: 14 additions & 0 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,14 @@ review:
# null. Default: null/false — byte-identical. (#2995)
# culture_profile: false

# Repeat-false-positive suppression (#2179, part of #1964). Bool | null. Default: null/false — byte-identical
# (no suppression-store read, no matching). Also requires the operator's GITTENSORY_REVIEW_MEMORY env flag to
# be on -- this manifest field alone cannot enable it. When both are on, an advisory (non-blocking) AI finding
# is matched against this repo's stored review_suppression signals (a maintainer's own past false-positive
# dismissals) before it is surfaced, and demoted/dropped on a match. ADVISORY-ONLY: never applied to gate
# blockers -- it can never change the merge/close disposition.
# memory: false

# Display-only floor for inline AI findings (`critical` | `major` | `minor` | `nitpick`). Findings below the
# configured level are suppressed from inline comments — never from gate blockers. Default: null (show all).
# min_finding_severity: major
Expand Down Expand Up @@ -840,6 +848,12 @@ settings:
# # (recent_merged_pull_requests). Reference-only grounding, never a gate/scoring input; requires the operator
# # flag GITTENSORY_REVIEW_CULTURE_PROFILE. Bool or null. Default: null/false. (#2995)
# culture_profile: false
# # When true (AND the operator's GITTENSORY_REVIEW_MEMORY env flag is also on), an advisory (non-blocking)
# # AI finding is matched against this repo's stored review_suppression signals (a maintainer's own past
# # false-positive dismissals) before it is surfaced, and demoted/dropped on a match. ADVISORY-ONLY: never
# # applied to gate blockers -- it can never change the merge/close disposition. Bool or null.
# # Default: null/false. (#2179, part of #1964)
# memory: false
# # When true, an inline finding is ALSO tagged with a category (security/correctness/performance/
# # maintainability/tests/style) -- the AI reviewer self-categorizes, with a deterministic path/keyword
# # fallback for whatever it omits. Only takes effect when inline_comments is already on. Bool or null.
Expand Down
1 change: 1 addition & 0 deletions apps/gittensory-ui/src/routes/docs.privacy-security.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ GITTENSORY_REVIEW_GROUNDING="true" # CI status + full changed-file
GITTENSORY_REVIEW_RAG="true" # codebase vector-index context (needs index)
GITTENSORY_REVIEW_IMPACT_MAP="true" # deterministic impact map (needs review.impact_map too)
GITTENSORY_REVIEW_CULTURE_PROFILE="true" # repo quality-culture profile (needs review.culture_profile: true)
GITTENSORY_REVIEW_MEMORY="true" # repeat-false-positive suppression (needs review.memory too)
GITTENSORY_REVIEW_REPUTATION="true" # submitter-reputation spend control (never shown)
GITTENSORY_REVIEW_UNIFIED_COMMENT="true" # one in-place unified PR comment
GITTENSORY_REVIEW_ENRICHMENT="true" # external analyzer registry (REES) findings
Expand Down
8 changes: 8 additions & 0 deletions apps/gittensory-ui/src/routes/docs.tuning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@ function Tuning() {
scoring input. Also requires the per-repo <code>review.culture_profile: true</code> opt-in
in <code>.gittensory.yml</code>. Per-PR.
</li>
<li>
<code>GITTENSORY_REVIEW_MEMORY</code> — repeat-false-positive suppression: matches an
advisory (non-blocking) AI finding against this repo's stored suppression signals (a
maintainer's own past false-positive dismissals) and demotes or drops it before the
unified comment renders. Advisory-only by construction — never applied to gate blockers,
so it can never change the merge/close disposition. Also requires the per-repo{" "}
<code>review.memory: true</code> opt-in in <code>.gittensory.yml</code>. Per-PR.
</li>
<li>
<code>GITTENSORY_REVIEW_REPUTATION</code> — submitter-reputation spend control. A new,
burst, or low-reputation submitter is downgraded to a deterministic-only review; good
Expand Down
14 changes: 14 additions & 0 deletions config/examples/gittensory.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,14 @@ review:
# null. Default: null/false — byte-identical. (#2995)
# culture_profile: false

# Repeat-false-positive suppression (#2179, part of #1964). Bool | null. Default: null/false — byte-identical
# (no suppression-store read, no matching). Also requires the operator's GITTENSORY_REVIEW_MEMORY env flag to
# be on -- this manifest field alone cannot enable it. When both are on, an advisory (non-blocking) AI finding
# is matched against this repo's stored review_suppression signals (a maintainer's own past false-positive
# dismissals) before it is surfaced, and demoted/dropped on a match. ADVISORY-ONLY: never applied to gate
# blockers -- it can never change the merge/close disposition.
# memory: false

# Display-only floor for inline AI findings (`critical` | `major` | `minor` | `nitpick`). Findings below the
# configured level are suppressed from inline comments — never from gate blockers. Default: null (show all).
# min_finding_severity: major
Expand Down Expand Up @@ -853,6 +861,12 @@ settings:
# # (recent_merged_pull_requests). Reference-only grounding, never a gate/scoring input; requires the operator
# # flag GITTENSORY_REVIEW_CULTURE_PROFILE. Bool or null. Default: null/false. (#2995)
# culture_profile: false
# # When true (AND the operator's GITTENSORY_REVIEW_MEMORY env flag is also on), an advisory (non-blocking)
# # AI finding is matched against this repo's stored review_suppression signals (a maintainer's own past
# # false-positive dismissals) before it is surfaced, and demoted/dropped on a match. ADVISORY-ONLY: never
# # applied to gate blockers -- it can never change the merge/close disposition. Bool or null.
# # Default: null/false. (#2179, part of #1964)
# memory: false
# # When true, an inline finding is ALSO tagged with a category (security/correctness/performance/
# # maintainability/tests/style) -- the AI reviewer self-categorizes, with a deterministic path/keyword
# # fallback for whatever it omits. Only takes effect when inline_comments is already on. Bool or null.
Expand Down
26 changes: 26 additions & 0 deletions migrations/0114_review_suppression_memory.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- Review memory (#2178, data-model slice of #1964): a bounded, public-safe per-repo store of "the maintainer
-- already dismissed this as a false positive" suppression signals. A maintainer-authored suppression is keyed
-- by (repo_full_name, category, path_glob, pattern_hash): `category` is the finding's own deterministic `code`
-- (e.g. "ai_review_split", never a private rubric term), `path_glob` narrows the suppression to a path pattern
-- ("" = repo-wide), and `pattern_hash` is a stable hash of the finding's NORMALIZED message (never the raw
-- message itself — no free-form finding text is stored, only its hash, keeping the row public-safe). Recording
-- (writing a row when a maintainer dismisses a finding) and applying (reading rows to suppress a future
-- matching finding) are both SEPARATE slices layered on top of this store — this migration adds ONLY the table
-- + typed row + repository accessors, no recording trigger and no apply-during-review logic.
CREATE TABLE IF NOT EXISTS review_suppression (
id TEXT PRIMARY KEY,
repo_full_name TEXT NOT NULL,
category TEXT NOT NULL,
path_glob TEXT NOT NULL DEFAULT '',
pattern_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
created_by TEXT
);
-- Idempotent recording: re-dismissing the SAME finding shape is a no-op upsert (bump created_at), not a
-- duplicate row -- mirrors active_review_tracking's one-row-per-key shape (migrations/0113).
CREATE UNIQUE INDEX IF NOT EXISTS review_suppression_key_unique
ON review_suppression (repo_full_name, category, path_glob, pattern_hash);
-- Per-repo listing + the bounded-row-cap eviction (oldest-first) both scan by repo_full_name ordered by
-- created_at -- this index serves both without a table scan.
CREATE INDEX IF NOT EXISTS review_suppression_repo_created_idx
ON review_suppression (repo_full_name, created_at);
107 changes: 107 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
repositoryAiKeys,
repositoryLinearKeys,
repositorySettings,
reviewSuppression,
scorePreviews,
scoringModelSnapshots,
signalSnapshots,
Expand Down Expand Up @@ -149,6 +150,7 @@ import type {
RepoSyncStateRecord,
RepositorySettings,
RepositoryRecord,
ReviewSuppressionRecord,
ScorePreviewRecord,
ScoringModelSnapshotRecord,
SignalSnapshotRecord,
Expand Down Expand Up @@ -4727,6 +4729,111 @@ export async function terminalizeActiveReviewTracking(
return Number(result.meta.changes ?? 0) > 0;
}

// Review memory (#2178, data-model slice of #1964). Hard per-repo cap on stored suppression signals — mirrors
// rag.ts's MAX_CHUNKS_PER_REPO discipline (bound a repo-controlled, unboundedly-growable store). A repo that
// keeps dismissing NEW finding shapes evicts its OLDEST suppression first rather than growing forever.
export const MAX_REVIEW_SUPPRESSIONS_PER_REPO = 500;

function toReviewSuppressionRecord(row: typeof reviewSuppression.$inferSelect): ReviewSuppressionRecord {
return {
id: row.id,
repoFullName: row.repoFullName,
category: row.category,
pathGlob: row.pathGlob,
patternHash: row.patternHash,
createdAt: row.createdAt,
createdBy: row.createdBy,
};
}

/** Idempotently record a review-memory suppression signal: a maintainer dismissed a finding matching
* (repoFullName, category, pathGlob, patternHash) as a false positive. Re-recording the SAME key is a true
* no-op upsert (bumps createdAt/createdBy only) — mirrors startActiveReviewTracking's upsert shape — so
* repeatedly dismissing the same recurring finding never creates duplicate rows. After the write, evicts the
* OLDEST rows for this repo beyond MAX_REVIEW_SUPPRESSIONS_PER_REPO (fail-safe: eviction errors are swallowed
* — a failed prune never blocks the recording write that already succeeded). */
export async function recordReviewSuppression(
env: Env,
input: { repoFullName: string; category: string; pathGlob?: string | null | undefined; patternHash: string; createdBy?: string | null | undefined },
): Promise<ReviewSuppressionRecord> {
const repoFullName = boundedString(input.repoFullName, 200);
const category = boundedString(input.category, 200);
const pathGlob = boundedString(input.pathGlob ?? "", 500);
const patternHash = boundedString(input.patternHash, 128);
const db = getDb(env.DB);
const values = {
id: crypto.randomUUID(),
repoFullName,
category,
pathGlob,
patternHash,
createdBy: input.createdBy ?? null,
};
await db
.insert(reviewSuppression)
.values(values)
.onConflictDoUpdate({
target: [reviewSuppression.repoFullName, reviewSuppression.category, reviewSuppression.pathGlob, reviewSuppression.patternHash],
set: { createdAt: nowIso(), createdBy: values.createdBy },
});
const row = await db
.select()
.from(reviewSuppression)
.where(
and(
eq(reviewSuppression.repoFullName, repoFullName),
eq(reviewSuppression.category, category),
eq(reviewSuppression.pathGlob, pathGlob),
eq(reviewSuppression.patternHash, patternHash),
),
)
.get();
await pruneReviewSuppressionsOverCap(env, repoFullName).catch((error) => {
console.warn("Failed to prune over-cap review suppressions", { repoFullName, error: errorMessage(error) });
});
/* v8 ignore next -- the row was just inserted/updated in this same call; a missing read-back would mean D1
* itself failed silently, not a reachable application branch. */
return row ? toReviewSuppressionRecord(row) : { ...values, createdAt: nowIso() };
}

/** Evict the OLDEST review_suppression rows for repoFullName once the per-repo count exceeds
* MAX_REVIEW_SUPPRESSIONS_PER_REPO — a repo that keeps dismissing new finding shapes never grows this table
* unbounded. Internal to recordReviewSuppression; not exported. */
async function pruneReviewSuppressionsOverCap(env: Env, repoFullName: string): Promise<void> {
const db = getDb(env.DB);
// Fetch every row for the repo (bounded: never more than MAX+1, since this runs after each insert) and slice
// the overflow off in JS, rather than a SQL OFFSET -- Drizzle's D1 dialect drops a `.limit(-1)` "unbounded
// limit" hint from the emitted SQL entirely, leaving a bare `OFFSET` clause that this driver rejects outright.
const rows = await db
.select({ id: reviewSuppression.id })
.from(reviewSuppression)
.where(eq(reviewSuppression.repoFullName, repoFullName))
.orderBy(desc(reviewSuppression.createdAt));
const overflow = rows.slice(MAX_REVIEW_SUPPRESSIONS_PER_REPO);
if (overflow.length === 0) return;
await db.delete(reviewSuppression).where(
and(
eq(reviewSuppression.repoFullName, repoFullName),
inArray(
reviewSuppression.id,
overflow.map((row) => row.id),
),
),
);
}

/** List every stored suppression signal for repoFullName, newest first. Bounded by `limit` (default 500,
* matching MAX_REVIEW_SUPPRESSIONS_PER_REPO) so a caller can never accidentally request an unbounded scan. */
export async function listReviewSuppressions(env: Env, repoFullName: string, limit = MAX_REVIEW_SUPPRESSIONS_PER_REPO): Promise<ReviewSuppressionRecord[]> {
const rows = await getDb(env.DB)
.select()
.from(reviewSuppression)
.where(eq(reviewSuppression.repoFullName, boundedString(repoFullName, 200)))
.orderBy(desc(reviewSuppression.createdAt))
.limit(clampInteger(limit, 1, MAX_REVIEW_SUPPRESSIONS_PER_REPO));
return rows.map(toReviewSuppressionRecord);
}

export async function listGateOutcomes(
env: Env,
options: { repoFullName?: string; windowDays?: number; now?: string; limit?: number } = {},
Expand Down
24 changes: 24 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,30 @@ export const activeReviewTracking = sqliteTable(
}),
);

// Review memory (#2178, data-model slice of #1964): a bounded, public-safe per-repo store of "the maintainer
// already dismissed this as a false positive" suppression signals (migrations/0114). `category` is the
// finding's own deterministic `code` (never a private rubric term); `pathGlob` narrows the suppression to a
// path pattern ("" = repo-wide); `patternHash` is a stable hash of the finding's NORMALIZED message — the raw
// message itself is never stored, only its hash. One row per (repoFullName, category, pathGlob, patternHash);
// re-recording the same shape upserts (bumps createdAt) rather than duplicating. Read-side matching (#2180)
// and apply-to-findings wiring (#2181) are separate slices layered on top of this store.
export const reviewSuppression = sqliteTable(
"review_suppression",
{
id: text("id").primaryKey(),
repoFullName: text("repo_full_name").notNull(),
category: text("category").notNull(),
pathGlob: text("path_glob").notNull().default(""),
patternHash: text("pattern_hash").notNull(),
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
createdBy: text("created_by"),
},
(table) => ({
key: uniqueIndex("review_suppression_key_unique").on(table.repoFullName, table.category, table.pathGlob, table.patternHash),
repoCreated: index("review_suppression_repo_created_idx").on(table.repoFullName, table.createdAt),
}),
);

// 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
5 changes: 5 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,11 @@ declare global {
* OFF — unset/false performs NO extra D1 read and keeps the reviewer prompt byte-identical (the new branch
* is unreachable when off). ADVISORY GROUNDING ONLY: never a gate/scoring input. */
GITTENSORY_REVIEW_CULTURE_PROFILE?: string;
/** Review memory (#2179, part of #1964): operator-level kill-switch for repeat-false-positive suppression,
* ANDed with the per-repo `.gittensory.yml review.memory` opt-in (see review/review-memory-wire's
* isReviewMemoryEnabled / shouldApplyReviewMemory). Default OFF — unset/false performs NO suppression-
* store read and NO matching, byte-identical to today. ADVISORY-ONLY: never applied to gate blockers. */
GITTENSORY_REVIEW_MEMORY?: string;
/** Review-enrichment service (REES): when truthy, the self-host review engine POSTs the PR diff/files to
* REES and splices any public-safe brief into the AI reviewer prompt. Requires REES_URL and the repo in
* GITTENSORY_REVIEW_REPOS. REES_ANALYZERS is an optional exact comma-list; unset/"all"/"*" lets REES run its
Expand Down
Loading
Loading