diff --git a/.git-hooks-binary-allowlist b/.git-hooks-binary-allowlist index 3a469bedd..c33c3e43d 100644 --- a/.git-hooks-binary-allowlist +++ b/.git-hooks-binary-allowlist @@ -60,3 +60,6 @@ assets/screenshots/git-lrc/risk-scored-view-1.png assets/screenshots/git-lrc/risk-scored-view-2.png assets/screenshots/git-lrc/summary-deck.gif scripts/blast-radius-demo-export/static/assets/gemini-icon.png + +assets/screenshots/auto-approve/demo.mp4 +assets/screenshots/auto-approve/poster.webp diff --git a/Makefile b/Makefile index 5a8e657c2..a5d7a86ac 100644 --- a/Makefile +++ b/Makefile @@ -381,6 +381,10 @@ SECURITY_GOVULN_PACKAGES := $(filter-out ./scripts ./cmd/onboarding-pdf,$(TEST_P testall: $(GOTEST) -count=1 $(TEST_PACKAGES) +.PHONY: regen-risk-report +regen-risk-report: + python3 scripts/regen_risk_report.py + .PHONY: security-govulncheck security-govulncheck-json security-osv security-gitleaks security-semgrep security-dependabot security-gh-secret-scanning security-triage # Run Go vulnerability analysis for reachable vulnerabilities. diff --git a/README.md b/README.md index ac3a6e29b..4a4c751cb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,36 @@ # LiveReview: Risk-Aware AI Code Review for Business-Critical Systems -LiveReview is an AI code reviewer that scores every hunk of a diff by **blast radius**: how far a change reaches through your call graph, how much persistent state it touches, and how well-tested it is. A 3-line change to a shared auth check can outrank a 300-line UI tweak. Your team's attention goes to the highest-risk code first, not spread evenly across every diff. +LiveReview is the org-wide risk-aware harness for AI-written code. It covers two different jobs: deciding which pull requests are safe to skip entirely, and prioritizing what's risky inside the ones that still need a human. + + +## Auto-Approve: Ship the Safe 50% Without a Human + +**Auto-Approve Upto 50% of Your Pull Requests, Safely.** Risk scoring decides which MRs/PRs are safe enough to ship without a human, so your team only spends review time on risky and critical changes. + +[](./assets/screenshots/auto-approve/demo.mp4) + +
LiveReview auto-approving a low-risk change while routing a risky change to a human reviewer. Click to play.
+ +The risk score blends three signals: + +- Semantic LLM analysis +- A custom ML model trained on your production risk signals +- Static graph-based codebase analysis + +Inspired by Meta's RADAR paper: **50x** fewer production incidents, **3x** fewer rollbacks, **35%** faster reviews. + +How it works: + +1. **Track outcomes** - trace production incidents and rollbacks back to the commits and reviews behind them. +2. **Learn from the data** - combine those outcomes with LLM analysis, static checks, and ML signals into a risk score per change. +3. **Automate selectively** - auto-approve the lowest-risk 30-50% of changes. Choose a conservative, balanced, or aggressive policy. Risky or uncertain changes still go to a human. +4. **Keep improving** - track incidents, false approvals, and overrides, and tighten or loosen the policy as real data comes in. + + +## Within-Diff Prioritization: Blast Radius & Review Priority + +For everything that still goes to a human, LiveReview scores every hunk of a diff by **blast radius**: how far a change reaches through your call graph, how much persistent state it touches, and how well-tested it is. A 3-line change to a shared auth check can outrank a 300-line UI tweak. Your team's attention goes to the highest-risk code first, not spread evenly across every diff. https://github.com/user-attachments/assets/b7663ad5-e792-4d24-8452-18bbb9b958a0 @@ -51,7 +80,7 @@ LiveReview organizes around five capabilities, each answering a question your te | Pillar | Question it answers | What it covers | |---|---|---| -| [**Review**](#org-wide-harness) | Can we get both speed and quality? | Multi-level review checkpoints (commit, push, MR/PR, CI/CD, scheduled), [Adaptive Reviews](#adaptive-reviews) | +| [**Review**](#org-wide-harness) | Can we get both speed and quality? | Multi-level review checkpoints (commit, push, MR/PR, CI/CD, scheduled), [Auto-Approve](#auto-approve), [Adaptive Reviews](#adaptive-reviews) | | [**Understand**](#features) | Can an engineer quickly understand what changed and what it means? | Summary decks, Issue Navigator, PR summaries, [Cognitive Coverage / PR Quizzes](#cognitive-coverage) | | [**Enforce**](#repository-rules) | Can we make our engineering standards and review policies actually happen? | [Repository Rules](#repository-rules), [CI/CD Gates](#cicd-gates), custom prompts, scheduled reviews | | [**Improve**](#impact-report) | How are we doing, and where should engineering improve? | [Impact Report](#impact-report), [Livi / Data-Backed Decisions](#data-backed-decisions) | @@ -66,7 +95,8 @@ The result: engineering becomes a business advantage. You stay ahead, your engin | I want to... | Go to | |---|---| | Try LiveReview free in under 5 minutes | [Quick Start](#quick-start) | -| Understand Blast-Radius scoring | [above ↑](#livereview-risk-aware-ai-code-review-for-business-critical-systems) | +| Auto-approve low-risk pull requests safely | [Auto-Approve](#auto-approve) | +| Understand Blast-Radius scoring | [Within-Diff Prioritization](#blast-radius-scoring) | | Enforce checks at commit / push / PR / CI | [Org-Wide Harness](#org-wide-harness) | | Write a precise, custom rule for blocking a merge | [CI/CD Gates](#cicd-gates) | diff --git a/assets/screenshots/auto-approve/demo.mp4 b/assets/screenshots/auto-approve/demo.mp4 new file mode 100644 index 000000000..6d3f3f7da Binary files /dev/null and b/assets/screenshots/auto-approve/demo.mp4 differ diff --git a/assets/screenshots/auto-approve/poster.webp b/assets/screenshots/auto-approve/poster.webp new file mode 100644 index 000000000..70e9d1c50 Binary files /dev/null and b/assets/screenshots/auto-approve/poster.webp differ diff --git a/db/migrations/20261012090000_add_links_comment_posted_at_to_pull_requests.sql b/db/migrations/20261012090000_add_links_comment_posted_at_to_pull_requests.sql new file mode 100644 index 000000000..d9685e148 --- /dev/null +++ b/db/migrations/20261012090000_add_links_comment_posted_at_to_pull_requests.sql @@ -0,0 +1,9 @@ +-- migrate:up + +-- Set once the "Start a review / View history" links comment is posted on a new PR/MR, +-- so duplicate webhook deliveries don't post it twice. +ALTER TABLE pull_requests ADD COLUMN links_comment_posted_at TIMESTAMPTZ; + +-- migrate:down + +ALTER TABLE pull_requests DROP COLUMN IF EXISTS links_comment_posted_at; diff --git a/db/migrations/20261012100000_review_commits_repository_fk_set_null.sql b/db/migrations/20261012100000_review_commits_repository_fk_set_null.sql new file mode 100644 index 000000000..cf681934b --- /dev/null +++ b/db/migrations/20261012100000_review_commits_repository_fk_set_null.sql @@ -0,0 +1,13 @@ +-- migrate:up + +-- Deleting a connector cascades to its repositories; review_commits rows blocked that. +-- Keep the review's commit history and just clear the repository link instead. +ALTER TABLE review_commits DROP CONSTRAINT review_commits_repository_id_fkey; +ALTER TABLE review_commits ADD CONSTRAINT review_commits_repository_id_fkey + FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE SET NULL; + +-- migrate:down + +ALTER TABLE review_commits DROP CONSTRAINT review_commits_repository_id_fkey; +ALTER TABLE review_commits ADD CONSTRAINT review_commits_repository_id_fkey + FOREIGN KEY (repository_id) REFERENCES repositories(id); diff --git a/db/migrations/20261013090000_create_risk_policy_engine.sql b/db/migrations/20261013090000_create_risk_policy_engine.sql new file mode 100644 index 000000000..3561d9828 --- /dev/null +++ b/db/migrations/20261013090000_create_risk_policy_engine.sql @@ -0,0 +1,168 @@ +-- migrate:up + +-- Risk-based auto-approve policy engine. See docs/risk-policy-engine.md for the +-- full design. Replaces the placeholder "no critical findings" policy in +-- internal/jobqueue/pr_policy_worker.go with a layered funnel +-- (eligibility -> static jq gates -> trained risk score -> LLM classification -> decision). + +-- 1. The seniority dial that gates eligibility: a plain integer owners bump per +-- user (no roles/tiers table). Default 1 (lowest). +ALTER TABLE users ADD COLUMN review_level INTEGER NOT NULL DEFAULT 1; + +-- 2. Pull-request author email, captured opportunistically during PR sync so the +-- identity map (below) can match an author to a LiveReview user by email as +-- well as by provider username. Often absent on GitHub (commit author email +-- may be used); nullable. +ALTER TABLE pull_requests ADD COLUMN author_email TEXT; + +-- 3. User identities: the "alternative emails / handles" each org member can hold, +-- used to map a PR author (provider username or email) back to a LiveReview +-- user so eligibility uses their real review_level instead of the default. +-- Org-scoped; a member manages their own, owner/admin manage others'. +CREATE TABLE user_identities ( + id BIGSERIAL PRIMARY KEY, + org_id BIGINT NOT NULL REFERENCES orgs(id), + user_id BIGINT NOT NULL REFERENCES users(id), + kind TEXT NOT NULL CHECK (kind IN ('email', 'username')), + value TEXT NOT NULL, -- normalized (lowercased) email or handle + created_by BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (org_id, kind, value) +); + +CREATE INDEX idx_user_identities_org_user ON user_identities (org_id, user_id); +CREATE INDEX idx_user_identities_lookup ON user_identities (org_id, kind, value); + +-- 4. Per-org / per-repo auto-approve policy. repository_id NULL = org default. +-- 'inherit' is only valid on repo-scoped rows (explicit "use the org default"). +CREATE TABLE auto_approve_policies ( + id BIGSERIAL PRIMARY KEY, + org_id BIGINT NOT NULL REFERENCES orgs(id), + repository_id BIGINT REFERENCES repositories(id), + mode TEXT NOT NULL DEFAULT 'custom' + CHECK (mode IN ('inherit', 'custom', 'always_approve', 'never_approve')), + min_author_level INTEGER NOT NULL DEFAULT 1, + eligibility_whitelist_user_ids BIGINT[] NOT NULL DEFAULT '{}', + risk_threshold_percentile INTEGER NOT NULL DEFAULT 20, + require_llm_confidence INTEGER NOT NULL DEFAULT 8, + blocking_ruleset_ids BIGINT[] NOT NULL DEFAULT '{}', + landing_delay_seconds INTEGER NOT NULL DEFAULT 0, + daily_approve_cap INTEGER, + restrict_outcome_reporting_to_admins BOOLEAN NOT NULL DEFAULT false, + force_approve_min_level INTEGER NOT NULL DEFAULT 3, + created_by BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX idx_auto_approve_policies_org_default ON auto_approve_policies (org_id) WHERE repository_id IS NULL; +CREATE UNIQUE INDEX idx_auto_approve_policies_org_repo ON auto_approve_policies (org_id, repository_id) WHERE repository_id IS NOT NULL; +CREATE INDEX idx_auto_approve_policies_org ON auto_approve_policies (org_id); + +-- 5. Append-only policy evaluation trace: one row per stage per review, so the +-- UI can render the full funnel (eligibility -> static -> risk -> llm -> final), +-- not just the final approve/reject. +CREATE TABLE review_policy_log ( + id BIGSERIAL PRIMARY KEY, + review_id BIGINT NOT NULL REFERENCES reviews(id), + org_id BIGINT NOT NULL REFERENCES orgs(id), + stage TEXT NOT NULL, + verdict TEXT NOT NULL, + detail JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_review_policy_log_review ON review_policy_log (review_id); +CREATE INDEX idx_review_policy_log_org ON review_policy_log (org_id, created_at DESC); + +-- 6. Production-truth outcomes (revert / incident / confirmed-safe) that feed the +-- risk model's feedback loop. reason is mandatory. 'test' source is excluded +-- from training. +CREATE TABLE review_outcomes ( + id BIGSERIAL PRIMARY KEY, + review_id BIGINT NOT NULL REFERENCES reviews(id), + org_id BIGINT NOT NULL REFERENCES orgs(id), + reported_by_user_id BIGINT, + outcome_type TEXT NOT NULL CHECK (outcome_type IN ('revert', 'incident', 'confirmed_safe')), + source TEXT NOT NULL CHECK (source IN ('webhook', 'manual', 'ci', 'test')), + reason TEXT NOT NULL, + detail JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_review_outcomes_review ON review_outcomes (review_id); +CREATE INDEX idx_review_outcomes_org_created ON review_outcomes (org_id, created_at DESC); + +-- 7. Human force-approve/override of a specific review's verdict, fully audited. +CREATE TABLE review_policy_overrides ( + id BIGSERIAL PRIMARY KEY, + review_id BIGINT NOT NULL REFERENCES reviews(id), + org_id BIGINT NOT NULL REFERENCES orgs(id), + actor_user_id BIGINT NOT NULL REFERENCES users(id), + original_verdict TEXT NOT NULL, + forced_verdict TEXT NOT NULL, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_review_policy_overrides_review ON review_policy_overrides (review_id); +CREATE INDEX idx_review_policy_overrides_org ON review_policy_overrides (org_id, created_at DESC); + +-- 8. Feature snapshots written at every policy evaluation: the training set once +-- review_outcomes rows attach to the same review_ids. Recomputed-after-the-fact +-- features would drift, so they're snapshotted at evaluation time. +CREATE TABLE risk_feature_snapshots ( + id BIGSERIAL PRIMARY KEY, + review_id BIGINT NOT NULL REFERENCES reviews(id), + org_id BIGINT NOT NULL REFERENCES orgs(id), + model_version INTEGER NOT NULL, + features JSONB NOT NULL, + score DOUBLE PRECISION NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_risk_feature_snapshots_review ON risk_feature_snapshots (review_id); +CREATE INDEX idx_risk_feature_snapshots_org ON risk_feature_snapshots (org_id, created_at DESC); + +-- 9. The trained risk model. Weights are a few dozen floats, so the row IS the +-- model (no blob store). org_id NULL = the shared seed/default every org +-- starts from. Append-only: any version can be reactivated as an instant rollback. +CREATE TABLE risk_model_versions ( + id BIGSERIAL PRIMARY KEY, + org_id BIGINT REFERENCES orgs(id), + version INTEGER NOT NULL, + weights_json JSONB NOT NULL, + trained_at TIMESTAMPTZ NOT NULL DEFAULT now(), + training_sample_count INTEGER NOT NULL DEFAULT 0, + holdout_auc DOUBLE PRECISION, + update_kind TEXT NOT NULL CHECK (update_kind IN ('seed', 'online_update', 'batch_refit')), + is_active BOOLEAN NOT NULL DEFAULT false +); + +CREATE INDEX idx_risk_model_versions_org_active ON risk_model_versions (org_id) WHERE is_active; + +-- 10. Coarse, category-level admin weight overrides (blast_radius / security_findings / +-- critical_findings / diff_size): an instant multiplier on live scoring, +-- decoupled from the nightly retrain cadence. +CREATE TABLE risk_factor_overrides ( + id BIGSERIAL PRIMARY KEY, + org_id BIGINT NOT NULL REFERENCES orgs(id), + feature_category TEXT NOT NULL, + multiplier DOUBLE PRECISION NOT NULL DEFAULT 1.0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (org_id, feature_category) +); + +-- migrate:down + +DROP TABLE IF EXISTS risk_factor_overrides; +DROP TABLE IF EXISTS risk_model_versions; +DROP TABLE IF EXISTS risk_feature_snapshots; +DROP TABLE IF EXISTS review_policy_overrides; +DROP TABLE IF EXISTS review_outcomes; +DROP TABLE IF EXISTS review_policy_log; +DROP TABLE IF EXISTS auto_approve_policies; +DROP TABLE IF EXISTS user_identities; +ALTER TABLE pull_requests DROP COLUMN IF EXISTS author_email; +ALTER TABLE users DROP COLUMN IF EXISTS review_level; diff --git a/db/schema.sql b/db/schema.sql index 09d7a0e8e..30e65c765 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -519,6 +519,50 @@ CREATE SEQUENCE public.auth_tokens_id_seq ALTER SEQUENCE public.auth_tokens_id_seq OWNED BY public.auth_tokens.id; +-- +-- Name: auto_approve_policies; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.auto_approve_policies ( + id bigint NOT NULL, + org_id bigint NOT NULL, + repository_id bigint, + mode text DEFAULT 'custom'::text NOT NULL, + min_author_level integer DEFAULT 1 NOT NULL, + eligibility_whitelist_user_ids bigint[] DEFAULT '{}'::bigint[] NOT NULL, + risk_threshold_percentile integer DEFAULT 20 NOT NULL, + require_llm_confidence integer DEFAULT 8 NOT NULL, + blocking_ruleset_ids bigint[] DEFAULT '{}'::bigint[] NOT NULL, + landing_delay_seconds integer DEFAULT 0 NOT NULL, + daily_approve_cap integer, + restrict_outcome_reporting_to_admins boolean DEFAULT false NOT NULL, + force_approve_min_level integer DEFAULT 3 NOT NULL, + created_by bigint, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT auto_approve_policies_mode_check CHECK ((mode = ANY (ARRAY['inherit'::text, 'custom'::text, 'always_approve'::text, 'never_approve'::text]))) +); + + +-- +-- Name: auto_approve_policies_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.auto_approve_policies_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: auto_approve_policies_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.auto_approve_policies_id_seq OWNED BY public.auto_approve_policies.id; + + -- -- Name: billing_notification_outbox; Type: TABLE; Schema: public; Owner: - -- @@ -651,7 +695,7 @@ CREATE TABLE public.chat_charts ( vega_spec jsonb NOT NULL, raw_llm_output text NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, - context text[], + context jsonb, stats jsonb ); @@ -730,7 +774,7 @@ CREATE TABLE public.chat_files ( rows integer, data bytea NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, - context text[] + context jsonb ); @@ -1652,6 +1696,8 @@ CREATE TABLE public.pull_requests ( metadata jsonb DEFAULT '{}'::jsonb NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, + links_comment_posted_at timestamp with time zone, + author_email text, CONSTRAINT pull_requests_last_synced_source_check CHECK ((last_synced_source = ANY (ARRAY['webhook'::text, 'poll'::text, 'backfill'::text]))), CONSTRAINT pull_requests_state_check CHECK ((state = ANY (ARRAY['open'::text, 'closed'::text, 'merged'::text]))) ); @@ -2039,22 +2085,6 @@ CREATE SEQUENCE public.review_commits_id_seq ALTER SEQUENCE public.review_commits_id_seq OWNED BY public.review_commits.id; --- --- Name: review_events; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.review_events ( - id bigint NOT NULL, - review_id bigint NOT NULL, - org_id bigint NOT NULL, - ts timestamp with time zone DEFAULT now() NOT NULL, - event_type text NOT NULL, - level text, - batch_id text, - data jsonb NOT NULL -); - - -- -- Name: review_events_id_seq; Type: SEQUENCE; Schema: public; Owner: - -- @@ -2086,6 +2116,22 @@ CREATE SEQUENCE public.review_events_id_seq1 ALTER SEQUENCE public.review_events_id_seq1 OWNED BY public.review_events.id; +-- +-- Name: review_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.review_events ( + id bigint NOT NULL, + review_id bigint NOT NULL, + org_id bigint NOT NULL, + ts timestamp with time zone DEFAULT now() NOT NULL, + event_type text NOT NULL, + level text, + batch_id text, + data jsonb NOT NULL +); + + -- -- Name: review_feedback; Type: TABLE; Schema: public; Owner: - -- @@ -2130,6 +2176,113 @@ CREATE SEQUENCE public.review_feedback_id_seq ALTER SEQUENCE public.review_feedback_id_seq OWNED BY public.review_feedback.id; +-- +-- Name: review_outcomes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.review_outcomes ( + id bigint NOT NULL, + review_id bigint NOT NULL, + org_id bigint NOT NULL, + reported_by_user_id bigint, + outcome_type text NOT NULL, + source text NOT NULL, + reason text NOT NULL, + detail jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT review_outcomes_outcome_type_check CHECK ((outcome_type = ANY (ARRAY['revert'::text, 'incident'::text, 'confirmed_safe'::text]))), + CONSTRAINT review_outcomes_source_check CHECK ((source = ANY (ARRAY['webhook'::text, 'manual'::text, 'ci'::text, 'test'::text]))) +); + + +-- +-- Name: review_outcomes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.review_outcomes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: review_outcomes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.review_outcomes_id_seq OWNED BY public.review_outcomes.id; + + +-- +-- Name: review_policy_log; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.review_policy_log ( + id bigint NOT NULL, + review_id bigint NOT NULL, + org_id bigint NOT NULL, + stage text NOT NULL, + verdict text NOT NULL, + detail jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: review_policy_log_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.review_policy_log_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: review_policy_log_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.review_policy_log_id_seq OWNED BY public.review_policy_log.id; + + +-- +-- Name: review_policy_overrides; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.review_policy_overrides ( + id bigint NOT NULL, + review_id bigint NOT NULL, + org_id bigint NOT NULL, + actor_user_id bigint NOT NULL, + original_verdict text NOT NULL, + forced_verdict text NOT NULL, + reason text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: review_policy_overrides_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.review_policy_overrides_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: review_policy_overrides_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.review_policy_overrides_id_seq OWNED BY public.review_policy_overrides.id; + + -- -- Name: reviews; Type: TABLE; Schema: public; Owner: - -- @@ -2196,6 +2349,109 @@ CREATE SEQUENCE public.reviews_id_seq ALTER SEQUENCE public.reviews_id_seq OWNED BY public.reviews.id; +-- +-- Name: risk_factor_overrides; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.risk_factor_overrides ( + id bigint NOT NULL, + org_id bigint NOT NULL, + feature_category text NOT NULL, + multiplier double precision DEFAULT 1.0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: risk_factor_overrides_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.risk_factor_overrides_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: risk_factor_overrides_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.risk_factor_overrides_id_seq OWNED BY public.risk_factor_overrides.id; + + +-- +-- Name: risk_feature_snapshots; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.risk_feature_snapshots ( + id bigint NOT NULL, + review_id bigint NOT NULL, + org_id bigint NOT NULL, + model_version integer NOT NULL, + features jsonb NOT NULL, + score double precision NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: risk_feature_snapshots_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.risk_feature_snapshots_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: risk_feature_snapshots_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.risk_feature_snapshots_id_seq OWNED BY public.risk_feature_snapshots.id; + + +-- +-- Name: risk_model_versions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.risk_model_versions ( + id bigint NOT NULL, + org_id bigint, + version integer NOT NULL, + weights_json jsonb NOT NULL, + trained_at timestamp with time zone DEFAULT now() NOT NULL, + training_sample_count integer DEFAULT 0 NOT NULL, + holdout_auc double precision, + update_kind text NOT NULL, + is_active boolean DEFAULT false NOT NULL, + CONSTRAINT risk_model_versions_update_kind_check CHECK ((update_kind = ANY (ARRAY['seed'::text, 'online_update'::text, 'batch_refit'::text]))) +); + + +-- +-- Name: risk_model_versions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.risk_model_versions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: risk_model_versions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.risk_model_versions_id_seq OWNED BY public.risk_model_versions.id; + + -- -- Name: river_client; Type: TABLE; Schema: public; Owner: - -- @@ -2839,6 +3095,24 @@ CREATE SEQUENCE public.upgrade_request_events_id_seq CACHE 1; +-- +-- Name: upgrade_request_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.upgrade_request_events ( + id bigint DEFAULT nextval('public.upgrade_request_events_id_seq'::regclass) NOT NULL, + upgrade_request_id character varying(36) NOT NULL, + org_id bigint NOT NULL, + event_source character varying(64) NOT NULL, + event_type character varying(64) NOT NULL, + from_status character varying(64), + to_status character varying(64), + event_payload jsonb, + event_time timestamp with time zone DEFAULT now() NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + -- -- Name: upgrade_requests; Type: TABLE; Schema: public; Owner: - -- @@ -2898,6 +3172,42 @@ CREATE SEQUENCE public.upgrade_requests_id_seq ALTER SEQUENCE public.upgrade_requests_id_seq OWNED BY public.upgrade_requests.id; +-- +-- Name: user_identities; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_identities ( + id bigint NOT NULL, + org_id bigint NOT NULL, + user_id bigint NOT NULL, + kind text NOT NULL, + value text NOT NULL, + created_by bigint, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT user_identities_kind_check CHECK ((kind = ANY (ARRAY['email'::text, 'username'::text]))) +); + + +-- +-- Name: user_identities_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_identities_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_identities_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_identities_id_seq OWNED BY public.user_identities.id; + + -- -- Name: user_management_audit; Type: TABLE; Schema: public; Owner: - -- @@ -3003,7 +3313,8 @@ CREATE TABLE public.users ( password_reset_required boolean DEFAULT false NOT NULL, onboarding_api_key text, last_cli_used_at timestamp with time zone, - default_org_id bigint + default_org_id bigint, + review_level integer DEFAULT 1 NOT NULL ); @@ -3105,6 +3416,13 @@ ALTER TABLE ONLY public.api_keys ALTER COLUMN id SET DEFAULT nextval('public.api ALTER TABLE ONLY public.auth_tokens ALTER COLUMN id SET DEFAULT nextval('public.auth_tokens_id_seq'::regclass); +-- +-- Name: auto_approve_policies id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.auto_approve_policies ALTER COLUMN id SET DEFAULT nextval('public.auto_approve_policies_id_seq'::regclass); + + -- -- Name: billing_notification_outbox id; Type: DEFAULT; Schema: public; Owner: - -- @@ -3322,6 +3640,27 @@ ALTER TABLE ONLY public.review_events ALTER COLUMN id SET DEFAULT nextval('publi ALTER TABLE ONLY public.review_feedback ALTER COLUMN id SET DEFAULT nextval('public.review_feedback_id_seq'::regclass); +-- +-- Name: review_outcomes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.review_outcomes ALTER COLUMN id SET DEFAULT nextval('public.review_outcomes_id_seq'::regclass); + + +-- +-- Name: review_policy_log id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.review_policy_log ALTER COLUMN id SET DEFAULT nextval('public.review_policy_log_id_seq'::regclass); + + +-- +-- Name: review_policy_overrides id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.review_policy_overrides ALTER COLUMN id SET DEFAULT nextval('public.review_policy_overrides_id_seq'::regclass); + + -- -- Name: reviews id; Type: DEFAULT; Schema: public; Owner: - -- @@ -3329,6 +3668,27 @@ ALTER TABLE ONLY public.review_feedback ALTER COLUMN id SET DEFAULT nextval('pub ALTER TABLE ONLY public.reviews ALTER COLUMN id SET DEFAULT nextval('public.reviews_id_seq'::regclass); +-- +-- Name: risk_factor_overrides id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.risk_factor_overrides ALTER COLUMN id SET DEFAULT nextval('public.risk_factor_overrides_id_seq'::regclass); + + +-- +-- Name: risk_feature_snapshots id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.risk_feature_snapshots ALTER COLUMN id SET DEFAULT nextval('public.risk_feature_snapshots_id_seq'::regclass); + + +-- +-- Name: risk_model_versions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.risk_model_versions ALTER COLUMN id SET DEFAULT nextval('public.risk_model_versions_id_seq'::regclass); + + -- -- Name: roles id; Type: DEFAULT; Schema: public; Owner: - -- @@ -3399,6 +3759,13 @@ ALTER TABLE ONLY public.upgrade_replacement_cutovers ALTER COLUMN id SET DEFAULT ALTER TABLE ONLY public.upgrade_requests ALTER COLUMN id SET DEFAULT nextval('public.upgrade_requests_id_seq'::regclass); +-- +-- Name: user_identities id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_identities ALTER COLUMN id SET DEFAULT nextval('public.user_identities_id_seq'::regclass); + + -- -- Name: user_management_audit id; Type: DEFAULT; Schema: public; Owner: - -- @@ -3483,6 +3850,14 @@ ALTER TABLE ONLY public.auth_tokens ADD CONSTRAINT auth_tokens_pkey PRIMARY KEY (id); +-- +-- Name: auto_approve_policies auto_approve_policies_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.auto_approve_policies + ADD CONSTRAINT auto_approve_policies_pkey PRIMARY KEY (id); + + -- -- Name: billing_notification_outbox billing_notification_outbox_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3843,6 +4218,30 @@ ALTER TABLE ONLY public.review_feedback ADD CONSTRAINT review_feedback_pkey PRIMARY KEY (id); +-- +-- Name: review_outcomes review_outcomes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.review_outcomes + ADD CONSTRAINT review_outcomes_pkey PRIMARY KEY (id); + + +-- +-- Name: review_policy_log review_policy_log_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.review_policy_log + ADD CONSTRAINT review_policy_log_pkey PRIMARY KEY (id); + + +-- +-- Name: review_policy_overrides review_policy_overrides_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.review_policy_overrides + ADD CONSTRAINT review_policy_overrides_pkey PRIMARY KEY (id); + + -- -- Name: reviews reviews_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3851,6 +4250,38 @@ ALTER TABLE ONLY public.reviews ADD CONSTRAINT reviews_pkey PRIMARY KEY (id); +-- +-- Name: risk_factor_overrides risk_factor_overrides_org_id_feature_category_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.risk_factor_overrides + ADD CONSTRAINT risk_factor_overrides_org_id_feature_category_key UNIQUE (org_id, feature_category); + + +-- +-- Name: risk_factor_overrides risk_factor_overrides_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.risk_factor_overrides + ADD CONSTRAINT risk_factor_overrides_pkey PRIMARY KEY (id); + + +-- +-- Name: risk_feature_snapshots risk_feature_snapshots_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.risk_feature_snapshots + ADD CONSTRAINT risk_feature_snapshots_pkey PRIMARY KEY (id); + + +-- +-- Name: risk_model_versions risk_model_versions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.risk_model_versions + ADD CONSTRAINT risk_model_versions_pkey PRIMARY KEY (id); + + -- -- Name: river_client river_client_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -4051,6 +4482,14 @@ ALTER TABLE ONLY public.upgrade_replacement_cutovers ADD CONSTRAINT upgrade_replacement_cutovers_upgrade_request_id_key UNIQUE (upgrade_request_id); +-- +-- Name: upgrade_request_events upgrade_request_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.upgrade_request_events + ADD CONSTRAINT upgrade_request_events_pkey PRIMARY KEY (id); + + -- -- Name: upgrade_requests upgrade_requests_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -4147,6 +4586,22 @@ ALTER TABLE ONLY public.review_commits ADD CONSTRAINT uq_review_commits_review_ref UNIQUE (review_id, ref); +-- +-- Name: user_identities user_identities_org_id_kind_value_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_identities + ADD CONSTRAINT user_identities_org_id_kind_value_key UNIQUE (org_id, kind, value); + + +-- +-- Name: user_identities user_identities_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_identities + ADD CONSTRAINT user_identities_pkey PRIMARY KEY (id); + + -- -- Name: user_management_audit user_management_audit_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -4384,6 +4839,27 @@ CREATE INDEX idx_auth_tokens_type_user ON public.auth_tokens USING btree (token_ CREATE INDEX idx_auth_tokens_user_id ON public.auth_tokens USING btree (user_id); +-- +-- Name: idx_auto_approve_policies_org; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_auto_approve_policies_org ON public.auto_approve_policies USING btree (org_id); + + +-- +-- Name: idx_auto_approve_policies_org_default; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_auto_approve_policies_org_default ON public.auto_approve_policies USING btree (org_id) WHERE (repository_id IS NULL); + + +-- +-- Name: idx_auto_approve_policies_org_repo; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_auto_approve_policies_org_repo ON public.auto_approve_policies USING btree (org_id, repository_id) WHERE (repository_id IS NOT NULL); + + -- -- Name: idx_billing_notification_outbox_org_created; Type: INDEX; Schema: public; Owner: - -- @@ -5000,6 +5476,48 @@ CREATE INDEX idx_review_feedback_review_id ON public.review_feedback USING btree CREATE INDEX idx_review_feedback_vote_type ON public.review_feedback USING btree (vote_type); +-- +-- Name: idx_review_outcomes_org_created; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_outcomes_org_created ON public.review_outcomes USING btree (org_id, created_at DESC); + + +-- +-- Name: idx_review_outcomes_review; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_outcomes_review ON public.review_outcomes USING btree (review_id); + + +-- +-- Name: idx_review_policy_log_org; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_policy_log_org ON public.review_policy_log USING btree (org_id, created_at DESC); + + +-- +-- Name: idx_review_policy_log_review; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_policy_log_review ON public.review_policy_log USING btree (review_id); + + +-- +-- Name: idx_review_policy_overrides_org; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_policy_overrides_org ON public.review_policy_overrides USING btree (org_id, created_at DESC); + + +-- +-- Name: idx_review_policy_overrides_review; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_policy_overrides_review ON public.review_policy_overrides USING btree (review_id); + + -- -- Name: idx_reviews_connector_id; Type: INDEX; Schema: public; Owner: - -- @@ -5070,6 +5588,27 @@ CREATE INDEX idx_reviews_repository ON public.reviews USING btree (repository); CREATE INDEX idx_reviews_status ON public.reviews USING btree (status); +-- +-- Name: idx_risk_feature_snapshots_org; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_risk_feature_snapshots_org ON public.risk_feature_snapshots USING btree (org_id, created_at DESC); + + +-- +-- Name: idx_risk_feature_snapshots_review; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_risk_feature_snapshots_review ON public.risk_feature_snapshots USING btree (review_id); + + +-- +-- Name: idx_risk_model_versions_org_active; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_risk_model_versions_org_active ON public.risk_model_versions USING btree (org_id) WHERE is_active; + + -- -- Name: idx_scheduled_review_configs_due; Type: INDEX; Schema: public; Owner: - -- @@ -5252,6 +5791,20 @@ CREATE INDEX idx_upgrade_replacement_cutovers_next_retry ON public.upgrade_repla CREATE INDEX idx_upgrade_replacement_cutovers_org_status ON public.upgrade_replacement_cutovers USING btree (org_id, status, updated_at DESC); +-- +-- Name: idx_upgrade_request_events_org_time; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_upgrade_request_events_org_time ON public.upgrade_request_events USING btree (org_id, event_time DESC); + + +-- +-- Name: idx_upgrade_request_events_request_time; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_upgrade_request_events_request_time ON public.upgrade_request_events USING btree (upgrade_request_id, event_time DESC); + + -- -- Name: idx_upgrade_requests_customer_state; Type: INDEX; Schema: public; Owner: - -- @@ -5301,6 +5854,20 @@ CREATE INDEX idx_upgrade_requests_pending_apply ON public.upgrade_requests USING CREATE INDEX idx_upgrade_requests_subscription ON public.upgrade_requests USING btree (razorpay_subscription_id) WHERE (razorpay_subscription_id IS NOT NULL); +-- +-- Name: idx_user_identities_lookup; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_user_identities_lookup ON public.user_identities USING btree (org_id, kind, value); + + +-- +-- Name: idx_user_identities_org_user; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_user_identities_org_user ON public.user_identities USING btree (org_id, user_id); + + -- -- Name: idx_user_role_history_changed_by; Type: INDEX; Schema: public; Owner: - -- @@ -5636,6 +6203,22 @@ ALTER TABLE ONLY public.auth_tokens ADD CONSTRAINT auth_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; +-- +-- Name: auto_approve_policies auto_approve_policies_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.auto_approve_policies + ADD CONSTRAINT auto_approve_policies_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id); + + +-- +-- Name: auto_approve_policies auto_approve_policies_repository_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.auto_approve_policies + ADD CONSTRAINT auto_approve_policies_repository_id_fkey FOREIGN KEY (repository_id) REFERENCES public.repositories(id); + + -- -- Name: billing_notification_outbox billing_notification_outbox_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -6081,7 +6664,7 @@ ALTER TABLE ONLY public.review_commits -- ALTER TABLE ONLY public.review_commits - ADD CONSTRAINT review_commits_repository_id_fkey FOREIGN KEY (repository_id) REFERENCES public.repositories(id); + ADD CONSTRAINT review_commits_repository_id_fkey FOREIGN KEY (repository_id) REFERENCES public.repositories(id) ON DELETE SET NULL; -- @@ -6124,6 +6707,62 @@ ALTER TABLE ONLY public.review_feedback ADD CONSTRAINT review_feedback_review_id_fkey FOREIGN KEY (review_id) REFERENCES public.reviews(id) ON DELETE SET NULL; +-- +-- Name: review_outcomes review_outcomes_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.review_outcomes + ADD CONSTRAINT review_outcomes_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id); + + +-- +-- Name: review_outcomes review_outcomes_review_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.review_outcomes + ADD CONSTRAINT review_outcomes_review_id_fkey FOREIGN KEY (review_id) REFERENCES public.reviews(id); + + +-- +-- Name: review_policy_log review_policy_log_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.review_policy_log + ADD CONSTRAINT review_policy_log_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id); + + +-- +-- Name: review_policy_log review_policy_log_review_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.review_policy_log + ADD CONSTRAINT review_policy_log_review_id_fkey FOREIGN KEY (review_id) REFERENCES public.reviews(id); + + +-- +-- Name: review_policy_overrides review_policy_overrides_actor_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.review_policy_overrides + ADD CONSTRAINT review_policy_overrides_actor_user_id_fkey FOREIGN KEY (actor_user_id) REFERENCES public.users(id); + + +-- +-- Name: review_policy_overrides review_policy_overrides_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.review_policy_overrides + ADD CONSTRAINT review_policy_overrides_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id); + + +-- +-- Name: review_policy_overrides review_policy_overrides_review_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.review_policy_overrides + ADD CONSTRAINT review_policy_overrides_review_id_fkey FOREIGN KEY (review_id) REFERENCES public.reviews(id); + + -- -- Name: reviews reviews_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -6140,6 +6779,38 @@ ALTER TABLE ONLY public.reviews ADD CONSTRAINT reviews_pull_request_id_fkey FOREIGN KEY (pull_request_id) REFERENCES public.pull_requests(id) ON DELETE SET NULL; +-- +-- Name: risk_factor_overrides risk_factor_overrides_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.risk_factor_overrides + ADD CONSTRAINT risk_factor_overrides_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id); + + +-- +-- Name: risk_feature_snapshots risk_feature_snapshots_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.risk_feature_snapshots + ADD CONSTRAINT risk_feature_snapshots_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id); + + +-- +-- Name: risk_feature_snapshots risk_feature_snapshots_review_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.risk_feature_snapshots + ADD CONSTRAINT risk_feature_snapshots_review_id_fkey FOREIGN KEY (review_id) REFERENCES public.reviews(id); + + +-- +-- Name: risk_model_versions risk_model_versions_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.risk_model_versions + ADD CONSTRAINT risk_model_versions_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id); + + -- -- Name: river_client_queue river_client_queue_river_client_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -6284,6 +6955,22 @@ ALTER TABLE ONLY public.upgrade_replacement_cutovers ADD CONSTRAINT upgrade_replacement_cutovers_upgrade_request_id_fkey FOREIGN KEY (upgrade_request_id) REFERENCES public.upgrade_requests(upgrade_request_id) ON DELETE CASCADE; +-- +-- Name: upgrade_request_events upgrade_request_events_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.upgrade_request_events + ADD CONSTRAINT upgrade_request_events_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id) ON DELETE CASCADE; + + +-- +-- Name: upgrade_request_events upgrade_request_events_upgrade_request_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.upgrade_request_events + ADD CONSTRAINT upgrade_request_events_upgrade_request_id_fkey FOREIGN KEY (upgrade_request_id) REFERENCES public.upgrade_requests(upgrade_request_id) ON DELETE CASCADE; + + -- -- Name: upgrade_requests upgrade_requests_actor_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -6308,6 +6995,22 @@ ALTER TABLE ONLY public.upgrade_requests ADD CONSTRAINT upgrade_requests_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id) ON DELETE CASCADE; +-- +-- Name: user_identities user_identities_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_identities + ADD CONSTRAINT user_identities_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id); + + +-- +-- Name: user_identities user_identities_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_identities + ADD CONSTRAINT user_identities_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); + + -- -- Name: user_management_audit user_management_audit_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -6548,4 +7251,11 @@ INSERT INTO public.schema_migrations (version) VALUES ('20260824190000'), ('20260825140000'), ('20260825190000'), - ('20260905090000'); + ('20260905000000'), + ('20260905010000'), + ('20260905020000'), + ('20260905030000'), + ('20260905090000'), + ('20261012090000'), + ('20261012100000'), + ('20261013090000'); diff --git a/docs/pr-review-links-plan.md b/docs/pr-review-links-plan.md new file mode 100644 index 000000000..8e19a08ab --- /dev/null +++ b/docs/pr-review-links-plan.md @@ -0,0 +1,147 @@ +# PR Review Links — Implementation Plan + +## Goal + +When a new PR/MR is opened, LiveReview adds a section with two links to the end of its description: + +1. **Start a review** — opens LiveReview, triggers a review for that PR, and redirects to the + review details page. +2. **View review history** — opens the Reviews list filtered to that PR's reviews. + +Policy-based auto-approval now has a **static stand-in**: reviews started from the link +are checked against "no critical findings" by the `pr_policy_check` job +(`internal/jobqueue/pr_policy_worker.go`), which posts a policy comment and approves the +PR, or requests changes (GitHub) / comments only (GitLab). Org-defined policies (likely +built on CI/CD Gates jq rulesets) remain a later feature. + +## Decisions + +| Topic | Decision | +|---|---| +| Providers | GitHub, GitLab, Bitbucket, Gitea, Azure DevOps | +| Which repos | Every repo with the LiveReview webhook installed | +| Which PRs | Only newly opened PRs (GitHub action `opened`, GitLab action `open`). Existing open PRs, reopen, push, and edit events are ignored. | +| Where links go | A section at the end of the PR description, between the markdown-comment markers `[//]: # (livereview:links)` and `[//]: # (livereview:end)` (HTML comments show as text on Bitbucket; the old `` markers are still recognised). Added at PR open; rewritten in place after each link-started review with an "Auto-approval decisions" table (one row per review, latest first: Review, Decision, Reason). The stage-by-stage policy breakdown is a separate PR comment. Replaced the earlier bot comment. | +| Duplicate webhooks | Guarded by a "links added" flag (`links_comment_posted_at`) on the `pull_requests` row | +| Auth | Both links require a LiveReview login. Logged-out users log in and are sent back to the link (existing `redirectAfterLogin` flow). | +| Org | The user is switched to the PR's org automatically. Non-members get "not found". | +| Trigger behavior | Opening the trigger link starts the review immediately (no confirm page, for now) | +| Repeat clicks | Each click starts a new review, **unless** a review for the PR is `created`/`in_progress` and started less than 10 minutes ago. Then it redirects to that review. | +| Existing triggers | Unchanged (reviewer assignment, @mentions) | +| History view | The existing Reviews list (`/reviews`) filtered by `pull_request_id` | + +## Link format + +The UI uses hash routing, so links look like: + +``` ++ This page explains the auto-approve risk model. It shows the setup, the tests, the results, and the + threshold settings. Every number on this page comes from a real, repeatable test. You can re-run each test + yourself with the commands in the Setup tab. +
+ +| What is the risk threshold today? | P20. The model approves only the safest 20% of an org's own recent reviews, and blocks the other 80%. A bigger number lets more through — see the row below and the Thresholds tab. |
| Is that threshold a fixed number? | No. P20 is a setting an admin picks. The score that counts as "the safest 20%" is recalculated from the org's own recent reviews. Two orgs with the same P20 setting can have different cutoff scores. See the Thresholds tab. |
| What does a score of 0.42 mean? | Scores run from 0 (looks safe) to 1 (looks risky). 0.42 is below the middle. A brand-new org with no history uses 0.5 as a placeholder cutoff. An org with real history almost never uses 0.5 — it uses its own calculated cutoff instead. See Quick Facts #4 below. |
| Does the model learn from mistakes? | Yes. When a human reports that a merged PR caused a revert or an incident, the model takes one learning step the same day. A nightly job also re-fits the model from scratch and checks it did not get worse. See the Tests and Explain tabs. |
| Where do I change the settings? | Settings → Auto Approve Policy, in the LiveReview app. Not on this page — this page only reports what the code does. |
This needs its own fact because it caused confusion. Here is the full explanation in order.
+A score has no fixed meaning by itself. 0.42 only means something next to a cutoff.
A brand-new org, with zero scored reviews, has no history to calculate a cutoff from. The code falls back to a placeholder cutoff of 0.5.
0.42 is below 0.5. So even on day one, a docs-only edit is approved. It is close to the placeholder line, but it is on the approved side.
Once an org has real review history, the placeholder 0.5 is never used again. The cutoff becomes "the score at the 20th percentile of my own recent reviews." The Thresholds tab shows this cutoff is usually different from 0.5, and is often much higher for a typical org.
What this means for you: 0.42 is not a sign that the model thinks a docs-only edit is risky. It is a sign that the placeholder cutoff (0.5) sits close to where ordinary, low-risk changes land. This is a known, reasonable property of the cold-start fallback, not a bug — but it is also why the placeholder cutoff should never be shown without this explanation next to it. This report no longer does that.
+Each test case is one block of plain data. It is not a database row. It is not a Go struct that only a
+ programmer can read. It is a JSON file:
+ internal/policyengine/testdata/scenarios.json.
{
+ "name": "readme-only-edit",
+ "rationale": "Docs-only change, no code risk at all.",
+ "blast_hunks": [],
+ "findings": [],
+ "llm_class": { "class": "docs-only", "confidence": 9 },
+ "diff": { "file_count": 1, "lines_added": 8 },
+ "author_level": 2,
+ "want": { "approve": true }
+}
+ Anyone can read this file and understand the test case without running any code.
+The test code reads this file. For each case, it writes a real review row into a real test database, with
+ the blast radius, the findings, and the LLM class from the JSON. Then it calls the real
+ decision function, engine.RunWithPolicy. This is the same function the
+ LiveReview app calls on a real pull request. The test does not re-implement the decision logic. It runs the
+ real logic and checks the real answer against the want field in the JSON.
Every row a test writes is deleted right after the test finishes. This happens even if the test fails or + crashes. We checked this by running the whole test suite twice in a row and counting rows before and + after. The count was zero both times, for every table.
+| Table | Rows left over |
|---|---|
| orgs, users, reviews | 0 |
| blast_radius_hunks | 0 |
| risk_feature_snapshots | 0 |
| review_outcomes | 0 |
| review_policy_log | 0 |
| risk_model_versions | 0 |
| ci_rulesets | 0 |
Because of this, you can run these tests again and again, on any database, including a live one, and + never need to clean up by hand.
+Point DATABASE_URL at a test database, then run:
# the 46 test cases, with full detail printed +go test ./internal/policyengine/... -run TestScenarios -v + +# the math and learning tests, no database needed for most +go test ./internal/riskscore/... -v + +# the threshold experiment (answers "what does P20 do") +go test ./internal/riskscore/... -run TestThresholdExperiment -v + +# the learning-loop test, with before/after weights printed +go test ./internal/riskscore/... -run TestLearningCycle -v + +# run everything twice, to check the database stays clean +go test ./internal/riskscore/... ./internal/policyengine/... -count=1+
Every number on this page — every score, every weight, every approval rate — is embedded in the page as + data, not typed in by hand. If you change the model's weights, the scenario corpus, or the reference mix + the Thresholds tab compares against, this page will go stale unless you regenerate it. There is one command + for that:
+make regen-risk-report +# or directly: +python3 scripts/regen_risk_report.py+
This script runs the same three tests listed above, reads the structured data they print (lines starting
+ with SCDATA:, TDDATA:,
+ TDSAME:, LCDATA:), and replaces this page's embedded
+ data with exactly what the tests just computed. It refuses to run if any of the three tests fail, so this
+ page can never embed a number from a broken test run. Run it, then check the diff — a page that changed
+ more than you expected is itself useful information.
Each group checks a different part of the system. Click a bucket chip below to see its test cases in the + Results tab.
+ +internal/riskscore/model.go,
+ these tests are your first line of defense. A failure here means "the formula itself is wrong," separate
+ from "this one test case is wrong."Every review gets one number from 0 to 1, called the score. 0 means "looks safe." 1 means + "looks risky." The score by itself tells you nothing — you always need to compare it to a + cutoff. If the score is at or above the cutoff, the review is blocked. If it is below the + cutoff, it is approved. The Percentile column below answers "where does this score sit, + compared to a realistic set of other reviews" — see the Thresholds tab for exactly what that reference set is.
+score = sigmoid( bias + sum of ( weight × category_multiplier × value ) for every signal )+
There is no other step. No large language model makes this call — it is one weighted sum, squeezed into the
+ 0-to-1 range by one standard function (sigmoid). Every signal's allowed range is
+ listed in the Signal Reference table below, so you can judge for yourself what a normal value looks like.
This is every signal the model has, nothing collapsed or hidden — 19 in total. The
+ "Starting weight" and "Starting category dial" columns are the hand-set defaults from
+ internal/riskscore/model.go's Prior() /
+ DefaultCategoryMultiplier. The category dial is the same number the Settings
+ UI's weight sliders show before an admin ever touches one — change it there, and this signal's effective
+ weight (weight × dial) changes immediately, for every future score.
| Signal | What it measures | Allowed range | Starting weight | Starting category dial |
|---|---|---|---|---|
| br_sum | Total blast-radius score, added up across every changed section ("hunk") in the diff | 0 and up. Each hunk's own score is 0–100 by convention, but a diff with many hunks can sum past 100. | 0.020 | ×2.00 (blast radius) |
| br_max | The single highest-scoring hunk in the diff | 0–100 | 0.010 | ×2.00 (blast radius) |
| br_weighted_avg | Hunk scores averaged together, weighted by how many lines each hunk touched | 0–100 | 0.010 | ×2.00 (blast radius) |
| br_high_count | Number of hunks that scored 66 or higher (the "high" tier) | 0 and up, a whole number | 0.200 | ×2.00 (blast radius) |
| critical_count | Number of critical-severity findings, of any category | 0 to 500 (the findings list has a hard limit of 500 per review) | 0.900 | ×1.00 (no dial — severity-based, not category-based) |
| warning_count | Number of warning-severity findings, of any category | 0 to 500 | 0.180 | ×1.00 (no dial) |
| files_changed | Number of distinct files touched | 0 and up, no hard limit — see the compression note below | 0.100 | ×1.00 (diff size — no dial in the UI today) |
| lines_added | Lines added across the whole diff | 0 and up, no hard limit — see the compression note below | 0.050 | ×1.00 (diff size) |
| llm_class_risk | The AI's reading of the diff, turned into a number | Exactly one of 0 (a safe class), 0.5 (a config/dependency/data-migration change), or 1 (a logic change or a security-sensitive change) — never anything in between | 0.300 | ×1.00 (no dial) |
Yes — every category the Settings UI shows a weight slider for (performance, developer experience, and + the rest) has its own dedicated signal here, its own starting weight, and its own starting dial. None of + them are special-cased out of learning: a batch re-build or a real-time nudge updates every weight in this + list together, in the same pass, using whichever of these counts were actually nonzero in the training + reports it saw (see "Why did `critical_count`'s weight drop so much?" below for what happens to a category + a training batch never touches).
+| Signal | Taxonomy category | Allowed range | Starting weight | Starting category dial |
|---|---|---|---|---|
| security_count | Security | 0 to 500 | 0.550 | ×1.50 |
| reliability_count | Reliability | 0 to 500 | 0.300 | ×1.25 |
| correctness_count | Correctness | 0 to 500 | 0.300 | ×1.25 |
| performance_count | Performance | 0 to 500 | 0.200 | ×1.10 |
| scalability_count | Scalability | 0 to 500 | 0.120 | ×1.00 |
| compliance_governance_count | Compliance & Governance | 0 to 500 | 0.120 | ×1.00 |
| maintainability_count | Maintainability | 0 to 500 | 0.100 | ×1.00 |
| architecture_count | Architecture | 0 to 500 | 0.100 | ×1.00 |
| developer_experience_count | Developer Experience | 0 to 500 | 0.100 | ×1.00 |
| cost_count | Cost | 0 to 500 | 0.100 | ×1.00 |
That's 10 categories (security plus these 9), matching the Settings UI's Risk Model sub-tab exactly — the
+ same list the auto-approve settings page stopped hiding under a collapsed "Other" section. A category
+ dial of ×1.00 is not "this category is ignored" — it means "not weighted
+ up or down relative to the others yet," and an admin can move any of these 10 dials independently at any
+ time; the change takes effect on the very next score, with no retrain needed.
The compression note: files_changed and
+ lines_added are passed through a squeezing step (log1p)
+ before they reach the formula above. This is on purpose: without it, a diff with 10× more files would
+ add exactly 10× more to the score, and a huge but otherwise harmless diff (like a generated file or a
+ big rename) would dominate the result. After squeezing, going from 1 file to 10 files adds much less than
+ going from 10 files to 100 — the signal still counts, but it cannot run away unbounded.
Click any column title to sort by it. Type in a box under a column title to filter by it. Click a row to + open it and see the step-by-step arithmetic that produced its score.
+| Status | +Scenario + + | +Group + + | +Decision + + | +Blocked at | +Score | +Percentile | +
|---|
The threshold is a percentile, written as P10, P20,
+ and so on. P20 means: approve only the safest 20% of this org's recent reviews,
+ and block the other 80%. A lower number is stricter (it approves less, blocks more). A higher number is
+ more relaxed (it approves more, blocks less). Today's default is P20.
We built a test with 60 made-up reviews, shaped like a realistic repository's recent history: mostly small, + clean changes, a smaller group of medium-risk changes, and a few clearly risky ones. We then checked what + each percentile setting actually does to that mix.
+We included the two extreme edges, P0 and P100,
+ on purpose — they are the settings that should be easiest to reason about, so they double as a check that
+ the dial behaves the way anyone would expect.
| Setting | Plain meaning | Cutoff score | % of reviews approved |
|---|
The two edges, explained precisely:
+P0 approves … of
+ the … reviews, including the safest one in the set (a docs-only edit).
+ The cutoff at P0 is the single lowest score in the whole mix, and nothing can score strictly below the
+ lowest score in its own set — so at the strictest possible setting, everything is blocked, with no
+ exceptions. This is proven by a passing test, not just described:
+ go test ./internal/riskscore/... -run TestThresholdExperiment -v.P100 approves … of
+ … reviews — every one except the single riskiest review in
+ the set. The cutoff at P100 is the single highest score in the mix, and that one review can never score
+ strictly below its own value, so it alone stays blocked even at the most relaxed possible setting. "P100
+ allows everything" is almost true, but not quite: one review is still blocked, by definition, no matter
+ how relaxed the setting is.Not just a handful of hand-picked examples — the full dataset. Click a column to sort it. A dash + (—) means the risk score never ran for that case (something earlier, like + the author's level, already decided the outcome), so there is no score to compare against a cutoff.
+Read the "one critical security finding" row carefully: at P80,
+ it flips to approved, in this test mix. This is not a bug. It is a real warning about what "very
+ relaxed" means: at a high enough percentile, the cutoff score itself can rise above a genuinely risky
+ review, if the rest of the reference mix was also fairly risky. This is the actual, concrete reason
+ the default is P20 and not something looser.
We ran the exact same setting, P20, against two different made-up histories:
+ a "quiet" org whose recent reviews mostly scored low, and a "busy" org whose recent reviews mostly scored
+ higher.
| Org | Recent review scores (made up, for this test) | Setting | Resulting cutoff |
|---|---|---|---|
| Quiet org | mostly 0.20–0.38 | P20 | … |
| Busy org | mostly 0.50–0.86 | P20 | … |
Same setting. Two different cutoff numbers. The setting (the percentile, like P20) stays fixed + until an admin changes it. The number it turns into (the cutoff score) is recalculated from the org's own + recent history, automatically, with no admin action needed. These are two different things, and + this is the whole answer to "is the threshold static."
+| Check | What it looks at | Where to change it |
|---|---|---|
| Author level | Is the PR author senior enough, or on a trusted list | Eligibility tab |
| Written rules | Rules your team wrote, such as "block if a secret is found" | Static Rules tab |
| Risk score | A number from 0 to 1, compared to the threshold | Risk Model tab (weights), General tab (threshold) |
| AI reading | Does the review AI's own classification of the diff agree it is safe | General tab |
z = bias + sum over every signal of (weight × category_multiplier × value) +score = sigmoid(z) // squeezes z into the range 0 to 1+
z. The row with the biggest number is the reason for the
+ score.internal/riskscore/model.go
+ (Score, Prior), internal/riskscore/features.go
+ (the full signal list, called FeatureSpecs).These numbers come from the PR itself. A developer cannot set these directly — they are measured from + the diff. Pick a preset, or move a slider by hand.
+These numbers are not part of the PR. They are settings an org admin controls in + Settings → Auto Approve Policy → Risk Model. They multiply how much a category of signal + counts. The starting values, before any admin changes them, are shown here.
+ +| Step | What happened | Score of one held-out, medium-blast-radius review |
|---|---|---|
| 0 | Starting point. No outcome reports yet. | … |
| 1 | After … made-up outcome reports, all following the blast-radius pattern above. | … |
| 2 | After one more outcome report arrives live, with no model re-build. | … |
Why the score moved: the actual weight changes, not just the score. We printed every + weight, before and after. The ones that moved the most are exactly the ones that measure blast radius, + because blast radius was the one pattern in the training reports:
+| Weight | Before | After | Change |
|---|
Why did critical_count's weight drop so much? Because none
+ of the 40 outcome reports in this test mentioned a critical finding — every single one of them only
+ varied blast radius. A fresh model re-build only learns from the patterns actually present in its
+ training reports. With no evidence either way about critical findings, the re-build pulls that weight
+ most of the way back toward a low, cautious value, instead of keeping the full hand-set starting weight.
+ In a real org, outcome reports would come from many different kinds of problems, not just
+ one — this specific, large drop is a property of this test's narrow, single-pattern made-up
+ data, not something to expect in production. It is a real and useful thing to know if you are ever
+ reading a weight-change log and wondering why an untouched category moved.
Total movement for the held-out review: … → + …, caused only by outcome reports, with no admin touching a + weight by hand. That is the direct proof that reporting an incident in the real app is not a + dead button.
+ +storage/reviews/taxonomy_report_store.go, buildWhereClause.A Risk-Based Auto-Approve Policy for Code Review
+LiveReview can now auto-approve a pull request it has measured to be low-risk. This is not a shortcut. The + robot runs a real check — several checks, in fact — before it decides anything. A human still + reviews every change that the robot's checks flag as uncertain or risky.
+The design is not a guess. It follows Meta's published RADAR system, a layered approach to exactly this + problem at much larger scale. LiveReview already shared three of RADAR's five architectural pieces before + this project started; we built the remaining two.
+The result has been tested, not just built. Forty-six hand-written scenarios exercise every decision path + the system can take. A live dashboard now shows, for each customer org, how many pull requests were + approved, how many were blocked, how often a human overrode the robot, and how often an approval turned out + to be wrong. Reviewer attention shifts from "every pull request" to "the pull requests that actually carry + risk" — and we can show our work.
+LiveReview Ships Risk-Based Auto-Approval, Returning Reviewer Hours Without Lowering the Bar on + Risky Changes
+ +A team merging a hundred pull requests a week gives each one the same scrutiny. A one-line fix to a README + gets read as carefully as a change to a billing calculation. That isn't rigor. It's an even coat of + attention spread across a distribution that is anything but even — a small number of changes carry + almost all of the actual risk, and the rest carry almost none.
+Reviewer attention is the scarcest resource in this loop. It does not scale the way compute does. Ask a + person to carefully read a hundred boring diffs a week, and eventually they stop reading carefully. That is + not laziness. It is what happens to any human asked to apply full attention to a task that rarely rewards + it.
+Today, most teams have no lever for this problem beyond "review everything" or "review nothing." There is + no middle setting that says: trust the obviously safe changes, and keep full scrutiny on everything else.
+ +LiveReview's auto-approve policy is that middle setting. A pull request now passes through a layered funnel + of checks — eligibility, static rules, a trained risk score, and an independent LLM classification + — before the system decides whether a human needs to look at it before merge, or whether the robot's + own checks are enough for now.
+Every check still runs. Nothing about auto-approve means less checking — it means a different + checker. Each stage writes down its own verdict, so "why was this approved" is always an answerable + question, never a shrug. Any single stage can block the change outright, no matter what the other stages + concluded.
+The threshold for "safe enough" is a dial, calibrated against each customer's own recent history, not one
+ fixed number forced on every team regardless of their risk profile. Turning the feature on costs nothing
+ permanent: an org can set always_approve or never_approve
+ on a specific repository, and a human can force an override on any single decision, in either direction, at
+ any time.
An org owner sets a starting percentile under Settings → Auto Approve Policy, watches the new Reports + → Auto Approve dashboard for a week, and adjusts the dial using their own measured override rate and + false-approve rate — not a guess, and not somebody else's benchmark.
+Pull request volume per engineer has grown faster than the careful, structured review process it gets + pushed through. That process was built for a slower rate of change, and it has not been redesigned to match + the new one — it has just been asked to go faster, which quietly means going shallower.
+The risk in a typical week's changes is not evenly spread. A small fraction of pull requests carry nearly + all of the blast radius — the reach of a change through the rest of the codebase — while the + majority carry almost none. Equal scrutiny on every change means most reviewer time is spent on changes that + were never going to fail.
+Every customer running LiveReview today is already paying this cost in reviewer hours, whether or not + anyone has measured it. Until now, there was no way to measure it, which made it easy to ignore and + impossible to improve on purpose.
+This is a mechanism change, not a headcount change. The lever here is how review attention gets allocated, + not how many reviewers exist on a team. It pays off immediately, without a single new hire.
+We did not invent this pattern from scratch. The design follows Meta's published RADAR system (arxiv + 2605.30208): a layered funnel — eligibility, deterministic gates, a trained risk score, LLM semantic + classification, and a final decision — rather than one opaque model making the entire call alone.
+ +Three lessons from that published system were carried over deliberately, because they came from a system + already proven at a scale ours has not reached yet. First, a risk threshold must be a per-org dial, never + one fixed cutoff applied everywhere — RADAR's own experiment comparing a strict and a lenient setting + showed exactly why a single global number does not hold up across different teams. Second, safety outcomes + — a revert, an incident, a confirmed-safe merge — must feed back into the model's calibration, or + the system never learns from the very thing it exists to prevent. Third, the system must stay legible. Every + decision shows its reasoning, stage by stage, never only a verdict with no explanation attached.
+We also did not start from a blank page. Three of RADAR's five architectural ingredients already existed + inside LiveReview in a different form before this project began: static rule gates (reused from the existing + CI/CD gate system), per-finding severity and confidence scores (already produced by LiveReview's review + engine), and blast-radius scoring (already computed per code change). The two genuinely new pieces were + eligibility — deciding who can be considered for auto-approval at all — and the trained risk + score itself.
+What this buys the customer is simple: the architectural risk of "will this kind of system work at all" has + already been retired by someone else's production deployment. What's actually new, and what actually needed + building, is the integration into LiveReview's own review pipeline and its own ten-category finding + taxonomy.
+Eligibility and static rules run first, and they are built to be simple on purpose. A rule that can be + explained in one sentence is a rule that can be audited in one sentence. Either stage can block a change + outright, no matter what a trained model further down the funnel would have concluded.
+The risk score itself is a logistic regression — one of the oldest, most explainable models available + — scoring blast radius and ten categories of finding severity into a single number between zero and + one. A simpler, explainable model was chosen deliberately over a more powerful but opaque one, because this + number decides whether a human ever looks at the code. In plain terms, the model works like this:
+z = bias + sum over every signal of (its value x its weight x its category importance) +score = squash(z) into a number between 0 and 1, where higher means riskier+
The LLM classification check runs beside the risk score, not behind it. Either one objecting blocks the + change on its own, and the system records exactly which one objected and why — a disqualifying + classification can override a safe-looking score, and a borderline score can be cleared by a confident, safe + classification.
+The threshold itself is relative, not absolute. The cutoff is a percentile measured against the customer's + own recent score history, so the same percentile setting resolves to a different number for a quiet + repository than for one in the middle of an incident spree — on purpose, because "safe enough" means + something different in those two situations.
+Finally, the model watches its own track record. Every outcome reported from production — a revert, + an incident, a confirmed-safe merge — nudges the model immediately by a small, careful amount. A + slower, periodic batch refit retrains the whole model from its full history, and that retrained model is + only promoted into production if it measurably performs at least as well as the one already live.
+ +A claim this specific deserves more than a demo. Forty-six hand-written scenarios run through the actual + production decision path — every stage of the funnel, all ten finding categories, every mode + shortcut, every threshold boundary — and none of them are reimplemented separately from the real code. + If the real engine changes, these scenarios run against the change directly.
+ +| What was measured | Result |
|---|---|
| Scenario coverage | 46 cases, every funnel stage and finding category |
| Strictest threshold setting (P0) | Approves 0 of a realistic 60-review mix |
| Most lenient setting (P100) | Approves every review except the single riskiest one |
| Model after cold start | Scored a risky held-out review at 0.668 |
| Same review, after a batch refit on real outcomes | 0.975 (held-out AUC: 1.000) |
| Same review, after one more reported outcome | 0.999 |
| A genuinely safer held-out review, same sequence | Moved the opposite direction (0.416 to 0.371) |
The threshold dial is measured, not asserted. A real experiment computes exactly + what each percentile setting resolves to on a realistic mix of reviews, including both extremes stated + precisely: the strictest possible setting approves nothing in the mix, and the most lenient possible setting + approves everything except the single riskiest item — because nothing in a sorted list of scores can + fall below its own maximum.
+The learning loop is proven with real numbers, not described in the abstract. A held-out risky review's + score moved from 0.668 to 0.975 to 0.999 as real outcomes were applied to the model in sequence, while a + genuinely safer held-out review moved in the opposite direction over the same run — evidence of + discrimination between the two, not simple upward drift.
+A real limitation is named here on purpose, instead of left out. One feature's learned weight can + under-update when the training data never varies it at all — found, measured, and written down rather + than smoothed over, because an evidence section that only shows wins is not evidence.
+Production trust does not stop at a test suite. A live Reports → Auto Approve dashboard shows an org's + own score distribution plotted against its real cutoff, its real approve-and-block counts over time, its + real override rate, and a real false-approve rate computed from outcomes reported on actual production + traffic.
+Translate the mechanism into the customer's own units, and the trade becomes concrete. At a representative + 500 pull requests a month, a 60% auto-approval rate, and a measured 1% false-approve rate, the arithmetic + looks like this:
+reviewer-hours returned = 300 auto-approved PRs x 15 min saved / 60 = 75 hours/month +false approvals = 300 auto-approved PRs x 1% = 3 reviews/month +expected incident cost = 3 x (illustrative $2,000 cleanup cost) = $6,000/month+
Whether that trade is worth it depends entirely on what a team's own reviewer-hours and its own incident + costs are worth — there is no universal answer — but the customer can now run this exact + arithmetic with their own real numbers instead of guessing.
+The percentile dial is the customer's lever, and for the first time it is visible. A more conservative + setting costs more reviewer time and buys a lower false-approve rate. A more lenient setting trades the + other way. Neither choice is hidden behind a black box; both are measured, per organization, continuously.
+An override rate that climbs over time is an early warning the customer gets for free. It surfaces policy + drift before an incident would have told the same story — often days or weeks earlier, while there is + still time to adjust the dial rather than clean up afterward.
+Nothing about this is a one-way door. always_approve and
+ never_approve exist per repository for teams who already know their own risk
+ profile. The static-rule check can never be silenced by a mode override — "we trust this repository"
+ is never allowed to quietly become "ignore a leaked credential." And any single decision, in either
+ direction, can be overridden by a human at any time, with that override logged and counted.
A brand-new customer org has no score history to calibrate a percentile against on day one. The system + falls back to an explicit, conservative fixed cutoff in that case, rather than silently assuming everything + is safe until enough real history accumulates.
+Thin training data can under-protect a feature, as the documented weight-collapse case shows. The + batch-refit promotion gate — never promote a model that performs worse than the one already live + — is the deliberate safeguard against that kind of drift reaching production unnoticed.
+This system is one layer in a funnel, not the last line of defense. The static-rule check and the + independent LLM classification exist specifically so that no single signal is ever the only thing standing + between a risky change and production.
+One capability was scoped and deliberately set aside for a later phase: a live, observe-only mode that + would let a new customer watch the policy's decisions on real incoming traffic before trusting it to act. It + is a known next step, named here rather than hidden, and the three measurement views described above were + prioritized first because they work against decisions the system is already making today.
+No. It means the + review that happens is concentrated on changes that carry actual risk, instead of spread evenly across + everything regardless of risk.
The policy + falls back to an explicit, conservative, fixed cutoff until enough real reviews accumulate for a percentile + to be calculated against the org's own data.
Yes. Mode and threshold are both configurable per repository, and a repository without its own + setting simply inherits the organization's default.
Three independent checks must all agree that nothing is wrong — eligibility, static + rules, and the risk-score-plus-LLM pair — and any one of them can block the change by itself.
It deliberately includes adversarial cases built to fail a naive approach — for example, a + high-blast-radius change with zero flagged findings, the exact pattern a severity-only check would miss + entirely.
This is now a measured, per-org number on the Reports → Auto Approve dashboard, not an + estimate — and it is the number that should gate any decision to widen the rollout.
Explainability is a requirement here, not a nice-to-have, because the model's output decides + whether a human ever sees the code at all. RADAR made the same choice, for the same reason.
Treat it as the earliest available signal that a threshold or a model weight has drifted out of + step with that org's current reality, and investigate before an incident does the same job the hard + way.
+ Approves the safest {value} out of 100 PRs. +
+ {custom && ( +
+
+ Blocking rulesets always run (even under "always approve"). Manage them under{' '}
+ CI Rulesets.
+
+
No rulesets defined yet.
} + {rulesets.map((r) => { + const on = (draft.blocking_ruleset_ids || []).includes(r.id); + return ( + + ); + })} +id.atlassian.comread:user, read:workspace, read:repository, read:pullrequest, write:pullrequest, read:webhook, write:webhook, delete:webhook
+ Override this repository's risk-based approval policy.
+
+
+ The trained risk model (weights, category overrides, retraining) always stays the one org-wide classifier —
+ only the threshold compared against its score, and everything below, can differ per repository.
+
+
+ Match this user's Git provider handles/emails to their account for auto-approve eligibility. +
+ + {loading ? ( +No identities yet.
} + {identities.map((i) => ( +| Feature | +Value | +
|---|---|
| {f.feature} | +{typeof f.value === 'number' ? f.value.toFixed(3) : String(f.value)} | +
No policy evaluation for this review.
++ Requires review level ≥ {log.force_approve_min_level} or owner/admin. Recorded in the audit log. +
+read:user, read:workspace, read:repository, read:pullrequest, write:pullrequest, read:webhook, write:webhook, delete:webhook
+ No reviews scored in this window yet.
; + } + + const cutoffBucketIndex = dist.buckets.findIndex((b) => dist.cutoff >= b.range_start && dist.cutoff < b.range_end); + const option = { + ...ECHARTS_ANIMATION_DEFAULTS, + grid: { left: 0, right: 12, top: 8, bottom: 24, containLabel: true }, + tooltip: { trigger: 'axis' }, + xAxis: { + type: 'category', + data: dist.buckets.map((b) => b.range_start.toFixed(2)), + axisLabel: { interval: 1, fontSize: 10 }, + }, + yAxis: { type: 'value' }, + series: [ + { + name: 'Reviews scored', + type: 'bar', + data: dist.buckets.map((b, i) => ({ + value: b.count, + itemStyle: i === cutoffBucketIndex ? { color: '#f59e0b' } : { color: '#3b82f6' }, + })), + }, + ], + }; + + return ( ++ Current cutoff for {thresholdLabel(dist.percentile)}: {dist.cutoff.toFixed(3)} + {' '}(highlighted bar). {dist.total_scored} reviews scored in this window. +
+No decisions in this window yet.
+ )} ++ Of approved reviews with a reported outcome, {(cal.false_approve_rate * 100).toFixed(1)}% were later reported as an incident or revert. +
+ )} +| Policy decision | + {outcomeTypes.map((t) => ( +{t.replace('_', ' ')} | + ))} +
|---|---|
| {decision} | + {outcomeTypes.map((t) => ( +{countFor(decision, t)} | + ))} +
No outcomes reported in this window yet.
+ )} ++ How the risk-based auto-approve policy is deciding on your org's real pull requests. +
+Starting review…
+Risk-based approval policy (docs/risk-policy-engine.md).
+
+ Category weight overrides
+
Simulation (last {simRows.length} completed reviews):
+ {simRows.map((r) => ( +
+ POST {window.location.origin}/api/v1/orgs/{orgId}/review-outcomes
+
+ Request body:
+
+{`{
+ "review_id": 12345,
+ "outcome_type": "revert" | "incident" | "confirmed_safe",
+ "reason": "why this was reported",
+ "source": "webhook" | "ci" // optional
+}`}
+
+