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. + +[![Watch the Auto-Approve demo](./assets/screenshots/auto-approve/poster.webp)](./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: + +``` +/#/pr//review +/#/reviews?pull_request_id= +``` + +- `` is LiveReview's internal `pull_requests.id` (primary key), **not** the + provider PR number. It is unique across providers, repos, and orgs, so a GitHub #123 and a + GitLab !123 never collide. +- `` comes from the `livereview_prod_url` setting (the same public URL webhooks are + installed against). If it is not set, the section is skipped with a logged warning. + +## Section text (end of the PR description) + +``` +🔍 LiveReview +- ▶ Start a review: /#/pr//review +- 📜 View review history: /#/reviews?pull_request_id= +``` + +## Backend changes + +### 1. Migration — `db/migrations/_add_links_comment_posted_at_to_pull_requests.sql` + +- Add `links_comment_posted_at TIMESTAMPTZ` (nullable) to `pull_requests`. +- Regenerate `db/schema.sql`. + +### 2. Add the links section — `internal/jobqueue/repo_sync_worker.go` (`PRStateSyncWorker.Work`) + +After `UpsertPullRequest` returns the PR row id: + +1. Skip unless `event.Metadata["action"]` is `opened` (GitHub) or `open` (GitLab). +2. Claim the post atomically: + `UPDATE pull_requests SET links_comment_posted_at = now() WHERE id = $1 AND links_comment_posted_at IS NULL`. + If no row changed, the section was already added — skip. +3. Load the repo token via the existing `repositoryConnectorInfo` lookup. +4. Read the public URL via the webhook store's `GetWebhookPublicEndpoint` (inject the store + into the worker in `jobqueue.go`). +5. Read the description, append the section if the marker is missing, and save it. On failure, reset `links_comment_posted_at` to `NULL` and return the + error so River retries. + +### 3. Description helpers + +- `internal/providers/github/pr_comment.go` — `AppendToPRBody(ctx, baseURL, token, ownerRepo, number, marker, section)` (GET then PATCH the PR body), + using the existing `apiBaseURL` (works for GitHub Enterprise). +- GitLab: `NewHTTPClient(baseURL, token).AppendToMRDescription(projectID, iid, marker, section)` (reuses `GetMergeRequest`, then PUTs `description`). + +Not reusing `GitHubProvider.PostComment`: it hardcodes `api.github.com` and formats text as a +review finding. + +### 4. Org lookup — `GET /api/v1/pull-requests/:id/org` + +- Auth only (`RequireAuth`); the frontend doesn't know the PR's org yet, so the org + middleware chain can't be used here (approved exception). +- Handler looks up `pull_requests.org_id` and verifies the user is an active member of that + org. Returns `{ "org_id": ... }`, or **404** if the PR doesn't exist or the user isn't a + member (no existence leak). +- Returns nothing besides the org id. + +### 5. Reuse ongoing review — `internal/api/pull_request_review_trigger.go` (`createReview`) + +- Add optional body field `reuse_ongoing` (bool, default false). +- When true: if a review exists for this `pull_request_id` + `org_id` with status + `created`/`in_progress` and `created_at > now() - interval '10 minutes'`, return its id + without creating a new review. +- Existing callers don't send the field, so their behavior is unchanged. +- The actual review still goes through `POST /api/v1/reviews` with the full middleware chain + and LOC quota preflight. + +### 6. Reviews list filter — `getReviews` in `internal/api/server.go` + +- Add optional `pull_request_id` query param → `AND pull_request_id = $n` (still scoped by + `org_id`). +- Add the field to the `ReviewsQuery` schema. + +## Frontend changes + +### 7. Trigger page — `ui/src/pages/Reviews/PullRequestReviewTrigger.tsx` + +Route `/pr/:id/review` in `ui/src/App.tsx`. On load: + +1. `GET /api/v1/pull-requests/:id/org`. +2. If the org differs from the current one, `switchToOrg(org_id)`. +3. `POST /api/v1/reviews { pull_request_id, reuse_ongoing: true }`. +4. Redirect (replace) to `/reviews/:reviewId`. + +States: loading spinner, "PR not found or you don't have access" (404), quota-exceeded +message. Use a ref guard so React StrictMode doesn't fire the POST twice in dev. + +### 8. Reviews list — `ui/src/pages/Reviews/Reviews.tsx`, `ui/src/api/reviews.ts` + +- Read `pull_request_id` from the URL and pass it to the reviews API. +- Show a small "Filtered to one pull request · Clear" chip. +- Like the trigger page, switch to the PR's org first (shared hook + `ui/src/hooks/useSwitchToPullRequestOrg.ts`), so multi-org users don't land on an empty list. + +## Docs & navigation + +- **Routes guide:** add `internal/docindex/docs/routes_guide/reviews/pr-review-link.md`, and + update the Reviews list doc for the `pull_request_id` filter. +- **Mega menu:** no entry. The trigger page is only reached from the PR description link, and the + filter is an option on the existing Reviews page. + +## Known limitations + +- Opening the trigger link starts a review, so chat/email link previewers (for a logged-in + user) could start reviews. The 10-minute ongoing-review reuse limits this. Revisit with a + confirm page if it becomes a problem. +- Bitbucket, Gitea, and Azure DevOps sync PRs into `pull_requests` from webhooks only (no + periodic list sync), so PRs opened before the webhook was installed get no links. +- The description decisions table keeps the last 10 reviews (Azure DevOps caps descriptions + at 4000 characters). diff --git a/docs/risk-policy-engine-six-pager.md b/docs/risk-policy-engine-six-pager.md new file mode 100644 index 000000000..77607bcfb --- /dev/null +++ b/docs/risk-policy-engine-six-pager.md @@ -0,0 +1,364 @@ +# The Robot Checks First: A Risk-Based Auto-Approve Policy for Code Review + +## Summary + +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. + +## Working Backwards + +### Headline + +**LiveReview Ships Risk-Based Auto-Approval, Returning Reviewer Hours +Without Lowering the Bar on Risky Changes** + +### The customer problem + +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. + +### The solution + +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. + +### Customer quote + +"We stopped spending review time on changes that were never going to be +rejected, and started actually noticing which ones deserved a second +look," said an engineering lead at a mid-sized SaaS company piloting the +feature. + +### Call to action + +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. + +## Why Now + +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. + +## Why We Believe This Works: The RADAR Precedent + +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. + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#eef3fb', 'primaryBorderColor': '#4a6fa5', 'primaryTextColor': '#1b2a41', 'lineColor': '#4a6fa5', 'fontFamily': 'monospace' }}}%% +flowchart LR + PR["Pull request"] --> E{"Eligibility"} + E -->|author too junior,
not whitelisted| B1["Human review"] + E -->|ok| S{"Static rules"} + S -->|a configured rule
tripped, e.g. a secret| B2["Human review"] + S -->|ok| R{"Risk score"} + R -->|score above the
org's own cutoff| B3["Human review"] + R -->|ok| L{"LLM classification"} + L -->|low confidence, or a
disqualifying class| B4["Human review"] + L -->|ok| A["Auto-approved"] +``` + +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. + +## How the Robot Decides + +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. + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#eef6ee', 'primaryBorderColor': '#4a8f5a', 'primaryTextColor': '#1b2a41', 'lineColor': '#4a8f5a', 'fontFamily': 'monospace' }}}%% +sequenceDiagram + participant PR as Pull request, auto-approved + participant Prod as Production + participant Report as Outcome report + participant Nudge as Real-time nudge + participant Refit as Periodic batch refit + participant Model as Live model + + PR->>Prod: merged + Prod->>Report: later reverted, caused an incident, or confirmed safe + Report->>Nudge: one small, immediate weight step + Nudge->>Model: applied the same day + Refit->>Model: retrains from the full history + Refit->>Model: promoted only if it performs at least as well as today's model +``` + +## The Evidence + +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. + +## What This Is Worth to the Customer + +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. + +## Risks and What We're Watching + +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. + +## Appendix: Frequently Asked Questions + +### Customer questions + +**Does this mean less code review?** +No. It means the review that happens is concentrated on changes that +carry actual risk, instead of spread evenly across everything regardless +of risk. + +**What happens on day one, with no history?** +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. + +**Can I turn this off for one repository but keep it on elsewhere?** +Yes. Mode and threshold are both configurable per repository, and a +repository without its own setting simply inherits the organization's +default. + +**What stops the robot from approving something dangerous?** +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. + +### Internal and hard questions + +**How do we know the 46-scenario corpus isn't cherry-picked?** +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. + +**What is the actual false-approve rate today, and is it acceptable?** +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. + +**Why logistic regression instead of a more powerful model?** +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. + +**What is the plan if an org's override rate starts climbing?** +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. diff --git a/docs/risk-policy-engine.md b/docs/risk-policy-engine.md new file mode 100644 index 000000000..eb294fffb --- /dev/null +++ b/docs/risk-policy-engine.md @@ -0,0 +1,685 @@ +# Risk-Based Auto-Approve Policy Engine + +## Context + +A colleague already shipped the plumbing for this feature on `feat/auto-approve`: +a PR comment with a "Start a review" link (`internal/jobqueue/repo_sync_worker.go:265-321`), +a trigger endpoint that launches a review from that link +(`internal/api/pull_request_review_trigger.go`), and a worker that posts an +approve/request-changes decision back to GitHub/GitLab +(`internal/jobqueue/pr_policy_worker.go`). The decision logic itself is a +placeholder — `result.Approved = result.Critical == 0` — explicitly marked +`// ponytail: one static policy; swap for org-defined policies later`. + +We're replacing that placeholder with a real, configurable, risk-based policy +engine, modeled on Meta's RADAR system (arxiv 2605.30208): a layered funnel of +**eligibility → static/deterministic gates → ML risk score → LLM semantic +classification → final decision**, rather than one opaque model. The paper's +core lessons we're deliberately carrying over: +- Risk thresholds are a per-org *dial*, not a fixed cutoff (their P5→P50 experiment). +- Safety signals (revert rate, incident rate) must feed back into calibration — this + doesn't exist in LiveReview today and needs new plumbing. +- The system must stay legible: every decision should show *why*, stage by stage, not just a verdict. + +LiveReview already has three of the five RADAR ingredients half-built, which +shapes this plan heavily: +- **Static gates** → the CI/CD gate jq-ruleset system (`ci_rulesets` table, + `internal/api/ci_rulesets_handler.go`'s `runJQBool`/`buildCanonicalReviewDoc`) + is *exactly* RADAR's deterministic-validation stage, just not yet reusable + outside CI. We reuse it as-is rather than building a second rule engine. +- **LLM semantic signal** → findings already carry `Severity`/`Confidence`/`Category` + (`pkg/models.ReviewComment`, stored in `ai_comments` JSONB). What's missing is an + explicit *diff-level* classification (safe-refactor vs logic-risk vs security-risk), + not per-finding severity. +- **Risk score** → blast radius already scores *hunks* (`internal/blastradius`, + `blast_radius_hunks` table) but only aggregates via `MAX(combined)` per review + (`blast_radius_reviews` view) — a deliberately crude placeholder with + "no production caller today". We build the real diff-level score on top of this. +- **Eligibility** → doesn't exist at all. No seniority/level field on users, no + org policy config table, no settings UI tab for any of this. + +## Non-goals for this plan + +No full automatic revert-detection (e.g. auto-recognizing a revert commit by +pattern) beyond a manual/webhook ingestion point, and no attempt to make the +online-learning update path literally streaming/sub-second — "near real +time" here means an immediate single-example gradient nudge on each incoming +labeled outcome, plus a daily stabilizing batch refit and an on-demand +button, which is what was asked for. + +## Architecture + +### 1. Data model + +**`users.review_level`** (new column, `INTEGER NOT NULL DEFAULT 1`) — the +"seniority" dial the user asked for. No roles/tiers table; just a plain +integer owners can bump per user from the existing Users settings tab +(`ui/src/pages/Settings/UsersSettingsTab.tsx` — add one numeric field + +`PUT /api/v1/orgs/:org_id/users/:user_id` already exists, extend its payload). + +**One table, with one deliberate exception**: the thing that must stay +strictly org-wide is *the trained classifier itself* — `risk_model_versions`' +learned weights and `risk_factor_overrides`' category multipliers (§3) — +because fitting/retraining a separate model per repo is the genuinely +expensive thing hundreds of repos can't each support. The *threshold* +compared against that one shared score is a different, cheap thing: just a +cutoff number, no model behind it, so it's fine — and per the user's +preference, correct — to let it vary per repo. `auto_approve_policies` +therefore carries `risk_threshold_percentile` alongside everything else, +resolved the exact same way as every other field on this table: + +```sql +CREATE TABLE auto_approve_policies ( + id BIGSERIAL PRIMARY KEY, + org_id BIGINT NOT NULL REFERENCES organizations(id), + repository_id BIGINT REFERENCES repositories(id), -- NULL = org default + mode TEXT NOT NULL DEFAULT 'custom' + CHECK (mode IN ('inherit','custom','always_approve','never_approve')), + -- 'inherit' only valid on repo-scoped rows (explicit "use the org default" marker, + -- distinct from simply having no row, so the repo settings UI in §9 can show it + -- as a selected choice rather than an absence); org-default rows are never 'inherit'. + min_author_level INTEGER NOT NULL DEFAULT 1, + eligibility_whitelist_user_ids BIGINT[] NOT NULL DEFAULT '{}', -- bypass min_author_level + risk_threshold_percentile INTEGER NOT NULL DEFAULT 20, -- P20 default; cheap to override per repo, see below + require_llm_confidence INTEGER NOT NULL DEFAULT 8, -- out of 10, mirrors RADAR's ACR gate + blocking_ruleset_ids BIGINT[] NOT NULL DEFAULT '{}', -- reuses ci_rulesets.id + landing_delay_seconds INTEGER NOT NULL DEFAULT 0, -- post-approve human-rejection window + daily_approve_cap INTEGER, -- NULL = unlimited + restrict_outcome_reporting_to_admins BOOLEAN NOT NULL DEFAULT false, -- see §10 + force_approve_min_level INTEGER NOT NULL DEFAULT 3, -- see §7 + created_by, created_at, updated_at +); +``` +Resolution order for a given PR: repo-scoped row if present and `mode != +'inherit'`, else org-default row (`repository_id IS NULL`), else feature is +off — every field on this table, including the threshold, resolves per-repo +this way, pre-filled from the org default when a repo admin opens +"Customize" (§9) so they start from the global calibration and only change +what they want. The risk *score* itself always comes from the one shared +model in `risk_model_versions` +regardless, looked up by `org_id` alone. `mode = 'always_approve'`/`'never_approve'` +on either level are the "people may already know the rules for this repo" +shortcuts from the user's ask — skipping the risk-score/eligibility/LLM +stages entirely for `always_approve` (clearly a low-stakes repo, no need to +run the model against it every time), but the static jq gate (§4) still +runs even under `always_approve` — a repo-wide "trust this repo" setting is +not the same thing as "ignore a secrets-leak detector," and should never be +able to silently become that by accident. Every decision made under either +mode still writes a `review_policy_log` row ("mode override: +always_approve"), so the Auto Approve tab (§7) shows *why* just as clearly +as a normal funnel run, not a silent skip. This mirrors `ci_rulesets`' +existing org-scoping pattern — reuse its storage/handler conventions +directly (`internal/api/ci_rulesets_handler.go` is the template). + +A new **`review_policy_log`** table (append-only) replaces the single +`metadata.policy_result` JSONB blob: one row per policy evaluation with +`review_id, stage, verdict, detail JSONB, created_at`, so the UI can render +the full funnel trace (eligibility → static → risk → LLM → final), not just +the final approve/reject. (`risk_feature_snapshots` and `risk_model_versions` +— the tables backing the trained risk model — are described in §3, since +they're part of that stage's design, not generic schema.) + +**`review_outcomes`** (new table) for the feedback loop: `review_id, org_id, +reported_by_user_id, outcome_type ('revert'|'incident'|'confirmed_safe'), +source ('webhook'|'manual'|'ci'), reason TEXT NOT NULL, detail JSONB, +created_at`. `reason` is mandatory, not optional free text — a one-line +"why" (e.g. "rolled back due to prod 500s") required on every manual mark +and every webhook payload. This is the deliberate friction point: a bare +toggle with no permission check (per §10) would be too easy to mis-click or +spam, but a required reason keeps the manual log readable/auditable and +gives the risk model's audit trail (§7, §11) something a human reviewing it +later can actually make sense of, without needing an approval workflow or +role restriction to prevent noise. Populated by a manual "Mark as +reverted" action in the review detail UI and a documented webhook endpoint +(`POST /api/v1/reviews/:id/outcomes`, `reason` required in the payload too) +for CI/CD or incident-tooling integrations to report rollbacks/incidents +against a known review id — real auto-detection (e.g. watching for +revert-commit patterns) is future work. Critically, every insert into this +table is also the trigger for the real-time online model update described +in §3 (path 1) — this table isn't just a read-only log, it's the live +feedback signal the risk model reacts to. + +**`review_policy_overrides`** (new table): `review_id, org_id, +actor_user_id, original_verdict, forced_verdict, reason TEXT NOT NULL, +created_at`. This backs **Force Approve** (§7) — a human explicitly +approving (or rejecting) a specific PR *despite* the policy engine's +verdict, with a mandatory reason, fully audited. This is distinct from +`review_outcomes`: an override is about one specific review's decision +being overridden by a person, while an outcome is a production-truth signal +(revert/incident) the model learns from — they can both exist for the same +review without conflating "a human overrode the gate" with "this PR turned +out to be risky in prod." + +### 2. Eligibility stage + +New `internal/policyengine/eligibility.go`: given a review + its PR, resolve +`users.review_level` for the PR author (join via `pull_requests.author_email` +or equivalent existing field). If the author's id is in +`eligibility_whitelist_user_ids`, pass immediately regardless of level — +this is the "if <L3, always requires a review, unless whitelisted" rule +from the user's ask, read literally: level is the default gate, the +whitelist is the explicit per-user exception to it. Otherwise require +`review_level >= min_author_level`. Bots (reviews with no human author / +from a known bot login) get `review_level = 0` unless explicitly +whitelisted — this is the human/bot split RADAR treats as the very first +fork in the funnel. + +### 3. Risk score stage — an online-learning model that updates itself + +**The hard requirement here is adaptivity, not model complexity**: new +incident/rollback data must move the risk score within the day it arrives +(ideally immediately), and an admin tweaking "weight security findings +higher" must take effect immediately too. That requirement rules out the +ONNX-file approach from the earlier draft of this plan — ONNX is a *serving* +format for a model trained elsewhere; re-running an offline training script +and re-exporting a file on every incoming webhook is the wrong shape for +"updates in near-real-time." So this stage is a genuine online-learning +model, not a periodically-refreshed static artifact. + +**Library**: `github.com/cdipaolo/goml`'s `linear.Logistic`, specifically its +**`StochasticGA` online-learning mode** — pure Go, no cgo, and built for +exactly this: each call to `OnlineLearn` takes one new labeled example and +nudges the existing weight vector by one gradient step, in milliseconds, no +retraining pass over the full dataset required. (`goml` also supports a full +batch fit over a dataset in one call, used below for the periodic +stabilizing pass.) This is a legitimate, citable ML library doing real +gradient-based learning — not hand-picked constants — while still being +light enough to update on every single incoming data point, which is the +actual thing the user asked for. + +**Feature vector** (model inputs, shared with the simulation/backtest panel in §8): +- Blast-radius diff-level aggregates computed fresh from `blast_radius_hunks` + rows for the review — `sum(combined)`, `count(tier >= high)`, + `max(combined)`, `weighted_avg(combined, by hunk LOC)` — replacing the + `MAX(combined)` placeholder's reasoning with real feature engineering. +- Finding-severity counts (critical/warning/info) from the same + `canonicalReviewDoc` the jq rulesets already build + (`buildCanonicalReviewDoc` in `ci_rulesets_handler.go` — reused directly, + not re-queried). +- Diff-shape: files changed, lines added/removed (already on the review/PR row). +- LLM semantic-classification confidence + class, once §5 lands (feature + vector is append-only across phases — new features don't break old model + versions, they just start at 0 until the next retrain includes them). + +**Cold start — common sense first, data-driven second.** Most orgs will +have zero or a handful of `review_outcomes` rows for a long time — "collect +enough data to train a model" cannot be the starting state, or the feature +is useless for exactly the orgs that most need good defaults. So the seed +model's weights aren't zero-initialized or randomly initialized before the +first fit — they're hand-set to encode the same domain reasoning already +embedded elsewhere in this codebase (blast-radius tiers, the current +critical-findings-block rule, RADAR's own safe/risk taxonomy): blast-radius +sum and critical-finding count dominate, security-category findings are +weighted up, info-level findings and small diffs are weighted near zero. +This prior is the actual fallback behavior with no data — not a "cold start +model" distinct from the "real model," but the starting *point* every org's +model begins from and is anchored back to. + +That anchor is enforced structurally, not just at initialization, via a +**prior-anchored regularization term** on every batch refit (§ path 2/3): +instead of plain L2-toward-zero, penalize weights for drifting from the +common-sense prior, scaled by `λ / (1 + training_sample_count)` — so with 3 +labeled outcomes the fitted model stays close to common sense (data can't +yet overrule domain knowledge), and by the time an org has a few hundred +outcomes the anchor's pull is negligible and the weights are essentially +data-driven. This is one extra term in `goml`'s existing loss, not a second +model or a blending step at inference time — inference is still a single +dot product against one weight vector, just one that was fit with this +anchor. The real-time online nudge (path 1) uses the same anchored loss per +step, so even online updates can't swing wildly away from the prior on a +single noisy example. + +The UI should say this plainly rather than imply a hidden black box: the +risk-model section of §8/§11 shows a **"model confidence"** indicator derived +from `training_sample_count` (e.g. "Low — mostly common-sense defaults (3 +outcomes recorded)" vs "Calibrated to your repo (180 outcomes recorded)"), +so an org with no data sees an honest "this is sensible defaults, not yet +learned from your history" rather than a number that looks authoritative +before it's earned that. + +**`risk_feature_snapshots`** (new table): `review_id, org_id, model_version, +features JSONB, score, created_at` — written at every policy evaluation. +Makes the model auditable (UI can show "this review's risk score came from +these feature values") and is the training set once `review_outcomes` rows +attach to the same `review_id`s — recomputing features after the fact from +mutable tables would drift, so they're snapshotted at evaluation time instead. + +**`risk_model_versions`** (new table): `id, org_id (NULL = shared default), +version, weights_json, trained_at, training_sample_count, holdout_auc, +update_kind ('seed'|'online_update'|'batch_refit'), is_active`. Weights are +a few dozen floats, so unlike the ONNX design there's no blob store involved +at all — the row itself is the model. Cold start ships one seed model +(`org_id IS NULL`, `update_kind = 'seed'`, `training_sample_count = 0`) whose +weights are exactly the hand-set common-sense prior described below, not a +fit against any data — every org starts here and only moves away from it as +real `review_outcomes` accumulate. + +**Three update paths, all ending in a new `risk_model_versions` row (append-only, so any version can be reactivated as an instant rollback)**: +1. **Real-time online nudge** — the moment a `review_outcomes` row lands + (webhook or manual "mark as reverted"), the policy engine loads that + review's `risk_feature_snapshots` row as the labeled example and calls + `OnlineLearn` on the org's current weights, immediately persisting the + nudged weights as a new version and activating it. This is the actual + "learns in real time" behavior — no batch job, no delay, one gradient step. +2. **Daily batch refit** — a River periodic job (same registration pattern + as `blast_radius_worker.go`) re-fits a fresh `linear.Logistic` from + scratch over all of an org's `risk_feature_snapshots ⋈ review_outcomes`, + corrects for drift that a long sequence of noisy one-example online steps + can accumulate, evaluates holdout AUC, and only promotes if it's at least + as good as the current online-nudged version — the stabilizing + counterpart to (1), not a replacement for it. +3. **On-demand refit** — the same batch refit as (2), triggered by a + "Retrain now" button in the policy settings UI (§8) / `POST + /api/v1/risk-model/retrain`, for when an admin just changed a weight + override (below) and doesn't want to wait for the nightly job. + +**Admin weight overrides** (`risk_factor_overrides` table: `org_id, +feature_category, multiplier, updated_at`) are how "people may update the +weights for certain factors (security, etc.)" takes effect *instantly*, +decoupled from training cadence. These are deliberately **coarse, +category-level** sliders, not one per raw model feature — `blast_radius`, +`security_findings`, `critical_findings`, `diff_size` map to small groups +of underlying features (e.g. `blast_radius` covers `sum(combined)`, +`max(combined)`, `weighted_avg(combined)` together), so an admin adjusts a +handful of legible dials instead of a dozen raw coefficients that could +easily be set into a contradictory configuration. Scoring computes +`effective_weight_i = learned_weight_i * override_multiplier[category(i)]`, +so dragging the "security" slider changes live scoring the moment it's +saved, with no retrain needed. The next batch refit (path 2/3) treats each +category override as an L2-regularization anchor (penalize that category's +learned weights for drifting far from `override_multiplier * initial_weight`) +so the model doesn't fight or silently erase the admin's stated preference +on its next pass — the override is both an instant hard effect and a soft +prior for future learning. + +**Inference**: `internal/riskscore.Scorer` keeps the org's active +`risk_model_versions` weights cached in memory (invalidated whenever a new +version activates via any of the three paths above), computes +`sigmoid(Σ effective_weight_i * feature_i)` directly — a dot product, no +runtime/session overhead, which is also why this design tolerates being +updated on every single webhook event without a performance concern. + +**Percentile calibration**: unchanged — per-org, a rolling window of recent +scores turns a given `risk_threshold_percentile` value into "this org's +actual P20," not a global magic number. Independent of which model version +produced the scores, so recalibration is cheap even right after an online +nudge changes the score distribution. The *calibration curve itself* is a +single org-wide thing for v1 (one model per org, per the per-org-only scope +decision) — but the `risk_threshold_percentile` cutoff read off that curve +is a plain field on `auto_approve_policies` (§1) and so *is* +repo-overridable, exactly like the rest of that table: a repo can decide +"P50 is fine for us" against the same shared score distribution without +needing its own curve. + +**ONNX note**: `github.com/yalue/onnxruntime_go` is already vendored +(serving the BGE embedder in `internal/docindex/embedder.go`) and remains a +good fit if a *future* heavier model — gradient-boosted trees, a small +MLP — trained offline on a slower cadence is ever wanted; `riskscore.Scorer` +should be a small interface (`Score(features) float64`) specifically so an +ONNX-backed implementation could be swapped in later for one org without +touching the policy engine. It's just not what satisfies "updates in near +real time" today — that's what `goml`'s online mode is for. + +### 4. Static/deterministic gate stage — reuse jq rulesets as-is + +No new rule engine. `auto_approve_policies.blocking_ruleset_ids` references +existing `ci_rulesets` rows. The policy worker calls the *same* +`buildCanonicalReviewDoc` + `runJQBool` already used by +`CIRulesetsHandler.Evaluate` — this is the literal reuse of "the existing jq +based blockers" the user asked for. The only new code is a thin loop: for +each configured ruleset id, evaluate, block on first truthy. The existing +`ui/src/pages/CiRulesets/` builder UI becomes dual-purpose automatically (a +ruleset can be attached to CI, to auto-approve, or both) — just needs a +checkbox/multi-select in the new policy editor listing existing rulesets by +name, no changes to the ruleset UI itself. + +### 5. LLM semantic classification stage — folded into the existing review call + +Rather than a second LLM round-trip, this rides inside the review-generation +call that already exists: `internal/prompts/templates.go`'s +`JSONStructureExample` (the schema the model is told to return, currently +`fileSummaries` + `comments`) gets one more top-level object, +`diffClassification`: +```json +"diffClassification": { + "class": "refactor-only|logic-change|security-sensitive|...", + "confidence": 7 +} +``` +alongside a short addition to `CommentRequirements`/`ReviewGuidelines` +describing RADAR's safe/risk taxonomy (refactor-only, dead-code-removal, +logic-change, security-sensitive, etc.) and how to pick `confidence` (0–10). +Same taxonomy-enforcement pattern already used for `category`/`subcategory` +(`TaxonomyClassificationRules`, templates.go:100) — a fixed, closed list, +not free text. Whatever code already parses the model's JSON response into +`fileSummaries`/`comments` (one extra field on the same struct) picks up +`diffClassification` too and stores it on the review alongside the rest of +`review_result` (same `metadata.review_result` JSONB it already writes to — +no new storage column needed, `buildCanonicalReviewDoc` already reads from +this blob and gains one more field to surface for jq rules). Trade-off +accepted deliberately: this couples the classification's prompt/schema to +the main review prompt, so a future change to one should check the other — +worth it here to avoid a second LLM call's cost/latency per review, since +findings generation already has full context on the diff. Policy requires +`confidence >= require_llm_confidence` AND no disqualifying class, mirroring +RADAR's "any risk signal disqualifies" rule. + +### 6. Rewiring `pr_policy_worker.go` + +Replace the body of `Work`/the fake `policyResult` with calls into +`internal/policyengine`: resolve policy → eligibility → static gates → risk +score vs. threshold → LLM classification gate → final verdict. Each stage +writes a `review_policy_log` row. `buildPolicyComment` is extended to render +the funnel (stage-by-stage ✅/❌ table) instead of just severity counts — +this is the "show why" requirement. `landing_delay_seconds` becomes an actual +delay before `publish()` runs (queue a follow-up River job instead of +publishing immediately), giving the "human rejection window" RADAR relies on +for safety. + +### 7. Review detail — "Auto Approve" tab + +Every review triggered through the PR-link flow should show its policy +funnel on its own detail page, not just in the GitHub/GitLab comment. Add +`'auto-approve'` as a fourth value to `ReviewDetail.tsx`'s existing +`activeTab` union (currently `'findings' | 'accounting' | 'events'`, +`ReviewDetail.tsx:84`) and a fourth entry in the `tabs={[...]}` array passed +to the existing `Tabs` component (`ReviewDetail.tsx:679-686`) — the tab only +appears when the review has `metadata.pr_link_policy = true` (same flag the +backend already uses to decide whether to run the policy at all), so plain +ad-hoc reviews don't show an empty tab. + +Content, backed entirely by `GET /api/v1/reviews/:id/policy-log` (new +endpoint, reading `review_policy_log` ordered by stage): +- A vertical stage list — Eligibility, Static Rules, Risk Score, LLM + Classification, Final Decision — each row showing ✅/❌/⏭️ (skipped, e.g. + static rules skipped because eligibility already blocked) and a one-line + reason ("Author level 2 < required 3" / "Blocked by ruleset `no-secrets`" + / "Risk score 34 (P45) ≥ threshold P20" / "approved"). +- Expanding the Risk Score row shows the actual feature breakdown from that + evaluation's `risk_feature_snapshots` row as a small table (feature name, + value, effective weight, contribution) — directly answers "what parts of + the decision are what" for the one stage that isn't already self-explanatory + jq/eligibility logic, and doubles as the explainability view for the + model-confidence indicator from §3. +- A final summary banner matching the comment already posted to + GitHub/GitLab (reuse `buildPolicyComment`'s data, rendered as UI instead + of markdown, so the two never drift apart in wording). +- If `mode` was `always_approve`/`never_approve` (§1), the tab shows that + as the single reason, with a link to the policy settings — no funnel to + render since none ran. +- **Force Approve**: when the final verdict is "blocked," a visible "Force + approve anyway" button, gated by `users.review_level >= + force_approve_min_level` (a configurable floor, default 3, in §8's + Eligibility sub-tab — not tied to org role, consistent with seniority + being the dial that already governs eligibility in §2) rather than a + fixed owner/admin check, opens a small form requiring a reason, then + calls `POST /api/v1/reviews/:id/force-approve`. Only the blocked→approved + direction exists — there's no symmetric "force request-changes" on a PR + the engine already approved, since a human can already just leave an + ordinary review comment or request-changes on any PR regardless of what + the bot posted; the override only needs to exist for the direction that's + actually dangerous (bypassing a safety gate), not the direction that's + already unrestricted. This posts the real GitHub/GitLab + approval via the same `publish()` path as a normal approve (§6), but the + comment is explicitly marked "⚠️ Force-approved by {user} despite policy + block — {reason}" so the PR history is honest about what happened, and + writes a `review_policy_overrides` row (§1) that the metrics dashboard + (§11) can later report on ("N force-approvals this month, M of which were + later marked as incidents" — the sharpest possible signal that a policy + threshold is miscalibrated). + +### 8. Settings UI — policy editor (sub-tabbed) + "what would've happened" simulation + +New Settings tab `ui/src/pages/Settings/AutoApprovePolicyTab.tsx` (outer +registration pattern: `StorageSettingsTab.tsx`, gated `requiresOwnerOrAdmin` +like the other admin tabs in `Settings.tsx`'s `tabs` array, ~line 292). The +page itself is **five horizontal sub-tabs** (Risk Model, Static Rules, +Eligibility, General, and Production Feedback — the last described in +§10), using the same `Tabs` component/pattern already used inside +`ReviewDetail.tsx` (§7) rather than a long single-scroll form — this +directly answers the "each thing probably can be a tab within that main +tab" ask. **The trained classifier itself is org-only** (`risk_model_versions`' +weights, `risk_factor_overrides`' category multipliers, retraining — all +with no `repository_id`, never per-repo, since that's the expensive part); +everything else, *including the threshold read off that model's score*, is +backed by `auto_approve_policies` and reappears, repo-scoped, in §9's +repository settings — same components, different `repository_id` on the +save call, per the "I'd prefer to have those as repository level overrides +as well" ask. + +**Sub-tab A — Risk Model (the classifier itself, org-wide only, not +repeated in §9)**: enabled toggle, the four coarse category weight sliders +(Blast Radius / Security Findings / Critical Findings / Diff Size, backed +by `risk_factor_overrides`, instant effect per §3), the model-confidence +indicator ("Low — 3 outcomes recorded" / "Calibrated — 180 outcomes +recorded"), a "Retrain now" button (`POST /api/v1/risk-model/retrain`), and +a read-only calibration chart (score distribution across the org's recent +reviews) that both this sub-tab's own org-default threshold field and every +repo's threshold override (Sub-tab D / §9) are plotted against, so an admin +tuning either always sees the same curve. The threshold field itself lives +on Sub-tab D below (org-default row included), not here — it's a plain +policy field, not part of the model. + +**Simulation/backtest panel** (shown on Sub-tab A, reused everywhere a +threshold is being edited): a read-only `POST +/api/v1/auto-approve-policies/simulate` that takes the draft policy (not +yet saved — optionally for a specific repo, passing a `repository_id` so +"what would this repo's *effective* policy have done" is answerable even +though the underlying score comes from the shared model) and replays it +against the org's (or that repo's) last N completed reviews — same +evaluation path as production, result discarded — showing a table of +{review, would-approve?, which stage blocked it, risk score}. Reuses 100% +of real evaluation code (§2-§6), so there's no separate preview logic to +keep in sync — the same pattern `CiRulesetEditor.tsx` already uses to +preview a jq expression against a past review. + +**Sub-tab B — Static Rules** *(repo-overridable, reused in §9)*: "Write and +test static jq rules" per the user's ask, reusing `ui/src/pages/CiRulesets/` +wholesale rather than building a second editor — this sub-tab is a thin +wrapper embedding `CiRulesetEditor.tsx`'s existing builder (expression +input, live preview against a past review, jq quick-help sidebar from +`jqQuickHelp.tsx`) plus a multi-select of which existing/newly-created +rulesets are attached to this policy as blockers (`blocking_ruleset_ids`). +A ruleset created or edited here is the exact same `ci_rulesets` row usable +for CI/CD gates — no fork, per §4. A repo override can attach a completely +different set of rulesets than the org default (e.g. a stricter ruleset +only on the payments repo). + +**Sub-tab C — Eligibility** *(repo-overridable, reused in §9)*: +`min_author_level` input, explanatory copy for the "below this level, +always requires human review unless whitelisted" rule, a user picker +(searching the org's members, same component pattern as +`UsersSettingsTab.tsx`'s member list) to manage +`eligibility_whitelist_user_ids` — add/remove with each user's current +`review_level` shown inline so it's clear *why* they'd otherwise be +blocked — and a separate `force_approve_min_level` input (§7) with its own +explanatory copy, since it's a distinct dial from `min_author_level` +(one governs whose *PRs* skip the funnel, the other governs who can +*override* the funnel's verdict on anyone's PR). A repo can reasonably want +a higher bar here than the org default (e.g. the payments repo requires +L4+ where the org default is L2+). + +**Sub-tab D — General** *(repo-overridable, reused in §9)*: +`risk_threshold_percentile` (shown as a percentile slider against the same +calibration chart from Sub-tab A, pre-filled from the org default on a +repo's first "Customize"), `require_llm_confidence` slider (the LLM +classification gate from §5), `landing_delay_seconds`, `daily_approve_cap`, +and `restrict_outcome_reporting_to_admins` — the threshold sits here rather +than on its own sub-tab specifically because it's a plain field on +`auto_approve_policies`, not model configuration, and belongs next to the +other per-policy knobs that need to be overridable per repo the same way. + +Mega menu: add an entry under the existing "Reviews" or "Settings" section +in `ui/src/components/Navbar/megaMenuData.ts` per this repo's standing rule +that every new settings tab needs one — mirror the `requiresOwnerOrAdmin` +gating from the tab itself. + +### 9. Repository-level overrides (Explore → Repositories → repository settings) + +`ui/src/pages/Explore/RepositorySettings.tsx` already has exactly the +layout this needs: a `SECTIONS` array (currently just `repo-cache`, +`RepositorySettings.tsx:10`) rendering a left-hand section list and the +selected section's component on the right, hash-routed +(`#repo-cache`/`#auto-approve`). Add one entry, `{ id: 'auto-approve', name: +'Auto-approve', icon: ... }`, and a new `RepoAutoApproveSection.tsx` +component (same shape as the existing `RepoCacheSection.tsx` it sits next +to) that: +- Shows the resolved org-default policy as read-only context ("Org default: + Custom · min level 2 · P20 threshold · 2 blocking rulesets") plus a line + making clear which part of that *can't* be changed per-repo: the trained + model behind the score itself (weights, retraining, category overrides — + §3) is always the one org-wide classifier, no matter what this repo's + policy says. The threshold compared against that score, though, is just + another field below. +- Offers the same four choices as `auto_approve_policies.mode`: + **Use org default** (`mode = 'inherit'`, the common case — most repos + need nothing here), **Always auto-approve this repo** + (`mode = 'always_approve'`, for the low-stakes repos the user mentioned — + docs sites, internal tools), **Never auto-approve this repo** + (`mode = 'never_approve'`, for the sensitive repos), or **Customize** + (`mode = 'custom'`, which expands into **Static Rules / Eligibility / + General** — §8's sub-tabs B, C, D, reusing those exact components with + `repository_id` set instead of building repo-scoped equivalents, General + including the `risk_threshold_percentile` field pre-filled from the org + default — but *not* a Risk Model sub-tab, since the classifier itself + stays global per the user's "keep the risk model simple and global" + decision, while the threshold and everything else becomes repo-overridable). +- This is the same `auto_approve_policies` table and + `internal/policyengine` resolution logic from §1 — a repo-scoped row is + not a different feature, just a different `repository_id` on the same + CRUD endpoints, so this section is mostly UI wiring, not new backend work. + The risk score itself is always computed once per review from the org's + single model (§3) regardless of which repo it's in — only the threshold + compared against it, and everything else on `auto_approve_policies`, can + differ per repo. + +### 10. Outcome ingestion UX — setup, test, and soft guidance + +This is the single most fragile link in the feedback loop: `review_outcomes` +is the only thing that makes the risk model real ML instead of a frozen seed +model, and most orgs have no existing process for tagging "this PR caused an +incident" — this is a brand-new habit, not a webhook they're already +sending. So the UI work here matters as much as the API/schema from §1. + +**Setup panel** (a fourth sub-tab on `AutoApprovePolicyTab.tsx`, §8 — +"Production Feedback" alongside Risk Model / Static Rules / Eligibility): a dedicated +"Production feedback" subsection, modeled directly on +`SMTPSettingsTab.tsx`'s `handleTest`/"Test Connection" pattern (same +loading/success/error state shape) and `StorageSettingsTab.tsx`'s connection +test, rather than inventing a new pattern: +- Shows the org's webhook URL (`POST /api/v1/orgs/:org_id/review-outcomes`, + authenticated the same way other inbound integration webhooks are — + reuse whatever bearer/signature scheme `ci_rulesets`' CI integration + already documents in `CiRulesetIntegration.tsx`/`shared.tsx` rather than + inventing a new auth story) plus a one-click "regenerate secret." +- A **"Send test event"** button that POSTs a synthetic outcome against a + real, recent review in that org (picked from a dropdown) marked + `source: 'test'` so it never pollutes the training set (`risk_feature_snapshots + ⋈ review_outcomes` queries used for refitting explicitly exclude + `source = 'test'`), then immediately shows the actual stored row back — + "✅ received, this review is now tagged as reverted" — so the admin gets + the same end-to-end confidence as `handleTest` gives for SMTP: a round + trip against the real endpoint, not a dry validation. +- Copy-paste integration snippets for the common cases, same generator + pattern as `buildCurlSnippet`/`buildGithubActionsWorkflow` in + `CiRulesetIntegration.tsx`'s `shared.tsx`: a `curl` one-liner for CI/CD + rollback jobs, a GitHub Actions snippet for a `revert` label or a + `git revert` commit message pattern, and a generic incident-tool webhook + shape (for PagerDuty/Opsgenie-style outbound webhooks) — reusing that file's + existing snippet-builder functions rather than writing new ones. + +**Soft guidance, not a hard requirement**: most orgs reading this panel will +have *no* existing revert/incident tracking at all — this isn't "connect +your existing system," it's "here's a new habit worth starting," and the +copy should say so plainly rather than assume a mature practice exists. +Concretely: +- An inline help callout: *"Don't have incident tracking yet? Start with + the easiest signal: mark a PR as reverted whenever you `git revert` it or + roll back a deploy because of it. Even a few marks a month are enough for + the risk model to start learning your repo's real risk patterns — it + doesn't need to be exhaustive."* +- The **manual "Mark as reverted/incident" button** in the review detail + page (already in scope from §1) is positioned as the primary, always-available + path — not a fallback for when the webhook isn't wired up. The webhook is + the "once you're ready to automate this" upgrade, not a prerequisite for + the feature doing anything useful. +- **Permissions default to open, with a lockdown switch**: by default, any + org member can click "Mark as reverted/incident" (reporting an incident + is exactly the kind of thing you want zero friction for, and restricting + it by default would undercut "start the habit easily"). A toggle in the + Production Feedback sub-tab — `restrict_outcome_reporting_to_admins` + (§1) — lets an org tighten this to owner/admin-only once they're worried + about noisy or mistaken reports skewing the model; the webhook endpoint + is always authenticated by its own secret regardless of this setting, so + the toggle only affects the in-app button. +- A small "last 30 days" counter on the setup panel — "3 outcomes recorded + (2 manual, 1 webhook)" — so an org can see at a glance whether this + signal is flowing at all, since a silently-empty `review_outcomes` table + is the single biggest way this whole feedback loop quietly does nothing. +- Explicitly do *not* promise automatic revert detection in this UI copy — + the non-goal from earlier in this plan — so expectations match what's + actually built (manual + webhook, not "we'll figure it out for you"). + +### 11. Metrics dashboard (wait time, revert rate, incident rate) + +New read-only panel (either its own page or a card on the existing analytics +page) computing the paper's three headline metrics from data this plan +already creates: +- **Wait time**: `policy_result published_at - review started_at` (or PR-open + time), from `review_policy_log` timestamps — no new instrumentation needed. +- **Revert rate / incident rate**: `count(review_outcomes) / count(auto-approved + reviews)` over a trailing window, compared against the same ratio for + human-reviewed PRs in the same repo — directly the paper's "1/3 revert + rate" style comparison, but will read near-zero/meaningless until + `review_outcomes` has real data (expected — the ingestion point existing is + what matters now, not the chart being populated day one). +- **Force-approval audit log**: a simple table view over + `review_policy_overrides` (§1/§7) — who, when, which review, what reason, + and whether that review later got a `review_outcomes` row — the most + direct "is this policy too strict" signal available, since every force + approval is a human explicitly disagreeing with the engine. + +## Delivery order + +1. **Schema + eligibility + policy CRUD** (reuse `ci_rulesets_handler.go` + patterns): `users.review_level`, `auto_approve_policies`, + `review_policy_log`, `review_outcomes`, `review_policy_overrides`, + `risk_feature_snapshots`, `risk_model_versions` migrations; + `internal/policyengine` package; new API handler + routes (including + `POST /api/v1/reviews/:id/force-approve`). No UI yet — policy rows + settable via SQL/API for internal testing. +2. **`internal/riskscore`**: feature extraction, the `goml`-based + `linear.Logistic` scorer (online + batch modes), `risk_model_versions`/ + `risk_feature_snapshots`/`risk_factor_overrides` migrations, the + seed/cold-start model (batch-fit once against weak labels, since + `review_outcomes` starts empty), the online-update hook fired from + `review_outcomes` inserts, and the daily River batch-refit job + its + on-demand API trigger. +3. **Rewire `pr_policy_worker.go`** onto the real engine (eligibility + + reused jq-ruleset gates + the trained risk scorer) — ships a materially + better auto-approve than the current placeholder even before the LLM + classification stage lands. +4. **LLM semantic classification stage** (§5) — additive gate, doesn't block + shipping step 3 first; extends the feature vector for future retrains. +5. **Review detail "Auto Approve" tab** (§7) + **Settings UI** (§8): + policy editor sub-tabs and simulation panel. +6. **Repository-level overrides** (§9): `RepoAutoApproveSection.tsx` and + the repo-settings `mode` picker, reusing §8's sub-tab components. +7. **Outcome ingestion UX** (§10): manual mark-as-reverted action, the + webhook setup/test/guidance panel, and excluding `source = 'test'` rows + from training queries. +8. **Metrics dashboard** (§11). + +## Verification + +- Unit tests for `internal/riskscore` (feature extraction + scorer math) and + `internal/policyengine` (each stage's pass/fail logic), following existing + test patterns in `internal/api/ci_gate_script_e2e_test.go`. +- End-to-end: trigger a review via the existing PR-link flow against a real + test PR, confirm `review_policy_log` captures all stages and the posted + GitHub/GitLab comment renders the funnel correctly for both an approve and + a blocked case. +- Settings UI: start dev server, exercise the policy editor and simulation + panel against real historical reviews in a test org, verify `npx tsc + --noEmit` passes (no full build without separate approval, per standing + project rule). diff --git a/docs/risk-policy-explainability.md b/docs/risk-policy-explainability.md new file mode 100644 index 000000000..eebeb2388 --- /dev/null +++ b/docs/risk-policy-explainability.md @@ -0,0 +1,386 @@ +# How the Risk-Based Auto-Approve Engine Works (and How We Know) + +This document explains the risk-based auto-approve policy engine +(`internal/policyengine`, `internal/riskscore`) at three depths — read as far +as you need. Every number quoted below comes directly from a passing, +rerunnable test in this repository, not a hypothetical example; the exact +commands to reproduce each one are in **"How do we know this works"** at the +end. The full architectural spec is `docs/risk-policy-engine.md`; this +document is the explainer, not a second spec. + +## 1. For leadership + +Every pull request that goes through LiveReview's auto-approve flow passes +through a funnel of independent checks, not one opaque model: + +```mermaid +flowchart LR + PR[Pull request] --> E{Eligibility} + E -->|author too junior,
not whitelisted| B1[Blocked] + E -->|ok| S{Static rules} + S -->|a configured jq rule
tripped, e.g. secrets| B2[Blocked] + S -->|ok| R{Risk score} + R -->|score above the
org's calibrated cutoff| B3[Blocked] + R -->|ok| L{LLM classification} + L -->|low confidence, or a
disqualifying class| B4[Blocked] + L -->|ok| A[Approved] +``` + +Any single stage can block the PR; a human review is always the fallback. A +`mode=always_approve` or `never_approve` setting (for repos a team has +already decided are low- or high-stakes) can skip the eligibility/risk/LLM +stages entirely, but the static rules stage — the one that can catch things +like a hardcoded secret — **always still runs**, even under +`always_approve`. "We trust this repo" is never allowed to silently become +"ignore a secrets leak." + +The risk score itself is a small, auditable statistical model (not a large +language model making a holistic judgment call) that starts from hand-set, +common-sense defaults and gradually adapts as real production outcomes +(reverts, incidents, confirmed-safe merges) are reported against it. Two +safety nets exist regardless of what the model says: a human can always +**force-approve** a blocked PR (with a mandatory, audited reason), and a +configurable **landing delay** gives humans a window to object after an +auto-approval before it's considered final. + +## 2. For engineering managers + +### The funnel's knobs, and where to find them + +| Stage | What it checks | Where it's configured | +|---|---|---| +| Eligibility | PR author's `review_level` vs. the policy's `min_author_level`, or an explicit whitelist | Settings → Auto Approve Policy → Eligibility | +| Static rules | Existing CI/CD jq rulesets, reused as-is (`ci_rulesets`) | Settings → Auto Approve Policy → Static Rules | +| Risk score | A trained score vs. a percentile cutoff calibrated from the org's own recent review history | Settings → Auto Approve Policy → Risk Model (weights) + General (threshold) | +| LLM classification | The review LLM's diff-level class (`refactor-only`, `logic-change`, `security-sensitive`, …) and its confidence | Settings → Auto Approve Policy → General | + +Every evaluation writes a `review_policy_log` row per stage, so the Review +Detail page's Auto Approve tab can show exactly which stage passed, failed, +or was skipped, and why — the same reasons a worked example below quotes. + +### The feedback loop + +```mermaid +sequenceDiagram + participant PR as PR auto-approved + participant Prod as Production + participant Report as Outcome report
(webhook or "mark as reverted") + participant Nudge as Real-time online nudge + participant Nightly as Nightly batch refit + participant Model as Active model + + PR->>Prod: merged + Prod->>Report: later reverted / incident / confirmed safe + Report->>Nudge: ApplyOutcome(review, outcome_type) + Nudge->>Model: one gradient step, persisted immediately + Note over Nudge,Model: Same day, no batch job required + Nightly->>Model: refit from scratch over all labeled outcomes + Nightly->>Model: only promotes if holdout AUC doesn't regress + Note over Nightly,Model: Corrects drift a long run of noisy
single-example nudges can accumulate +``` + +The two paths are complementary, not redundant: the real-time nudge is what +makes a single newly reported incident move the score *that day*; the +nightly (or on-demand, via "Retrain now") batch refit is what keeps a long +sequence of small, possibly-noisy nudges from drifting away from a globally +well-fit model, and it has a safety gate — a refit that doesn't do at least +as well on held-out data as the current model doesn't get promoted. + +**What to watch, ongoing**: the Risk Model sub-tab's model-confidence +indicator ("Low — common-sense defaults, N outcomes recorded" vs +"Calibrated, N outcomes recorded") tells you how much of the current score +is learned vs. default. The metrics dashboard (§11 of the architecture doc) +tracks revert/incident rate on auto-approved PRs and the force-approval +audit log — the sharpest signal available for "is this policy too strict or +too lax," since every force-approval is a human explicitly overriding the +engine. + +## 3. For engineers + +### The score, exactly + +``` +Score = sigmoid(bias + Σ_i effective_weight_i · feature_i) +effective_weight_i = learned_weight_i × category_multiplier(category(i)) +``` + +- `sigmoid`, `Score`, `scoreRaw`: `internal/riskscore/model.go` +- The feature vector (append-only, index-stable across model versions): + `internal/riskscore/features.go`'s `FeatureSpecs` — blast-radius + aggregates (`br_sum`, `br_max`, `br_weighted_avg`, `br_high_count`), + severity counts (`critical_count`, `warning_count`), `security_count`, + diff shape (`files_changed`, `lines_added`, both `log1p`-compressed), + `llm_class_risk`, and one count feature per remaining taxonomy category + (`reliability_count`, `correctness_count`, `performance_count`, …). +- `category_multiplier`: `DefaultCategoryMultiplier` in `model.go` — + `blast_radius` ×2.0 (weighted highest of all; see the commit that moved it + there), `security` ×1.5, `correctness`/`reliability` ×1.25, + `performance` ×1.1, everything else ×1.0 — overridable per org via + `risk_factor_overrides`, applied instantly with no retrain + (`categoryOverride` in `model.go`). + +### Every feature's valid range + +So a reader can judge what a normal value looks like, rather than guess: + +| Feature | Measures | Valid range | +|---|---|---| +| `br_sum` | Sum of every hunk's blast-radius score | 0 and up — each hunk is 0-100 by convention (`internal/blastradius.Tier`), but the sum across many hunks can exceed 100 | +| `br_max` | The single highest-scoring hunk | 0-100 | +| `br_weighted_avg` | Hunk scores averaged, weighted by lines touched | 0-100 | +| `br_high_count` | Count of hunks scoring ≥66 ("high" tier) | 0 and up, an integer | +| `critical_count`, `warning_count` | Count of findings at that severity, any category | 0-500 — `storagereviews.ListFindings`'s hard `limit` parameter (`internal/canonicaldoc/canonicaldoc.go`) | +| `files_changed`, `lines_added` | Diff shape | 0 and up, uncapped, but `log1p`-compressed before reaching the formula so a 10× bigger diff doesn't add 10× more to the score | +| `llm_class_risk` | The diff's AI classification, mapped to a risk ordinal | Exactly one of `0`, `0.5`, `1` — never continuous (`classRisk` in `features.go`) | + +### All 10 finding-taxonomy categories have their own signal, weight, and dial + +Not collapsed into "the other N categories" — every category the Settings +UI shows a weight slider for has a dedicated feature here, with its own +starting weight (`Prior()`) and starting category multiplier +(`DefaultCategoryMultiplier`). A batch refit or an online nudge updates +every one of these weights together, in the same pass — none of them is +special-cased out of learning: + +| Category | Feature | Starting weight | Starting dial | +|---|---|---|---| +| Security | `security_count` | 0.55 | ×1.50 | +| Reliability | `reliability_count` | 0.30 | ×1.25 | +| Correctness | `correctness_count` | 0.30 | ×1.25 | +| Performance | `performance_count` | 0.20 | ×1.10 | +| Scalability | `scalability_count` | 0.12 | ×1.00 | +| Compliance & Governance | `compliance_governance_count` | 0.12 | ×1.00 | +| Maintainability | `maintainability_count` | 0.10 | ×1.00 | +| Architecture | `architecture_count` | 0.10 | ×1.00 | +| Developer Experience | `developer_experience_count` | 0.10 | ×1.00 | +| Cost | `cost_count` | 0.10 | ×1.00 | + +A `×1.00` starting dial doesn't mean "ignored" — it means "not weighted up +or down relative to the others yet." Every one of these 10 is independently +adjustable from the Settings UI's Risk Model sub-tab (the same page that +used to hide several of these under a collapsed "Other" section — see the +commit that fixed that), and the change applies to the very next score, +with no retrain required. + +### The prior anchor + +Every org starts from one shared, hand-set seed model (`Prior()` in +`model.go`), not a zero- or randomly-initialized one — this is the +"common-sense first" default, not a placeholder. Both learning paths blend +back toward it: + +``` +AnchorStrength(n) = λ / (λ + n), λ = 1.0 +``` + +At `n=0` (no labeled outcomes yet), `AnchorStrength = 1.0` — the model is +entirely the prior. By a few hundred outcomes, the pull is negligible and +the weights are effectively data-driven. `TestAnchorStrengthMonotonicallyDecreasing` +(`internal/riskscore/riskscore_test.go`) locks this curve's shape in as a +regression guard. The real-time `OnlineStep` uses the same anchor, scaled +instead by `λ/(1+sampleCount)` per step — `TestOnlineStepRegularizationWeakensAsSampleCountGrows` +confirms this pull measurably weakens as an org accumulates more outcomes, +so a single noisy report early on can't swing the model, while the same +report matters less once there's a real track record. + +### Worked examples (from the scenario corpus, `internal/policyengine/testdata/scenarios.json`) + +Run `go test ./internal/policyengine/... -run TestScenarios -v` to reproduce +these exactly: + +| Scenario | Score | Decision | Why | +|---|---|---|---| +| `readme-only-edit` | 0.420 | Approved | No findings, no blast radius, safe LLM class. 0.420 is compared against **0.5, the cold-start fallback cutoff a brand-new org with zero scored reviews uses** — not a permanent threshold every org compares against. See "Is the threshold a fixed number?" below; an org with real history compares against its own percentile cutoff instead, almost never 0.5. | +| `huge-refactor-critical-security-finding` | 1.000 | Blocked (risk) | High blast radius **and** a critical security finding compound: `blast_radius` weight ×2.0, `security` weight ×1.5 — either alone would likely have blocked it; together the score saturates. | +| `high-blast-radius-silent-landmine-no-findings` | 0.999 | Blocked (risk) | Zero findings at all (the LLM reviewer found nothing to flag line-by-line) — blast radius alone catches what per-finding severity counts miss. This is the exact scenario the risk stage exists for. | +| `threshold-p80-lenient-approves-borderline-score` / `threshold-p10-strict-blocks-same-score` | 0.582 (both) | Approved / Blocked | The *identical* review, scored identically, under two different `risk_threshold_percentile` settings against the same seeded org calibration curve. Proves the percentile dial is a real lever on the decision, not just cosmetic. | +| `llm-disqualifying-class-high-confidence-blocks` | 0.476 | Blocked (llm) | The risk score alone (0.476) would approve; the LLM gate blocks anyway, because `security-sensitive` is a disqualifying class regardless of confidence — RADAR's "any risk signal disqualifies" rule, implemented literally. | + +### The learning loop, with real before/after numbers + +`internal/riskscore/learning_cycle_test.go`'s `TestLearningCycleProvesFeedbackLoopWorks` +runs a full synthetic cycle and logs this exact progression (`go test +./internal/riskscore/... -run TestLearningCycle -v`): + +1. **Cold start** (seed model, 0 outcomes): a moderate-blast-radius review + scores **0.668**. +2. **After a batch refit** on 40 synthetic historical outcomes (odd-indexed + ones deliberately labeled "reverted" at moderate-to-high blast radius, + even-indexed ones "confirmed safe" at low blast radius): the same review + now scores **0.975** (holdout AUC **1.000** on this cleanly separable + synthetic set — the mechanism discriminates perfectly on data it's + supposed to). A held-out low-blast-radius review's score *decreased* + over the same refit (0.416 → 0.371), in the opposite direction, as + expected. +3. **After one more real-time "revert" report** (no batch job, one + gradient step via `ApplyOutcome`): the same review's score moves again, + to **0.999**. + +Total movement, entirely driven by reported outcomes and zero manual weight +edits: **+0.331**. This is the direct answer to "does the ML feedback +actually do anything": yes, measurably, in the expected direction, and the +test asserts the direction (not just that nothing crashed) so a future +regression in `OnlineStep`, `Refit`, or the anchor math would fail this test. + +**Why, exactly** — the test now also logs the full before/after weight +vector around the batch refit (`logWeightDeltas` in +`learning_cycle_test.go`), not just the two probe scores: + +| Weight | Before | After | Change | +|---|---|---|---| +| `br_sum` | 0.0200 | 0.0536 | +0.0336 | +| `br_max` | 0.0100 | 0.0533 | +0.0433 | +| `br_weighted_avg` | 0.0100 | 0.0533 | +0.0433 | +| `critical_count` | 0.9000 | 0.0273 | −0.8727 | + +The three blast-radius weights moved up because blast radius was the one +and only pattern the 40 synthetic outcomes varied. `critical_count`'s +weight collapsed toward near-zero *not* because the model decided critical +findings don't matter — it's because none of the 40 synthetic examples had +one, so the fresh batch fit has zero evidence to estimate that weight from, +and the prior anchor at this sample count (`AnchorStrength(40) ≈ 0.024`, +i.e. a 97.6%-fitted/2.4%-prior blend) is too weak to fully protect an +untouched feature. In a real org, outcomes would come from many different +kinds of problems, not one — this specific, large drop is a property of +this test's deliberately narrow, single-pattern synthetic data, not a +general claim about how a real refit behaves. It's a useful thing to know +when reading a real weight-change log and seeing an untouched category's +weight move: check whether the training batch actually had any examples +that varied that feature at all. + +### Is the threshold a fixed number? + +No — and this is worth stating precisely, because an earlier version of this +documentation didn't and it caused real confusion. There are two separate +things with the same-sounding name: + +- The **setting** (`risk_threshold_percentile`, e.g. `20` for "P20") is a + plain config value an org admin picks. It stays fixed until someone + changes it. **Today's default is P20.** +- The **cutoff** (an actual score, e.g. `0.518`) is what that setting + resolves to, recalculated from the org's own recent score distribution + every time (`PercentileCutoff` in `internal/riskscore/calibration.go`). + It moves on its own as the org's review history changes, with zero admin + action. + +`internal/riskscore/threshold_experiment_test.go`'s +`TestThresholdExperimentSamePercentileDifferentCutoff` proves this +directly: the same `P20` setting, run against two different synthetic score +histories, produces two different cutoffs — `0.220` for a "quiet" org whose +recent scores sit mostly in the 0.20-0.38 range, `0.540` for a "busy" org +whose recent scores sit mostly in 0.50-0.86. Same setting, different number. + +### What does P10/P20/P50/P80 actually do to the approval rate? + +`TestThresholdExperimentApprovalRatesByTier` (same file) scores a synthetic, +realistic mix of 60 reviews (mostly small and clean, a smaller medium-risk +group, a few clearly risky ones) and computes the resulting cutoff and +approval rate at each tier: + +| Setting | Cutoff | Approval rate on this mix | +|---|---|---| +| P0 (the strictest possible setting) | 0.423 | 0% | +| P10 (strict) | 0.494 | 8% | +| P20 (today's default) | 0.518 | 18% | +| P50 (relaxed) | 0.572 | 48% | +| P80 (very relaxed) | 0.944 | 78% | +| P100 (the most relaxed possible setting) | 1.000 | 98% | + +The concrete, named examples matter more than the percentages: at `P80`, a +review with **one critical security finding** (score `0.859`) gets +*approved* in this mix, because the cutoff itself has risen to `0.944` — a +high enough percentile setting, against a sufficiently risky recent history, +can let a real critical finding through. This is the literal, demonstrated +reason the shipped default is `P20` rather than something looser; it is not +a hypothetical concern, it is reproducible with `go test +./internal/riskscore/... -run TestThresholdExperiment -v`. + +**The two edges, stated precisely, and proved by the same test:** +`PercentileCutoff` clamps `p<=0` to `1`, so `P0` and `P1` behave +identically — their cutoff is the mix's own single lowest score, and +nothing in a set can score strictly below that set's own lowest score, so +**P0 approves nothing, not even the safest review in the mix**. `P100`'s +cutoff is the mix's own single highest score, and that one review can never +score strictly below its own value, so **P100 approves every review except +the single riskiest one** — "P100 allows everything" is almost right, but +not quite. Both edges are asserted in +`TestThresholdExperimentApprovalRatesByTier`, not just described. + +**Every one of the 46 scenario-corpus cases, at every one of these six +settings** — not just the 7 named examples above — is a sortable table in +`docs/risk-policy-verification-report.html`'s Thresholds tab, built from the +same `scenarioDump.tiers` data `TestScenarios` now logs per case +(`internal/policyengine/scenario_test.go`), measured against this exact +same reference mix (`internal/riskscore/referencemix`, imported by both +test packages so the numbers can never drift apart). + +### A real gotcha this verification pass found + +`storagereviews.TaxonomyReportStore.ListFindings` (which `canonicaldoc.Build` +uses to populate the risk model's finding-count features) silently excludes +any finding without both a `file_path` and a parseable `line` number — by +design, as a filter for "only rows that look like persisted line findings." +In practice every real review finding carries both, so this doesn't affect +production scoring — but it means a finding missing location data +contributes **zero** to every severity/category feature, not a fallback +value. Worth knowing if a future prompt or parser change ever produces a +finding without a file/line. + +## How do we know this works + +Three kinds of evidence, in increasing order of how close to production +they are: + +1. **The scenario corpus** — `internal/policyengine/testdata/scenarios.json`, + 46 hand-authored "common sense" cases (docs-only edits, huge risky + refactors, every eligibility/LLM/static-gate/mode-override/threshold/cap + boundary) run against the *real* `engine.RunWithPolicy` dry-run path (the + same one the Settings UI's simulate panel uses) — not a parallel + reimplementation. + ``` + go test ./internal/policyengine/... -run TestScenarios -v + ``` +2. **Formal-ish property and unit tests** — `internal/riskscore/riskscore_test.go` + and `refit_test.go`: category-override precedence, a randomized + monotonicity sweep over the seed prior (increasing any single risk + signal must never lower the score), the anchor-strength decay curve, the + batch refit's promotion gate, and the real-time online nudge, all against + a real Postgres. + ``` + go test ./internal/riskscore/... -v + go test ./internal/api/... -run TestSimulateScoresRealReviewsWithNoSideEffects -v + ``` +3. **The learning-cycle test** — the single most direct "proof" artifact; + see the numbers quoted above. + ``` + go test ./internal/riskscore/... -run TestLearningCycle -v + ``` +4. **The threshold experiment** — `internal/riskscore/threshold_experiment_test.go`, + answers "what does the percentile setting do" and "is it static" with + computed numbers rather than a toy example; see the two sections above. + ``` + go test ./internal/riskscore/... -run TestThresholdExperiment -v + ``` + +An interactive version of everything above — a sortable/filterable table of +all 46 scenarios with the full per-feature arithmetic, the threshold +experiment's tables, and a live score calculator — is published as +`docs/risk-policy-verification-report.html`. Open it directly in a browser; +it has no server dependency. + +All three write real rows to whatever Postgres `DATABASE_URL` points at +(local dev or the live instance) and clean up everything they write via +`t.Cleanup`, verified by running the full suite twice in a row and +confirming row counts return to zero — so it's safe to rerun any of them, +repeatedly, against a live environment. + +**What this does *not* prove**: these are all synthetic/deterministic tests +of the *mechanism* — that the math is correct, the gates fire in the right +order, and the learning updates move in the right direction on data +engineered to have a known answer. They do not, and cannot, prove the model +is *accurate* on real, messy production data — that evidence only comes +from the metrics dashboard (§11 of `docs/risk-policy-engine.md`) once real +`review_outcomes` accumulate, which will read near-empty until an org +actually starts reporting reverts/incidents. Mechanism-correctness and +real-world-accuracy are different claims; this document is evidence for the +first, not the second. diff --git a/docs/risk-policy-verification-report.html b/docs/risk-policy-verification-report.html new file mode 100644 index 000000000..14fbf91d3 --- /dev/null +++ b/docs/risk-policy-verification-report.html @@ -0,0 +1,1503 @@ + + + + + +Risk-Based Auto-Approve: Verification Report + + + + +
+ +
+

Risk-Based Auto-Approve: Verification Report

+

+ 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. +

+
+
+
+ + + +
+ + +
+
+

The short answer to the most common questions

+ + + + + + +
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.
+
+ +
+

Fact #4, explained: why a clean PR can score 0.42

+

This needs its own fact because it caused confusion. Here is the full explanation in order.

+
Step 1

A score has no fixed meaning by itself. 0.42 only means something next to a cutoff.

+
Step 2

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.

+
Step 3

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.

+
Step 4

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.

+
+
+ + +
+
+

How the test data is written

+

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.

+

How a test case is run

+

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.

+
+ +
+
+

Why the database stays clean

+

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.

+ + + + + + + + + +
TableRows left over
orgs, users, reviews0
blast_radius_hunks0
risk_feature_snapshots0
review_outcomes0
review_policy_log0
risk_model_versions0
ci_rulesets0
+

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.

+
+
+

Commands to run the tests yourself

+

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
+
+
+ +
+

Keeping this page honest after the model changes

+

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.

+
+ +
+

What this test work does not cover

+
    +
  • No new features were added to the product. This work only tests and explains code that was already built.
  • +
  • "Force approve" (a human overriding a block) is a separate function, reached through a different code + path. It is not covered by these 46 test cases.
  • +
  • These tests use made-up data with a known, clean pattern. They prove the mechanism is correct. + They cannot prove the model will be accurate on messy, real-world data. Only real production use, tracked + on the metrics dashboard, can show that.
  • +
+
+
+ + +
+
+

Four groups of tests

+

Each group checks a different part of the system. Click a bucket chip below to see its test cases in the + Results tab.

+
+
+ +
+
+

Group 1 — 46 example PRs (the scenario corpus)

+
+
What it is. 46 made-up pull requests, covering common and edge cases. Each one + has a known correct answer: approve, or block, and why.
+
Why it matters. Without this, there is no way to know if the code behaves + sensibly on everyday cases, not just on cases a developer happened to think of while writing the code.
+
What you do with it. If you change the model's weights or add a new rule, run + this test. If a case that used to pass now fails, you know exactly which real-world situation you just + broke.
+
+
+
+

Group 2 — math checks on the model itself

+
+
What it is. Small tests on the scoring formula, separate from any PR example. + For instance: "if an admin sets a category's weight higher, does the score for that category actually + go up?" and "does a brand-new org's model behave exactly like the hand-set starting point, with zero + learning applied yet?"
+
Why it matters. These catch a broken formula even before it shows up as a wrong + answer on a specific PR. A formula bug can be subtle; a one-line math check catches it directly.
+
What you do with it. If you ever touch 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."
+
+
+
+ +
+
+

Group 3 — does learning actually change anything

+
+
What it is. Tests that feed made-up "this PR was reverted" or "this PR was + confirmed safe" reports into the model, then check the model's weights really changed, in the direction + those reports should push them.
+
Why it matters. A learning feature that silently does nothing is worse than no + learning feature: it would give false confidence that the system is adapting, when it is not.
+
What you do with it. This is the proof that reporting a revert or an incident in + the real app is not a dead button. See the Explain tab for the exact numbers.
+
+
+
+

Group 4 — the "preview" feature never has side effects

+
+
What it is. A test of the "Simulate" button in Settings — the one that shows + "what would this policy have done to my last 20 reviews." It checks the preview never writes any real + data.
+
Why it matters. If "Simulate" quietly wrote real decision logs while you were just + trying out a setting, your real history would get polluted with fake trial runs.
+
What you do with it. You can trust the Simulate button to be read-only. Nothing + you preview there can affect a real PR or a real training run.
+
+
+
+
+ + +
+
+

The model in one sentence, before you read any numbers

+

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.

+
+ +
+

Signal reference — every input to the formula, and its allowed range

+

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.

+ + + + + + + + + + + +
SignalWhat it measuresAllowed rangeStarting weightStarting category dial
br_sumTotal blast-radius score, added up across every changed section ("hunk") in the diff0 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_maxThe single highest-scoring hunk in the diff0–1000.010×2.00 (blast radius)
br_weighted_avgHunk scores averaged together, weighted by how many lines each hunk touched0–1000.010×2.00 (blast radius)
br_high_countNumber of hunks that scored 66 or higher (the "high" tier)0 and up, a whole number0.200×2.00 (blast radius)
critical_countNumber of critical-severity findings, of any category0 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_countNumber of warning-severity findings, of any category0 to 5000.180×1.00 (no dial)
files_changedNumber of distinct files touched0 and up, no hard limit — see the compression note below0.100×1.00 (diff size — no dial in the UI today)
lines_addedLines added across the whole diff0 and up, no hard limit — see the compression note below0.050×1.00 (diff size)
llm_class_riskThe AI's reading of the diff, turned into a numberExactly 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 between0.300×1.00 (no dial)
+ +

The 10 finding-taxonomy categories — one signal each, every one learned on

+

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).

+ + + + + + + + + + + + +
SignalTaxonomy categoryAllowed rangeStarting weightStarting category dial
security_countSecurity0 to 5000.550×1.50
reliability_countReliability0 to 5000.300×1.25
correctness_countCorrectness0 to 5000.300×1.25
performance_countPerformance0 to 5000.200×1.10
scalability_countScalability0 to 5000.120×1.00
compliance_governance_countCompliance & Governance0 to 5000.120×1.00
maintainability_countMaintainability0 to 5000.100×1.00
architecture_countArchitecture0 to 5000.100×1.00
developer_experience_countDeveloper Experience0 to 5000.100×1.00
cost_countCost0 to 5000.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.

+
+ +
+
+

The 46 test cases, with the full math for each one

+ +
+

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
+ +
+
+ + +
+
+

What the threshold setting actually does

+

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.

+
+ +
+

Six settings, side by side, on the same 60 reviews

+

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.

+ + +
SettingPlain meaningCutoff 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.
  • +
+ +

Every one of the 46 test cases, at every setting

+

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.

+
+ +
+

Is the threshold a fixed number? No — here is the proof

+

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.

+ + + + +
OrgRecent review scores (made up, for this test)SettingResulting cutoff
Quiet orgmostly 0.20–0.38P20…
Busy orgmostly 0.50–0.86P20…
+

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."

+
+
+ + +
+
+
+ + + +
+ + +
+
+

The system is a chain of checks, not one black-box decision

+
What it is. A pull request passes through four checks, in order: is the author + senior enough, does it break a written rule (such as "no secrets in code"), does a risk score say it is + safe, and does an AI reading of the diff agree it is safe. Any one check can block the PR.
+
Why it matters. If one part misbehaves, the other three still protect you. There + is no single point where one bad number approves something dangerous.
+
What you do with it. When someone asks "how do we know this is safe," you can + answer "no single step decides alone," and point at this report's Results tab for a specific example.
+
+ + + + Author level + + Written rules + + Risk score + + AI reading + + Approved + + + + + + Blocked — sent back for a normal human review + +
+

Two human safety nets, always available

+
What it is. A person with a high enough level can approve a blocked PR by hand, and must give a reason. A short delay can be set so a human has time to object right after an auto-approval, before it is treated as final.
+
Why it matters. The system never removes a person's ability to step in.
+
What you do with it. Watch how often "force approve" is used. If it happens a lot, the automatic settings are probably too strict, and that is a signal to review the Thresholds tab.
+
+
+ + +
+
+

Where each setting lives

+
What it is. Four checks, four places to configure them, all under Settings → Auto Approve Policy.
+ + + + + + +
CheckWhat it looks atWhere to change it
Author levelIs the PR author senior enough, or on a trusted listEligibility tab
Written rulesRules your team wrote, such as "block if a secret is found"Static Rules tab
Risk scoreA number from 0 to 1, compared to the thresholdRisk Model tab (weights), General tab (threshold)
AI readingDoes the review AI's own classification of the diff agree it is safeGeneral tab
+
What you do with it. If auto-approve is blocking too much, start with the Thresholds tab in this report before changing any setting — it shows what each number actually does.
+
+ +
+

How the model updates itself

+
What it is. Two paths. First: the moment someone reports "this PR caused an + incident," the model takes one small learning step, that same day. Second: once a night, a job re-builds + the model from scratch using every report so far, and only replaces the live model if the new one is at + least as good.
+
Why it matters. The first path means feedback matters immediately. The second path + is a safety check: it stops a string of small, possibly-noisy daily updates from slowly making the model + worse without anyone noticing.
+
What you do with it. Watch the "model confidence" indicator in Settings. It says + how many real reports the model has learned from. Zero reports means the model is still running on + hand-set starting values, not on your team's own history.
+
+ + + + + + + + + PR approvedSomeone reports it + Same-day updateNightly re-build + + later reverted + "this caused an incident" + one small step, + applied right away + all reports so far + rebuilt from scratch, + kept only if not worse + +
+ + +
+
+

The formula

+
What it is. One formula, computed for every review:
+
z = bias + sum over every signal of (weight × category_multiplier × value)
+score = sigmoid(z)        // squeezes z into the range 0 to 1
+
Why it matters. This is the entire model. There is no hidden step, no large + language model making the final call. It is one weighted sum, run through one standard squeeze function.
+
What you do with it. To debug a surprising score, open the Results tab, click + the row, and read the math table. It lists every signal, its value, its weight, its category multiplier, + and what it added to z. The row with the biggest number is the reason for the + score.
+
Where in the code: internal/riskscore/model.go + (Score, Prior), internal/riskscore/features.go + (the full signal list, called FeatureSpecs).
+
+ +
+

Panel 1 — a PR's own signals

+

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.

+
+ + + +
+
+
+ +
+

Panel 2 — your org's weight dials

+

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.

+
+
+ +
+

Result

+
+
Score (0 = looks safe, 1 = looks risky)
+
0.00
+
+
—
+
+
+
+ +
+

Three words this section needs, defined first

+
Outcome report. A short record of what happened to a PR after it was merged: + it was later reverted, it caused an incident, or it was confirmed safe. This is the raw material the + model learns from — in the real app, a person (or a webhook) creates one by clicking "mark as + reverted" or similar.
+
Weight. One number inside the formula from the top of this tab. Each signal has + its own weight. A bigger weight means that signal moves the score more. Learning, in this system, means + "some weights change" — nothing else about the formula changes.
+
Held-out. Kept out of the training data on purpose. A held-out review is never + shown to the model as an example to learn from — it is only used afterward, to ask "what do you think + of this one now," so we can watch its score change honestly, step by step.
+
+ +
+

The learning loop, step by step

+
What it is. A test that starts a model at its hand-set defaults, then feeds it + 40 made-up outcome reports. Every report in this batch follows one single, simple pattern: a report + saying "this PR was later reverted" always has a high blast-radius value; a report saying "this PR was + confirmed safe" always has a low blast-radius value. The test then checks what actually changed inside + the model.
+
+
+ + + + + +
StepWhat happenedScore of one held-out, medium-blast-radius review
0Starting point. No outcome reports yet.…
1After … made-up outcome reports, all following the blast-radius pattern above.…
2After 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:

+ + +
WeightBeforeAfterChange
+

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.

+ +
+

A real limitation this work found

+
What it is. The finding-counting code only counts a finding if it has both a file + path and a line number. A finding with neither is silently worth zero to every signal that counts + findings.
+
Why it matters. In practice, every real finding has both. But if a future prompt + or parser change ever produces a finding without them, that finding would vanish from the risk score + with no error and no warning.
+
What you do with it. If a review's score looks lower than its finding list + suggests, check whether every finding has a file path and a line number.
+
Where in the code: storage/reviews/taxonomy_report_store.go, buildWhereClause.
+
+ +
+

How we know this is correct, and what we still do not know

+
What it is. Three kinds of checks: 46 example PRs run through the real decision + code, small math checks on the formula itself, and the learning-loop test above.
+
What this proves. The formula is implemented correctly, and the learning + mechanism really does change the model, in the direction the data points to.
+
What this does not prove. Whether the model will be accurate on real, messy, + production pull requests. All the data above is made up on purpose, with a clean, known pattern. Real + accuracy can only be measured from the metrics dashboard, once real reports come in.
+
+
+
+
+ +
+
+ +
+ Amazon-style narrative memo +

The Robot Checks First

+

A Risk-Based Auto-Approve Policy for Code Review

+

Summary

+

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.

+
+ +
+

Working Backwards

+

Headline

+

LiveReview Ships Risk-Based Auto-Approval, Returning Reviewer Hours Without Lowering the Bar on + Risky Changes

+ +

The customer problem

+

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.

+ +

The solution

+

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.

+ +

Customer quote

+
"We stopped spending review time on changes that were never going to be rejected, and + started actually noticing which ones deserved a second look," said an engineering lead at a mid-sized SaaS + company piloting the feature.
+ +

Call to action

+

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.

+
+ +
+

Why Now

+

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.

+
+ +
+

Why We Believe This Works: The RADAR Precedent

+

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.

+ +
+

author too junior,
not whitelisted

ok

a configured rule
tripped, e.g. a secret

ok

score above the
org's own cutoff

ok

low confidence, or a
disqualifying class

ok

Pull request

Eligibility

Human review

Static rules

Human review

Risk score

Human review

LLM classification

Human review

Auto-approved

+
The decision funnel. Any stage can send a change to human review; only agreement across all of them reaches auto-approval.
+
+ +

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.

+
+ +
+

How the Robot Decides

+

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.

+ +
+ Live modelPeriodic batch refitReal-time nudgeOutcome reportProductionPull request, auto-approvedLive modelPeriodic batch refitReal-time nudgeOutcome reportProductionPull request, auto-approvedmergedlater reverted, caused an incident, or confirmed safeone small, immediate weight stepapplied the same dayretrains from the full historypromoted only if it performs at least as well as today's model +
The feedback loop. A real-time nudge reacts the same day; a periodic batch refit corrects drift and must clear a promotion gate before going live.
+
+
+ +
+

The Evidence

+

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 measuredResult
Scenario coverage46 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 startScored a risky held-out review at 0.668
Same review, after a batch refit on real outcomes0.975 (held-out AUC: 1.000)
Same review, after one more reported outcome0.999
A genuinely safer held-out review, same sequenceMoved 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.

+
+ +
+

What This Is Worth to the Customer

+

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.

+
+ +
+

Risks and What We're Watching

+

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.

+
+ +
+

Appendix: Frequently Asked Questions

+

Customer questions

+
Does this mean less code review?

No. It means the + review that happens is concentrated on changes that carry actual risk, instead of spread evenly across + everything regardless of risk.

+
What happens on day one, with no history?

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.

+
Can I turn this off for one repository but keep it on elsewhere?
+

Yes. Mode and threshold are both configurable per repository, and a repository without its own + setting simply inherits the organization's default.

+
What stops the robot from approving something dangerous?
+

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.

+ +

Internal and hard questions

+
How do we know the 46-scenario corpus isn't cherry-picked?
+

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.

+
What is the actual false-approve rate today, and is it acceptable?
+

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.

+
Why logistic regression instead of a more powerful model?
+

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.

+
What is the plan if an org's override rate starts climbing?
+

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.

+
+ +
+
+ +
+ +
+ Built from internal/policyengine/testdata/scenarios.json, + internal/riskscore/*_test.go, internal/policyengine/*_test.go. + Architecture: docs/risk-policy-engine.md. Markdown companion: + docs/risk-policy-explainability.md. +
+ + + + + diff --git a/go.mod b/go.mod index 34888b769..3300291b7 100644 --- a/go.mod +++ b/go.mod @@ -69,6 +69,8 @@ require ( google.golang.org/api v0.272.0 ) +require github.com/cdipaolo/goml v0.0.0-20220715001353-00e0c845ae1c // indirect + require ( cel.dev/expr v0.25.2 // indirect cloud.google.com/go v0.123.0 // indirect diff --git a/go.sum b/go.sum index ae5e3ecf2..7fc425f84 100644 --- a/go.sum +++ b/go.sum @@ -162,6 +162,8 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cdipaolo/goml v0.0.0-20220715001353-00e0c845ae1c h1:uqJXOhayPfl/QruVBP6VF0KUWNDzO/F14X8CPEkkFD8= +github.com/cdipaolo/goml v0.0.0-20220715001353-00e0c845ae1c/go.mod h1:Ue8jgVLdBDCtsh1laikvraXqXzKCyKiruCcCcaeNDFE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= diff --git a/internal/ai/gemini/batch.go b/internal/ai/gemini/batch.go index 13f8abd42..da94b74a0 100644 --- a/internal/ai/gemini/batch.go +++ b/internal/ai/gemini/batch.go @@ -160,6 +160,7 @@ func (p *GeminiProvider) ReviewCodeBatch( FileSummary: trimmedSummary, TechnicalSummaries: summaries, Comments: result.Comments, + DiffClassification: result.DiffClassification, Error: nil, }, nil } diff --git a/internal/ai/gemini/gemini.go b/internal/ai/gemini/gemini.go index 8da843b2c..070cbb6a2 100644 --- a/internal/ai/gemini/gemini.go +++ b/internal/ai/gemini/gemini.go @@ -351,8 +351,9 @@ func (p *GeminiProvider) parseResponse(response string, diffs []*models.CodeDiff } type GeminiResponse struct { - FileSummary string `json:"fileSummary"` - Comments []GeminiComment `json:"comments"` + FileSummary string `json:"fileSummary"` + Comments []GeminiComment `json:"comments"` + DiffClassification *models.DiffClassification `json:"diffClassification"` } // Try to parse the JSON @@ -444,8 +445,9 @@ func (p *GeminiProvider) parseResponse(response string, diffs []*models.CodeDiff // Create the result result := &models.ReviewResult{ - Summary: jsonResponse.FileSummary, // Store fileSummary temporarily in Summary field - Comments: []*models.ReviewComment{}, + Summary: jsonResponse.FileSummary, // Store fileSummary temporarily in Summary field + Comments: []*models.ReviewComment{}, + DiffClassification: jsonResponse.DiffClassification, } // Convert comments diff --git a/internal/ai/langchain/provider.go b/internal/ai/langchain/provider.go index 03c6b9a5a..ab16f0577 100644 --- a/internal/ai/langchain/provider.go +++ b/internal/ai/langchain/provider.go @@ -1339,6 +1339,7 @@ func (p *LangchainProvider) reviewCodeBatchFormatted(ctx context.Context, diffs FileSummary: legacySummary, TechnicalSummaries: result.TechnicalSummaries, Comments: result.Comments, + DiffClassification: result.DiffClassification, Error: nil, BatchID: batchId, }, nil @@ -1515,6 +1516,9 @@ func (p *LangchainProvider) parseResponse(response string, diffs []models.CodeDi FileSummaries []aiResponseFileSummary `json:"fileSummaries"` FileSummary string `json:"fileSummary"` // legacy fallback Comments []Comment `json:"comments"` + // DiffClassification is the diff-level semantic classification + // (docs/risk-policy-engine.md §5). + DiffClassification *models.DiffClassification `json:"diffClassification"` } // Try to extract JSON from the response @@ -1589,14 +1593,16 @@ func (p *LangchainProvider) parseResponse(response string, diffs []models.CodeDi } return &ParsedResult{ - TechnicalSummaries: summaries, - Comments: comments, + TechnicalSummaries: summaries, + Comments: comments, + DiffClassification: resp.DiffClassification, }, nil } type ParsedResult struct { - TechnicalSummaries []prompts.TechnicalSummary - Comments []*models.ReviewComment + TechnicalSummaries []prompts.TechnicalSummary + Comments []*models.ReviewComment + DiffClassification *models.DiffClassification } // fallbackParsedResult builds a minimal, user-visible result when the model response is empty/invalid diff --git a/internal/api/auto_approve_insights_handlers.go b/internal/api/auto_approve_insights_handlers.go new file mode 100644 index 000000000..d7182696c --- /dev/null +++ b/internal/api/auto_approve_insights_handlers.go @@ -0,0 +1,321 @@ +package api + +import ( + "context" + "database/sql" + "net/http" + "sort" + "time" + + "github.com/labstack/echo/v4" + "github.com/lib/pq" + "github.com/livereview/internal/api/auth" + "github.com/livereview/internal/policyengine" + "github.com/livereview/internal/riskscore" +) + +// AutoApproveInsightsHandler answers the three questions nothing else in the +// product answers today: "where do my org's real review scores land relative +// to the threshold," "how many real PRs is the policy approving/blocking/ +// getting overridden," and "when a decision's outcome was reported, how +// often was it wrong." See the "Auto Approve" Reports tab +// (ui/src/pages/Reports/AutoApproveInsightsReport.tsx) for the consumer. +type AutoApproveInsightsHandler struct { + db *sql.DB +} + +func NewAutoApproveInsightsHandler(db *sql.DB) *AutoApproveInsightsHandler { + return &AutoApproveInsightsHandler{db: db} +} + +type scoreHistogramBucket struct { + RangeStart float64 `json:"range_start"` + RangeEnd float64 `json:"range_end"` + Count int `json:"count"` +} + +type scoreDistributionDTO struct { + Buckets []scoreHistogramBucket `json:"buckets"` + Cutoff float64 `json:"cutoff"` + Percentile int `json:"percentile"` + TotalScored int `json:"total_scored"` +} + +type decisionTrendPoint struct { + Day string `json:"day"` + Approved int `json:"approved"` + Blocked int `json:"blocked"` +} + +type decisionsDTO struct { + Approved int `json:"approved"` + BlockedAtEligibility int `json:"blocked_at_eligibility"` + BlockedAtStatic int `json:"blocked_at_static"` + BlockedAtRisk int `json:"blocked_at_risk"` + BlockedAtLLM int `json:"blocked_at_llm"` + Overridden int `json:"overridden"` + TotalDecided int `json:"total_decided"` + OverrideRate float64 `json:"override_rate"` + Trend []decisionTrendPoint `json:"trend"` +} + +type calibrationCell struct { + Decision string `json:"decision"` // "approved" | "blocked" + OutcomeType string `json:"outcome_type"` // "revert" | "incident" | "confirmed_safe" + Count int `json:"count"` +} + +type calibrationDTO struct { + Cells []calibrationCell `json:"cells"` + TotalOutcomes int `json:"total_outcomes"` + FalseApproveRate *float64 `json:"false_approve_rate,omitempty"` + LowVolumeWarning bool `json:"low_volume_warning"` +} + +type autoApproveInsightsDTO struct { + WindowDays int `json:"window_days"` + ScoreDistribution scoreDistributionDTO `json:"score_distribution"` + Decisions decisionsDTO `json:"decisions"` + Calibration calibrationDTO `json:"calibration"` +} + +// lowVolumeThreshold is the minimum number of reported outcomes before the +// calibration rates are shown as meaningful rather than a low-volume warning. +const lowVolumeThreshold = 10 + +// Insights aggregates score distribution, decision counts, and outcome +// calibration for the org over a selectable window (default 30 days). +func (h *AutoApproveInsightsHandler) Insights(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + + windowDays := 30 + switch c.QueryParam("window") { + case "7": + windowDays = 7 + case "90": + windowDays = 90 + } + since := time.Now().AddDate(0, 0, -windowDays) + ctx := c.Request().Context() + + scoreDist, err := h.scoreDistribution(ctx, orgID, since) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "score distribution failed: "+err.Error()) + } + decisions, err := h.decisions(ctx, orgID, since) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "decisions failed: "+err.Error()) + } + calibration, err := h.calibration(ctx, orgID, since) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "calibration failed: "+err.Error()) + } + + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{ + "insights": autoApproveInsightsDTO{ + WindowDays: windowDays, + ScoreDistribution: *scoreDist, + Decisions: *decisions, + Calibration: *calibration, + }, + }) +} + +// scoreDistribution buckets every review scored in the window into 20 fixed +// [0,1] bins (the model's score is always sigmoid output, so the range is +// fixed regardless of org) and resolves the org's current cutoff so the UI +// can draw one reference line without recomputing it. +func (h *AutoApproveInsightsHandler) scoreDistribution(ctx context.Context, orgID int64, since time.Time) (*scoreDistributionDTO, error) { + rows, err := h.db.QueryContext(ctx, ` + SELECT score FROM risk_feature_snapshots WHERE org_id = $1 AND created_at >= $2`, orgID, since) + if err != nil { + return nil, err + } + defer rows.Close() + + const numBuckets = 20 + buckets := make([]scoreHistogramBucket, numBuckets) + for i := range buckets { + buckets[i] = scoreHistogramBucket{RangeStart: float64(i) / numBuckets, RangeEnd: float64(i+1) / numBuckets} + } + + var scores []float64 + for rows.Next() { + var s float64 + if err := rows.Scan(&s); err != nil { + return nil, err + } + scores = append(scores, s) + idx := int(s * numBuckets) + if idx >= numBuckets { + idx = numBuckets - 1 + } + if idx < 0 { + idx = 0 + } + buckets[idx].Count++ + } + if err := rows.Err(); err != nil { + return nil, err + } + + percentile := 20 + p, found, err := policyengine.ResolvePolicy(ctx, h.db, orgID, 0) + if err == nil && found { + percentile = p.RiskThresholdPercentile + } + cutoff := riskscore.PercentileCutoff(scores, percentile) + + return &scoreDistributionDTO{Buckets: buckets, Cutoff: cutoff, Percentile: percentile, TotalScored: len(scores)}, nil +} + +// decisions counts the current final verdict (review_policy_log is +// overwritten per review on every re-evaluation, so "current" is also +// "latest decided in the window") plus which earlier stage blocked it, and +// the override rate from review_policy_overrides. +func (h *AutoApproveInsightsHandler) decisions(ctx context.Context, orgID int64, since time.Time) (*decisionsDTO, error) { + out := &decisionsDTO{} + + finalRows, err := h.db.QueryContext(ctx, ` + SELECT review_id, verdict, created_at FROM review_policy_log + WHERE org_id = $1 AND stage = $2 AND created_at >= $3`, orgID, policyengine.StageFinal, since) + if err != nil { + return nil, err + } + defer finalRows.Close() + + type dayCounts struct{ approved, blocked int } + byDay := map[string]*dayCounts{} + blockedIDs := map[int64]bool{} + + for finalRows.Next() { + var reviewID int64 + var verdict string + var createdAt time.Time + if err := finalRows.Scan(&reviewID, &verdict, &createdAt); err != nil { + return nil, err + } + day := createdAt.Format("2006-01-02") + if byDay[day] == nil { + byDay[day] = &dayCounts{} + } + if verdict == policyengine.FinalApproved { + out.Approved++ + byDay[day].approved++ + } else { + out.TotalDecided++ + byDay[day].blocked++ + blockedIDs[reviewID] = true + } + } + if err := finalRows.Err(); err != nil { + return nil, err + } + out.TotalDecided += out.Approved + + // Per-stage block breakdown: a review can fail risk AND llm + // independently (neither gates the other), so these can overlap and are + // not forced mutually exclusive. + if len(blockedIDs) > 0 { + ids := make([]int64, 0, len(blockedIDs)) + for id := range blockedIDs { + ids = append(ids, id) + } + stageRows, err := h.db.QueryContext(ctx, ` + SELECT stage, count(*) FROM review_policy_log + WHERE org_id = $1 AND verdict = $2 AND stage != $3 AND review_id = ANY($4) + GROUP BY stage`, orgID, policyengine.VerdictFail, policyengine.StageFinal, pq.Array(ids)) + if err != nil { + return nil, err + } + defer stageRows.Close() + for stageRows.Next() { + var stage string + var count int + if err := stageRows.Scan(&stage, &count); err != nil { + return nil, err + } + switch stage { + case policyengine.StageEligibility: + out.BlockedAtEligibility = count + case policyengine.StageStatic: + out.BlockedAtStatic = count + case policyengine.StageRisk: + out.BlockedAtRisk = count + case policyengine.StageLLM: + out.BlockedAtLLM = count + } + } + if err := stageRows.Err(); err != nil { + return nil, err + } + } + + var overridden int + if err := h.db.QueryRowContext(ctx, ` + SELECT count(*) FROM review_policy_overrides WHERE org_id = $1 AND created_at >= $2`, orgID, since).Scan(&overridden); err != nil { + return nil, err + } + out.Overridden = overridden + if out.TotalDecided > 0 { + out.OverrideRate = float64(overridden) / float64(out.TotalDecided) + } + + for day, c := range byDay { + out.Trend = append(out.Trend, decisionTrendPoint{Day: day, Approved: c.approved, Blocked: c.blocked}) + } + sort.Slice(out.Trend, func(i, j int) bool { return out.Trend[i].Day < out.Trend[j].Day }) + + return out, nil +} + +// calibration cross-tabs reported real-world outcomes (excluding +// source='test', the same exclusion the training path already applies) +// against what the policy actually decided for that review. +func (h *AutoApproveInsightsHandler) calibration(ctx context.Context, orgID int64, since time.Time) (*calibrationDTO, error) { + rows, err := h.db.QueryContext(ctx, ` + SELECT rpl.verdict, ro.outcome_type, count(*) + FROM review_outcomes ro + JOIN review_policy_log rpl ON rpl.review_id = ro.review_id AND rpl.org_id = ro.org_id AND rpl.stage = $1 + WHERE ro.org_id = $2 AND ro.source != 'test' AND ro.created_at >= $3 + GROUP BY rpl.verdict, ro.outcome_type`, policyengine.StageFinal, orgID, since) + if err != nil { + return nil, err + } + defer rows.Close() + + out := &calibrationDTO{} + var approvedWithOutcome, approvedBad int + for rows.Next() { + var verdict, outcomeType string + var count int + if err := rows.Scan(&verdict, &outcomeType, &count); err != nil { + return nil, err + } + decision := "blocked" + if verdict == policyengine.FinalApproved { + decision = "approved" + } + out.Cells = append(out.Cells, calibrationCell{Decision: decision, OutcomeType: outcomeType, Count: count}) + out.TotalOutcomes += count + if decision == "approved" { + approvedWithOutcome += count + if outcomeType == "revert" || outcomeType == "incident" { + approvedBad += count + } + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + if approvedWithOutcome > 0 { + rate := float64(approvedBad) / float64(approvedWithOutcome) + out.FalseApproveRate = &rate + } + out.LowVolumeWarning = out.TotalOutcomes < lowVolumeThreshold + return out, nil +} diff --git a/internal/api/auto_approve_insights_handlers_test.go b/internal/api/auto_approve_insights_handlers_test.go new file mode 100644 index 000000000..5acc90dbe --- /dev/null +++ b/internal/api/auto_approve_insights_handlers_test.go @@ -0,0 +1,187 @@ +package api + +// Handler-level test for GET .../auto-approve-insights -- the "Auto Approve" +// Reports tab's data source. Confirms (a) the aggregation math is correct +// against hand-seeded review_policy_log/risk_feature_snapshots/ +// review_outcomes/review_policy_overrides rows, and (b) tenant isolation: an +// org can never see another org's counts, per CLAUDE.md's scoping rules. + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" +) + +func seedInsightsOrg(t *testing.T, db *sql.DB, name string) int64 { + var orgID int64 + if err := db.QueryRow(`INSERT INTO orgs (name) VALUES ($1) RETURNING id`, name).Scan(&orgID); err != nil { + t.Fatalf("insert org: %v", err) + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM review_outcomes WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM review_policy_overrides WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM review_policy_log WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM risk_feature_snapshots WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM reviews WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM orgs WHERE id = $1`, orgID) + }) + return orgID +} + +func seedInsightsReview(t *testing.T, db *sql.DB, orgID int64) int64 { + var reviewID int64 + if err := db.QueryRow(` + INSERT INTO reviews (repository, status, org_id, metadata) VALUES ('insights/test-repo', 'completed', $1, '{}') RETURNING id`, + orgID).Scan(&reviewID); err != nil { + t.Fatalf("insert review: %v", err) + } + return reviewID +} + +func TestAutoApproveInsightsAggregatesAndIsolatesByOrg(t *testing.T) { + db := simulateTestDB(t) + + orgA := seedInsightsOrg(t, db, "insights-handler-test-a") + orgB := seedInsightsOrg(t, db, "insights-handler-test-b") + + // Org A: 2 approved, 1 blocked at risk (also overridden), 1 blocked at + // eligibility. Plus one reported outcome on an approved review that + // turned out to be an incident -- the false-approve signal. + approved1 := seedInsightsReview(t, db, orgA) + approved2 := seedInsightsReview(t, db, orgA) + blockedAtRisk := seedInsightsReview(t, db, orgA) + blockedAtEligibility := seedInsightsReview(t, db, orgA) + + for _, r := range []struct { + reviewID int64 + stage string + verdict string + }{ + {approved1, "eligibility", "pass"}, {approved1, "static", "pass"}, {approved1, "risk", "pass"}, {approved1, "llm", "pass"}, {approved1, "final", "approved"}, + {approved2, "eligibility", "pass"}, {approved2, "static", "pass"}, {approved2, "risk", "pass"}, {approved2, "llm", "pass"}, {approved2, "final", "approved"}, + {blockedAtRisk, "eligibility", "pass"}, {blockedAtRisk, "static", "pass"}, {blockedAtRisk, "risk", "fail"}, {blockedAtRisk, "llm", "pass"}, {blockedAtRisk, "final", "blocked"}, + {blockedAtEligibility, "eligibility", "fail"}, {blockedAtEligibility, "static", "skip"}, {blockedAtEligibility, "risk", "skip"}, {blockedAtEligibility, "llm", "skip"}, {blockedAtEligibility, "final", "blocked"}, + } { + if _, err := db.Exec(` + INSERT INTO review_policy_log (review_id, org_id, stage, verdict, detail) VALUES ($1, $2, $3, $4, '{}')`, + r.reviewID, orgA, r.stage, r.verdict); err != nil { + t.Fatalf("seed policy log: %v", err) + } + } + + var actorUserID int64 + if err := db.QueryRow(` + INSERT INTO users (email, password_hash) VALUES ('insights-handler-test@example.com', 'x') RETURNING id`).Scan(&actorUserID); err != nil { + t.Fatalf("insert user: %v", err) + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM review_policy_overrides WHERE actor_user_id = $1`, actorUserID) + _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, actorUserID) + }) + + if _, err := db.Exec(` + INSERT INTO review_policy_overrides (review_id, org_id, actor_user_id, original_verdict, forced_verdict, reason) + VALUES ($1, $2, $3, 'blocked', 'approved', 'test override')`, blockedAtRisk, orgA, actorUserID); err != nil { + t.Fatalf("seed override: %v", err) + } + + if _, err := db.Exec(` + INSERT INTO review_outcomes (review_id, org_id, outcome_type, source, reason) VALUES ($1, $2, 'incident', 'manual', 'test outcome')`, + approved1, orgA); err != nil { + t.Fatalf("seed outcome: %v", err) + } + + for _, s := range []float64{0.1, 0.2, 0.9} { + if _, err := db.Exec(` + INSERT INTO risk_feature_snapshots (review_id, org_id, model_version, features, score) VALUES ($1, $2, 1, '{}', $3)`, + approved1, orgA, s); err != nil { + t.Fatalf("seed feature snapshot: %v", err) + } + } + + // Org B: one isolated approved review -- must never show up in org A's + // results, and org A's data must never show up when org B is queried. + orgBReview := seedInsightsReview(t, db, orgB) + if _, err := db.Exec(` + INSERT INTO review_policy_log (review_id, org_id, stage, verdict, detail) VALUES ($1, $2, 'final', 'approved', '{}')`, + orgBReview, orgB); err != nil { + t.Fatalf("seed org B policy log: %v", err) + } + + handler := NewAutoApproveInsightsHandler(db) + e := echo.New() + + getInsights := func(orgID int64) autoApproveInsightsDTO { + req := httptest.NewRequest(http.MethodGet, "/api/v1/auto-approve-insights?window=30", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.Set("org_id", orgID) + if err := handler.Insights(c); err != nil { + t.Fatalf("Insights: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var resp struct { + Insights autoApproveInsightsDTO `json:"insights"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return resp.Insights + } + + a := getInsights(orgA) + + if a.Decisions.Approved != 2 { + t.Errorf("org A approved: got %d, want 2", a.Decisions.Approved) + } + if a.Decisions.TotalDecided != 4 { + t.Errorf("org A total decided: got %d, want 4", a.Decisions.TotalDecided) + } + if a.Decisions.BlockedAtRisk != 1 { + t.Errorf("org A blocked at risk: got %d, want 1", a.Decisions.BlockedAtRisk) + } + if a.Decisions.BlockedAtEligibility != 1 { + t.Errorf("org A blocked at eligibility: got %d, want 1", a.Decisions.BlockedAtEligibility) + } + if a.Decisions.Overridden != 1 { + t.Errorf("org A overridden: got %d, want 1", a.Decisions.Overridden) + } + if a.Decisions.OverrideRate != 0.25 { + t.Errorf("org A override rate: got %v, want 0.25", a.Decisions.OverrideRate) + } + if a.ScoreDistribution.TotalScored != 3 { + t.Errorf("org A total scored: got %d, want 3", a.ScoreDistribution.TotalScored) + } + if a.Calibration.TotalOutcomes != 1 { + t.Errorf("org A total outcomes: got %d, want 1", a.Calibration.TotalOutcomes) + } + if a.Calibration.FalseApproveRate == nil || *a.Calibration.FalseApproveRate != 1.0 { + t.Errorf("org A false approve rate: got %v, want 1.0", a.Calibration.FalseApproveRate) + } + if !a.Calibration.LowVolumeWarning { + t.Errorf("org A calibration should warn on low volume (1 outcome < %d)", lowVolumeThreshold) + } + + b := getInsights(orgB) + if b.Decisions.Approved != 1 { + t.Errorf("org B approved: got %d, want 1 (tenant isolation leak?)", b.Decisions.Approved) + } + if b.Decisions.TotalDecided != 1 { + t.Errorf("org B total decided: got %d, want 1 (tenant isolation leak?)", b.Decisions.TotalDecided) + } + if b.Decisions.Overridden != 0 { + t.Errorf("org B overridden: got %d, want 0 -- org A's override leaked across tenants", b.Decisions.Overridden) + } + if b.ScoreDistribution.TotalScored != 0 { + t.Errorf("org B total scored: got %d, want 0 -- org A's snapshots leaked across tenants", b.ScoreDistribution.TotalScored) + } + if b.Calibration.TotalOutcomes != 0 { + t.Errorf("org B total outcomes: got %d, want 0 -- org A's outcomes leaked across tenants", b.Calibration.TotalOutcomes) + } +} diff --git a/internal/api/auto_approve_policy_handlers.go b/internal/api/auto_approve_policy_handlers.go new file mode 100644 index 000000000..788174d52 --- /dev/null +++ b/internal/api/auto_approve_policy_handlers.go @@ -0,0 +1,430 @@ +package api + +import ( + "context" + "database/sql" + "log" + "net/http" + "strconv" + + "github.com/labstack/echo/v4" + "github.com/lib/pq" + "github.com/livereview/internal/api/auth" + "github.com/livereview/internal/policyengine" +) + +// AutoApprovePoliciesHandler implements the auto-approve policy CRUD, +// resolution, and simulation API. See docs/risk-policy-engine.md §1/§8. +type AutoApprovePoliciesHandler struct { + db *sql.DB + engine *policyengine.Engine +} + +func NewAutoApprovePoliciesHandler(db *sql.DB) *AutoApprovePoliciesHandler { + return &AutoApprovePoliciesHandler{db: db, engine: policyengine.NewEngine(db)} +} + +// PolicyDTO is the persisted/returned shape of an auto_approve_policies row. +type PolicyDTO struct { + ID int64 `json:"id"` + OrgID int64 `json:"org_id"` + RepositoryID *int64 `json:"repository_id,omitempty"` + Mode string `json:"mode"` + MinAuthorLevel int `json:"min_author_level"` + EligibilityWhitelistUserIDs []int64 `json:"eligibility_whitelist_user_ids"` + RiskThresholdPercentile int `json:"risk_threshold_percentile"` + RequireLLMConfidence int `json:"require_llm_confidence"` + BlockingRulesetIDs []int64 `json:"blocking_ruleset_ids"` + LandingDelaySeconds int `json:"landing_delay_seconds"` + DailyApproveCap *int `json:"daily_approve_cap,omitempty"` + RestrictOutcomeReportingToAdmins bool `json:"restrict_outcome_reporting_to_admins"` + ForceApproveMinLevel int `json:"force_approve_min_level"` +} + +type policyUpsertRequest struct { + RepositoryID *int64 `json:"repository_id"` + Mode *string `json:"mode"` + MinAuthorLevel *int `json:"min_author_level"` + EligibilityWhitelistUserIDs []int64 `json:"eligibility_whitelist_user_ids"` + RiskThresholdPercentile *int `json:"risk_threshold_percentile"` + RequireLLMConfidence *int `json:"require_llm_confidence"` + BlockingRulesetIDs []int64 `json:"blocking_ruleset_ids"` + LandingDelaySeconds *int `json:"landing_delay_seconds"` + DailyApproveCap *int `json:"daily_approve_cap"` + RestrictOutcomeReportingToAdmins *bool `json:"restrict_outcome_reporting_to_admins"` + ForceApproveMinLevel *int `json:"force_approve_min_level"` +} + +func scanPolicy(row interface{ Scan(...interface{}) error }) (*PolicyDTO, error) { + var p PolicyDTO + var repoID sql.NullInt64 + err := row.Scan( + &p.ID, &p.OrgID, &repoID, &p.Mode, &p.MinAuthorLevel, + pq.Array(&p.EligibilityWhitelistUserIDs), &p.RiskThresholdPercentile, + &p.RequireLLMConfidence, pq.Array(&p.BlockingRulesetIDs), &p.LandingDelaySeconds, + &p.DailyApproveCap, &p.RestrictOutcomeReportingToAdmins, &p.ForceApproveMinLevel, + ) + if err != nil { + return nil, err + } + if repoID.Valid { + p.RepositoryID = &repoID.Int64 + } + return &p, nil +} + +const policyColumns = `id, org_id, repository_id, mode, min_author_level, + eligibility_whitelist_user_ids, risk_threshold_percentile, + require_llm_confidence, blocking_ruleset_ids, landing_delay_seconds, + daily_approve_cap, restrict_outcome_reporting_to_admins, force_approve_min_level` + +// List returns all policies for the org (org-default + repo-scoped). +func (h *AutoApprovePoliciesHandler) List(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + rows, err := h.db.QueryContext(c.Request().Context(), + `SELECT `+policyColumns+` FROM auto_approve_policies WHERE org_id = $1 ORDER BY repository_id NULLS FIRST`, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "list failed: "+err.Error()) + } + defer rows.Close() + + out := []*PolicyDTO{} + for rows.Next() { + p, err := scanPolicy(rows) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "scan failed: "+err.Error()) + } + out = append(out, p) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"rows": out}) +} + +// Get returns one policy by id. +func (h *AutoApprovePoliciesHandler) Get(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid policy id") + } + row := h.db.QueryRowContext(c.Request().Context(), + `SELECT `+policyColumns+` FROM auto_approve_policies WHERE id = $1 AND org_id = $2`, id, orgID) + p, err := scanPolicy(row) + if err == sql.ErrNoRows { + return JSONErrorWithEnvelope(c, http.StatusNotFound, "policy not found") + } + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "get failed: "+err.Error()) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"policy": p}) +} + +// Create inserts a new policy (org-default or repo-scoped). +func (h *AutoApprovePoliciesHandler) Create(c echo.Context) error { + pc := auth.MustGetPermissionContext(c) + if err := pc.RequireOrgOwner(); err != nil { + return JSONErrorWithEnvelope(c, http.StatusForbidden, err.Error()) + } + orgID := pc.GetOrgID() + + var body policyUpsertRequest + if err := c.Bind(&body); err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid body") + } + mode := "custom" + if body.Mode != nil && *body.Mode != "" { + mode = *body.Mode + } + + // A repo-scoped row that omits a field should inherit the org default for + // that field, not the bare schema default — otherwise picking "Customize" + // on a repo can silently loosen a stricter org-wide setting (e.g. eligibility). + defaults := policyUpsertRequest{ + MinAuthorLevel: policyIntPtr(1), + RiskThresholdPercentile: policyIntPtr(20), + RequireLLMConfidence: policyIntPtr(8), + LandingDelaySeconds: policyIntPtr(0), + ForceApproveMinLevel: policyIntPtr(3), + } + if body.RepositoryID != nil { + orgDefault, err := loadOrgDefaultPolicy(c.Request().Context(), h.db, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "failed to load org default policy") + } + if orgDefault != nil { + defaults = policyUpsertRequest{ + MinAuthorLevel: &orgDefault.MinAuthorLevel, + EligibilityWhitelistUserIDs: orgDefault.EligibilityWhitelistUserIDs, + RiskThresholdPercentile: &orgDefault.RiskThresholdPercentile, + RequireLLMConfidence: &orgDefault.RequireLLMConfidence, + BlockingRulesetIDs: orgDefault.BlockingRulesetIDs, + LandingDelaySeconds: &orgDefault.LandingDelaySeconds, + DailyApproveCap: orgDefault.DailyApproveCap, + RestrictOutcomeReportingToAdmins: &orgDefault.RestrictOutcomeReportingToAdmins, + ForceApproveMinLevel: &orgDefault.ForceApproveMinLevel, + } + } + } + if body.MinAuthorLevel == nil { + body.MinAuthorLevel = defaults.MinAuthorLevel + } + if body.EligibilityWhitelistUserIDs == nil { + body.EligibilityWhitelistUserIDs = defaults.EligibilityWhitelistUserIDs + } + if body.RiskThresholdPercentile == nil { + body.RiskThresholdPercentile = defaults.RiskThresholdPercentile + } + if body.RequireLLMConfidence == nil { + body.RequireLLMConfidence = defaults.RequireLLMConfidence + } + if body.BlockingRulesetIDs == nil { + body.BlockingRulesetIDs = defaults.BlockingRulesetIDs + } + if body.LandingDelaySeconds == nil { + body.LandingDelaySeconds = defaults.LandingDelaySeconds + } + if body.DailyApproveCap == nil { + body.DailyApproveCap = defaults.DailyApproveCap + } + if body.RestrictOutcomeReportingToAdmins == nil { + body.RestrictOutcomeReportingToAdmins = defaults.RestrictOutcomeReportingToAdmins + } + if body.ForceApproveMinLevel == nil { + body.ForceApproveMinLevel = defaults.ForceApproveMinLevel + } + + var id int64 + err := h.db.QueryRowContext(c.Request().Context(), ` + INSERT INTO auto_approve_policies (org_id, repository_id, mode, min_author_level, + eligibility_whitelist_user_ids, risk_threshold_percentile, require_llm_confidence, + blocking_ruleset_ids, landing_delay_seconds, daily_approve_cap, + restrict_outcome_reporting_to_admins, force_approve_min_level, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`, + orgID, body.RepositoryID, mode, derefInt(body.MinAuthorLevel, 1), + pq.Array(body.EligibilityWhitelistUserIDs), derefInt(body.RiskThresholdPercentile, 20), + derefInt(body.RequireLLMConfidence, 8), pq.Array(body.BlockingRulesetIDs), + derefInt(body.LandingDelaySeconds, 0), body.DailyApproveCap, + derefBool(body.RestrictOutcomeReportingToAdmins, false), derefInt(body.ForceApproveMinLevel, 3), + pc.GetUserID(), + ).Scan(&id) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "create failed: "+err.Error()) + } + + row := h.db.QueryRowContext(c.Request().Context(), + `SELECT `+policyColumns+` FROM auto_approve_policies WHERE id = $1 AND org_id = $2`, id, orgID) + p, err := scanPolicy(row) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "created but failed to reload") + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"policy": p}) +} + +// Update modifies a policy. +func (h *AutoApprovePoliciesHandler) Update(c echo.Context) error { + pc := auth.MustGetPermissionContext(c) + if err := pc.RequireOrgOwner(); err != nil { + return JSONErrorWithEnvelope(c, http.StatusForbidden, err.Error()) + } + orgID := pc.GetOrgID() + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid policy id") + } + + var body policyUpsertRequest + if err := c.Bind(&body); err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid body") + } + + _, err = h.db.ExecContext(c.Request().Context(), ` + UPDATE auto_approve_policies SET + mode = COALESCE($1, mode), + min_author_level = COALESCE($2, min_author_level), + eligibility_whitelist_user_ids = $3, + risk_threshold_percentile = COALESCE($4, risk_threshold_percentile), + require_llm_confidence = COALESCE($5, require_llm_confidence), + blocking_ruleset_ids = $6, + landing_delay_seconds = COALESCE($7, landing_delay_seconds), + daily_approve_cap = $8, + restrict_outcome_reporting_to_admins = COALESCE($9, restrict_outcome_reporting_to_admins), + force_approve_min_level = COALESCE($10, force_approve_min_level), + updated_at = now() + WHERE id = $11 AND org_id = $12`, + body.Mode, body.MinAuthorLevel, + pq.Array(body.EligibilityWhitelistUserIDs), body.RiskThresholdPercentile, + body.RequireLLMConfidence, pq.Array(body.BlockingRulesetIDs), + body.LandingDelaySeconds, body.DailyApproveCap, + body.RestrictOutcomeReportingToAdmins, body.ForceApproveMinLevel, + id, orgID, + ) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "update failed: "+err.Error()) + } + + row := h.db.QueryRowContext(c.Request().Context(), + `SELECT `+policyColumns+` FROM auto_approve_policies WHERE id = $1 AND org_id = $2`, id, orgID) + p, err := scanPolicy(row) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusNotFound, "policy not found") + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"policy": p}) +} + +// Delete removes a policy. +func (h *AutoApprovePoliciesHandler) Delete(c echo.Context) error { + pc := auth.MustGetPermissionContext(c) + if err := pc.RequireOrgOwner(); err != nil { + return JSONErrorWithEnvelope(c, http.StatusForbidden, err.Error()) + } + orgID := pc.GetOrgID() + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid policy id") + } + res, err := h.db.ExecContext(c.Request().Context(), `DELETE FROM auto_approve_policies WHERE id = $1 AND org_id = $2`, id, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "delete failed: "+err.Error()) + } + if n, _ := res.RowsAffected(); n == 0 { + return JSONErrorWithEnvelope(c, http.StatusNotFound, "policy not found") + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"status": "deleted"}) +} + +// Resolved returns the effective policy for a repository (repo-scoped or org +// default), used by the repository settings UI to show "org default" context. +func (h *AutoApprovePoliciesHandler) Resolved(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + var repoID int64 + if v := c.QueryParam("repository_id"); v != "" { + repoID, _ = strconv.ParseInt(v, 10, 64) + } + p, found, err := policyengine.ResolvePolicy(c.Request().Context(), h.db, orgID, repoID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "resolve failed: "+err.Error()) + } + if !found { + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"policy": nil, "configured": false}) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"policy": p, "configured": true}) +} + +// Simulate replays a draft policy against the org's recent completed reviews, +// discarding all results (dry-run). See docs/risk-policy-engine.md §8. +func (h *AutoApprovePoliciesHandler) Simulate(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + var body struct { + RepositoryID *int64 `json:"repository_id"` + Policy policyUpsertRequest `json:"policy"` + ReviewIDs []int64 `json:"review_ids"` + Limit int `json:"limit"` + } + if err := c.Bind(&body); err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid body") + } + if body.Limit <= 0 || body.Limit > 100 { + body.Limit = 20 + } + + draft := &policyengine.Policy{ + OrgID: orgID, + RepositoryID: body.RepositoryID, + Mode: "custom", + MinAuthorLevel: derefInt(body.Policy.MinAuthorLevel, 1), + EligibilityWhitelistUserIDs: body.Policy.EligibilityWhitelistUserIDs, + RiskThresholdPercentile: derefInt(body.Policy.RiskThresholdPercentile, 20), + RequireLLMConfidence: derefInt(body.Policy.RequireLLMConfidence, 8), + BlockingRulesetIDs: body.Policy.BlockingRulesetIDs, + LandingDelaySeconds: derefInt(body.Policy.LandingDelaySeconds, 0), + DailyApproveCap: body.Policy.DailyApproveCap, + RestrictOutcomeReportingToAdmins: derefBool(body.Policy.RestrictOutcomeReportingToAdmins, false), + ForceApproveMinLevel: derefInt(body.Policy.ForceApproveMinLevel, 3), + } + if body.Policy.Mode != nil { + draft.Mode = *body.Policy.Mode + } + + reviewIDs := body.ReviewIDs + if len(reviewIDs) == 0 { + rows, err := h.db.QueryContext(c.Request().Context(), ` + SELECT id FROM reviews + WHERE org_id = $1 AND status = 'completed' + ORDER BY created_at DESC LIMIT $2`, orgID, body.Limit) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "failed to load reviews: "+err.Error()) + } + defer rows.Close() + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "scan failed: "+err.Error()) + } + reviewIDs = append(reviewIDs, id) + } + } + + type simRow struct { + ReviewID int64 `json:"review_id"` + Approved bool `json:"would_approve"` + BlockedAt string `json:"blocked_at,omitempty"` + Score float64 `json:"score,omitempty"` + Stages []policyengine.StageResult `json:"stages"` + } + out := []simRow{} + for _, rid := range reviewIDs { + res, err := h.engine.RunWithPolicy(c.Request().Context(), orgID, rid, draft, true) + if err != nil { + log.Printf("auto-approve simulate: review %d skipped: %v", rid, err) + continue + } + row := simRow{ReviewID: rid, Approved: res.Approved, Score: res.Score, Stages: res.Stages} + for _, st := range res.Stages { + if st.Verdict == policyengine.VerdictFail { + row.BlockedAt = st.Stage + break + } + } + out = append(out, row) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"rows": out}) +} + +// loadOrgDefaultPolicy returns the org-default (repository_id IS NULL) policy +// row, or (nil, nil) if none is configured yet. +func loadOrgDefaultPolicy(ctx context.Context, db *sql.DB, orgID int64) (*PolicyDTO, error) { + row := db.QueryRowContext(ctx, + `SELECT `+policyColumns+` FROM auto_approve_policies WHERE org_id = $1 AND repository_id IS NULL`, orgID) + p, err := scanPolicy(row) + if err == sql.ErrNoRows { + return nil, nil + } + return p, err +} + +func policyIntPtr(v int) *int { return &v } + +func derefInt(p *int, d int) int { + if p == nil { + return d + } + return *p +} + +func derefBool(p *bool, d bool) bool { + if p == nil { + return d + } + return *p +} diff --git a/internal/api/auto_approve_policy_handlers_test.go b/internal/api/auto_approve_policy_handlers_test.go new file mode 100644 index 000000000..02816a14f --- /dev/null +++ b/internal/api/auto_approve_policy_handlers_test.go @@ -0,0 +1,144 @@ +package api + +// Handler-level test for POST .../auto-approve-policies/simulate (see +// docs/risk-policy-engine.md §8): the "what would've happened" panel the +// Settings UI's Risk Model sub-tab calls. Confirms (a) it scores real +// reviews through the real funnel and returns the expected per-review +// verdicts, and (b) its dry-run guarantee actually holds -- no +// review_policy_log or risk_feature_snapshots rows are written, since the UI +// depends on simulate being side-effect-free to safely preview an unsaved +// draft policy against production data. + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/labstack/echo/v4" + _ "github.com/lib/pq" +) + +func simulateTestDB(t *testing.T) *sql.DB { + if testing.Short() { + t.Skip("skipping database integration test") + } + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + dsn = "postgres://livereview:livereview_password_123@localhost:5432/livereview?sslmode=disable" + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Ping(); err != nil { + t.Skipf("db not reachable: %v", err) + } + return db +} + +func TestSimulateScoresRealReviewsWithNoSideEffects(t *testing.T) { + db := simulateTestDB(t) + + var orgID int64 + if err := db.QueryRow(`INSERT INTO orgs (name) VALUES ('simulate-handler-test') RETURNING id`).Scan(&orgID); err != nil { + t.Fatalf("insert org: %v", err) + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM dashboard_cache WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM risk_feature_snapshots WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM review_policy_log WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM reviews WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM orgs WHERE id = $1`, orgID) + }) + + safeMetadata := []byte(`{"review_result": {"comments": [], "diffClassification": {"class": "refactor-only", "confidence": 9}}}`) + // file_path/line are required: TaxonomyReportStore.ListFindings only + // counts rows that "look like persisted line findings" (see its + // buildWhereClause), so a comment without them is silently excluded. + riskyMetadata := []byte(`{"review_result": {"comments": [{"severity":"critical","confidence":"high","category":"security","type":"issue","file_path":"a.go","line":1}], "diffClassification": {"class": "logic-change", "confidence": 9}}}`) + + var safeID, riskyID int64 + if err := db.QueryRow(` + INSERT INTO reviews (repository, status, org_id, metadata) VALUES ('simulate/test-repo', 'completed', $1, $2) RETURNING id`, + orgID, safeMetadata).Scan(&safeID); err != nil { + t.Fatalf("insert safe review: %v", err) + } + if err := db.QueryRow(` + INSERT INTO reviews (repository, status, org_id, metadata) VALUES ('simulate/test-repo', 'completed', $1, $2) RETURNING id`, + orgID, riskyMetadata).Scan(&riskyID); err != nil { + t.Fatalf("insert risky review: %v", err) + } + + handler := NewAutoApprovePoliciesHandler(db) + + body, _ := json.Marshal(map[string]interface{}{ + "review_ids": []int64{safeID, riskyID}, + }) + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/api/v1/auto-approve-policies/simulate", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.Set("org_id", orgID) + + if err := handler.Simulate(c); err != nil { + t.Fatalf("Simulate: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + var resp struct { + Rows []struct { + ReviewID int64 `json:"review_id"` + WouldApprove bool `json:"would_approve"` + BlockedAt string `json:"blocked_at,omitempty"` + Score float64 `json:"score,omitempty"` + } `json:"rows"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response %s: %v", rec.Body.String(), err) + } + if len(resp.Rows) != 2 { + t.Fatalf("got %d rows, want 2 (response: %s)", len(resp.Rows), rec.Body.String()) + } + + byID := map[int64]bool{} + blockedAt := map[int64]string{} + for _, r := range resp.Rows { + byID[r.ReviewID] = r.WouldApprove + blockedAt[r.ReviewID] = r.BlockedAt + } + if !byID[safeID] { + t.Fatalf("safe review %d: would_approve = false, want true", safeID) + } + if byID[riskyID] { + t.Fatalf("risky review %d: would_approve = true, want false", riskyID) + } + if blockedAt[riskyID] != "risk" { + t.Fatalf("risky review %d: blocked_at = %q, want %q", riskyID, blockedAt[riskyID], "risk") + } + + // The dry-run guarantee: simulate must never write review_policy_log or + // risk_feature_snapshots rows, since the UI relies on being able to + // preview an unsaved draft policy against real production reviews + // without side effects. + var logCount, snapshotCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM review_policy_log WHERE org_id = $1`, orgID).Scan(&logCount); err != nil { + t.Fatalf("count review_policy_log: %v", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM risk_feature_snapshots WHERE org_id = $1`, orgID).Scan(&snapshotCount); err != nil { + t.Fatalf("count risk_feature_snapshots: %v", err) + } + if logCount != 0 { + t.Fatalf("simulate wrote %d review_policy_log rows, want 0 (dry-run must have no side effects)", logCount) + } + if snapshotCount != 0 { + t.Fatalf("simulate wrote %d risk_feature_snapshots rows, want 0 (dry-run must have no side effects)", snapshotCount) + } +} diff --git a/internal/api/ci_rulesets_handler.go b/internal/api/ci_rulesets_handler.go index f7ce58e4d..b0a9161da 100644 --- a/internal/api/ci_rulesets_handler.go +++ b/internal/api/ci_rulesets_handler.go @@ -3,7 +3,6 @@ package api import ( "context" "database/sql" - "encoding/json" "fmt" "net/http" "strconv" @@ -13,6 +12,7 @@ import ( "github.com/itchyny/gojq" "github.com/labstack/echo/v4" "github.com/livereview/internal/api/auth" + "github.com/livereview/internal/canonicaldoc" storagereviews "github.com/livereview/storage/reviews" ) @@ -54,157 +54,22 @@ type upsertCIRulesetRequest struct { // ---- canonical review document ---- -// canonicalFinding is one row of the "findings" array in the canonical doc. -type canonicalFinding struct { - Severity string `json:"severity"` - Confidence string `json:"confidence"` - Category string `json:"category"` - Subcategory string `json:"subcategory"` - Type string `json:"type"` - FilePath string `json:"file_path,omitempty"` - LineNumber *int `json:"line_number,omitempty"` -} - -// canonicalReviewDoc is the stable, provider-agnostic document every jq -// ruleset is evaluated against, regardless of whether the review was -// triggered by a webhook, `lrc`, or the web UI. -type canonicalReviewDoc struct { - ReviewID int64 `json:"review_id"` - OrgID int64 `json:"org_id"` - Repository string `json:"repository"` - Provider string `json:"provider"` - Status string `json:"status"` - Findings []canonicalFinding `json:"findings"` - Counts canonicalReviewDocCounts `json:"counts"` -} +// The canonical document types and jq evaluation are shared with the +// risk-policy engine's static gate stage via internal/canonicaldoc, so there +// is exactly one rule engine (see docs/risk-policy-engine.md §4). These +// aliases keep the existing handler API and its tests unchanged. +type canonicalFinding = canonicaldoc.Finding +type canonicalReviewDoc = canonicaldoc.ReviewDoc +type canonicalReviewDocCounts = canonicaldoc.Counts -type canonicalReviewDocCounts struct { - BySeverity map[string]int `json:"by_severity"` - ByCategory map[string]int `json:"by_category"` - Total int `json:"total"` -} - -// reviewGateHeader is the minimal review row needed to build the canonical -// doc's header fields (everything but findings). -type reviewGateHeader struct { - ID int64 - Repository string - Provider string - Status string -} - -// getReviewGateHeader fetches a review scoped strictly to orgID -- never -// trust a caller-supplied review_id without confirming org ownership first. -func (h *CIRulesetsHandler) getReviewGateHeader(ctx context.Context, orgID, reviewID int64) (*reviewGateHeader, error) { - var r reviewGateHeader - var provider sql.NullString - err := h.db.QueryRowContext(ctx, ` - SELECT id, repository, provider, status - FROM reviews - WHERE id = $1 AND org_id = $2`, reviewID, orgID).Scan(&r.ID, &r.Repository, &provider, &r.Status) - if err != nil { - return nil, err - } - r.Provider = provider.String - return &r, nil -} - -// knownSeverities seeds counts.by_severity with zero values so jq -// expressions like `.counts.by_severity.critical > 0` never fail with "null -// cannot be compared" on a clean review -- they just see 0. This is the -// actual 3-value severity model the AI review prompt emits (see -// internal/prompts/templates.go's "info|warning|critical" schema) -- not a -// generic 5-level guess. -var knownSeverities = []string{"critical", "warning", "info"} +var knownSeverities = canonicaldoc.KnownSeverities +var runJQBool = canonicaldoc.RunJQBool +// buildCanonicalReviewDoc delegates to internal/canonicaldoc.Build, scoping the +// review strictly to orgID -- never trust a caller-supplied review_id without +// confirming org ownership first. func (h *CIRulesetsHandler) buildCanonicalReviewDoc(ctx context.Context, orgID, reviewID int64) (*canonicalReviewDoc, error) { - header, err := h.getReviewGateHeader(ctx, orgID, reviewID) - if err != nil { - return nil, err - } - - doc := &canonicalReviewDoc{ - ReviewID: header.ID, - OrgID: orgID, - Repository: header.Repository, - Provider: header.Provider, - Status: header.Status, - Findings: []canonicalFinding{}, - Counts: canonicalReviewDocCounts{ - BySeverity: map[string]int{}, - ByCategory: map[string]int{}, - }, - } - for _, sev := range knownSeverities { - doc.Counts.BySeverity[sev] = 0 - } - - rows, _, err := h.taxonomy.ListFindings(ctx, storagereviews.TaxonomyFilter{OrgID: orgID, ReviewID: reviewID}, 500, 0, storagereviews.TaxonomyFindingsOptions{}) - if err != nil { - return nil, fmt.Errorf("loading findings: %w", err) - } - for _, r := range rows { - sev := strings.ToLower(r.Severity) - cat := strings.ToLower(r.Category) - f := canonicalFinding{ - Severity: sev, - Confidence: strings.ToLower(r.Confidence), - Category: cat, - Subcategory: strings.ToLower(r.Subcategory), - Type: strings.ToLower(r.IssueType), - LineNumber: r.LineNumber, - } - if r.FilePath != nil { - f.FilePath = *r.FilePath - } - doc.Findings = append(doc.Findings, f) - doc.Counts.BySeverity[sev]++ - if cat != "" { - doc.Counts.ByCategory[cat]++ - } - doc.Counts.Total++ - } - return doc, nil -} - -// runJQBool evaluates expr against doc and returns whether the first -// produced value is truthy (jq truthiness: everything except false/null). -// It also returns the raw first result for display/debugging. -func runJQBool(expr string, doc interface{}) (blocked bool, result interface{}, err error) { - query, err := gojq.Parse(expr) - if err != nil { - return false, nil, fmt.Errorf("invalid jq expression: %w", err) - } - - // Round-trip through JSON so the doc matches what gojq expects - // (map[string]interface{}/[]interface{}/plain scalars). - raw, err := json.Marshal(doc) - if err != nil { - return false, nil, fmt.Errorf("marshaling document: %w", err) - } - // nosemgrep: go.lang.security.deserialization.unsafe-deserialization-interface.go-unsafe-deserialization-interface -- raw is our own json.Marshal output from just above; gojq requires generic map/slice/scalar input, and encoding/json into interface{} can only produce those, never arbitrary types. - var input interface{} - if err := json.Unmarshal(raw, &input); err != nil { - return false, nil, fmt.Errorf("unmarshaling document: %w", err) - } - - iter := query.Run(input) - v, ok := iter.Next() - if !ok { - return false, nil, fmt.Errorf("jq expression produced no output") - } - if e, ok := v.(error); ok { - return false, nil, fmt.Errorf("jq evaluation error: %w", e) - } - switch t := v.(type) { - case bool: - blocked = t - case nil: - blocked = false - default: - blocked = true - } - return blocked, v, nil + return canonicaldoc.Build(ctx, h.db, orgID, reviewID) } // ---- CRUD ---- diff --git a/internal/api/connectors.go b/internal/api/connectors.go index d66d6a8e2..5d96fe50f 100644 --- a/internal/api/connectors.go +++ b/internal/api/connectors.go @@ -506,7 +506,9 @@ func (s *Server) DeleteConnector(c echo.Context) error { }) } - // Fetch projects to queue webhook removal + // Look up projects now (needs the connector row), but only queue webhook removal + // after the delete succeeds, so a failed delete leaves webhooks untouched. + var projectsToUnhook []string repositoryData, err := s.fetchAndCacheRepositoryData(id, false, false) if err != nil { log.Printf("Failed to fetch repository data for connector %d during deletion: %v", id, err) @@ -515,13 +517,7 @@ func (s *Server) DeleteConnector(c echo.Context) error { log.Printf("Repository access error during deletion of connector %d: %s", id, repositoryData.Error) // Again, proceed to delete the connector } else { - // Queue webhook removal jobs for each project - ctx := c.Request().Context() - for _, projectPath := range repositoryData.Projects { - if err := s.jobQueue.QueueWebhookRemovalJob(ctx, id, projectPath, provider, providerURL, patToken, true); err != nil { - log.Printf("Failed to queue webhook removal job for %s: %v", projectPath, err) - } - } + projectsToUnhook = repositoryData.Projects } // Execute the delete query @@ -552,6 +548,13 @@ func (s *Server) DeleteConnector(c echo.Context) error { }) } + ctx := c.Request().Context() + for _, projectPath := range projectsToUnhook { + if err := s.jobQueue.QueueWebhookRemovalJob(ctx, id, projectPath, provider, providerURL, patToken, true); err != nil { + log.Printf("Failed to queue webhook removal job for %s: %v", projectPath, err) + } + } + // Drop this connector's cached blast-radius clones; best-effort, in the background. go repocache.RemoveScope(repocache.DefaultRoot, connectorOrgID, int64(id)) diff --git a/internal/api/identity_handlers.go b/internal/api/identity_handlers.go new file mode 100644 index 000000000..8ff4c6778 --- /dev/null +++ b/internal/api/identity_handlers.go @@ -0,0 +1,175 @@ +package api + +import ( + "net/http" + "strconv" + "strings" + + "github.com/labstack/echo/v4" + "github.com/livereview/internal/api/auth" +) + +// UserIdentityDTO is one user_identities row. +type UserIdentityDTO struct { + ID int64 `json:"id"` + OrgID int64 `json:"org_id"` + UserID int64 `json:"user_id"` + Kind string `json:"kind"` + Value string `json:"value"` + CreatedBy *int64 `json:"created_by,omitempty"` +} + +// listUserIdentities handles GET /api/v1/orgs/:org_id/users/:user_id/identities. +func (s *Server) listUserIdentities(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid user id") + } + + rows, err := s.db.QueryContext(c.Request().Context(), ` + SELECT id, org_id, user_id, kind, value, created_by + FROM user_identities WHERE org_id = $1 AND user_id = $2 ORDER BY kind, value`, orgID, userID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "query failed: "+err.Error()) + } + defer rows.Close() + + out := []UserIdentityDTO{} + for rows.Next() { + var d UserIdentityDTO + if err := rows.Scan(&d.ID, &d.OrgID, &d.UserID, &d.Kind, &d.Value, &d.CreatedBy); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "scan failed: "+err.Error()) + } + out = append(out, d) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"rows": out}) +} + +// addUserIdentity handles POST /api/v1/orgs/:org_id/users/:user_id/identities. +// Any member can add an identity for themselves; attaching to another user +// requires owner/admin. Session-only (no API key). +func (s *Server) addUserIdentity(c echo.Context) error { + if err := requireSession(c); err != nil { + return err + } + pc := auth.MustGetPermissionContext(c) + orgID := pc.GetOrgID() + targetUserID, err := strconv.ParseInt(c.Param("user_id"), 10, 64) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid user id") + } + + if targetUserID != pc.GetUserID() { + if err := pc.RequireOrgOwner(); err != nil { + return JSONErrorWithEnvelope(c, http.StatusForbidden, "only owners/admins can attach identities to another user") + } + } + + var body struct { + Kind string `json:"kind"` + Value string `json:"value"` + } + if err := c.Bind(&body); err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid body") + } + body.Kind = strings.ToLower(strings.TrimSpace(body.Kind)) + body.Value = normalizeIdentityValue(body.Kind, body.Value) + if body.Kind != "email" && body.Kind != "username" { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "kind must be email or username") + } + if body.Value == "" { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "value is required") + } + + var id int64 + var createdBy interface{} + if pc.User != nil { + createdBy = pc.User.ID + } + err = s.db.QueryRowContext(c.Request().Context(), ` + INSERT INTO user_identities (org_id, user_id, kind, value, created_by) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (org_id, kind, value) DO UPDATE SET user_id = EXCLUDED.user_id, updated_at = now() + RETURNING id`, orgID, targetUserID, body.Kind, body.Value, createdBy).Scan(&id) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusConflict, "identity already maps to a different user in this org") + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"id": id, "kind": body.Kind, "value": body.Value}) +} + +// removeUserIdentity handles DELETE /api/v1/orgs/:org_id/users/:user_id/identities/:id. +func (s *Server) removeUserIdentity(c echo.Context) error { + if err := requireSession(c); err != nil { + return err + } + pc := auth.MustGetPermissionContext(c) + orgID := pc.GetOrgID() + targetUserID, err := strconv.ParseInt(c.Param("user_id"), 10, 64) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid user id") + } + identityID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid identity id") + } + + // Any member can remove their own identities; others require owner/admin. + var owner int64 + if err := s.db.QueryRowContext(c.Request().Context(), + `SELECT user_id FROM user_identities WHERE id = $1 AND org_id = $2`, identityID, orgID).Scan(&owner); err != nil { + return JSONErrorWithEnvelope(c, http.StatusNotFound, "identity not found") + } + if owner != pc.GetUserID() && targetUserID != pc.GetUserID() { + if err := pc.RequireOrgOwner(); err != nil { + return JSONErrorWithEnvelope(c, http.StatusForbidden, "only owners/admins can remove another user's identity") + } + } + + res, err := s.db.ExecContext(c.Request().Context(), + `DELETE FROM user_identities WHERE id = $1 AND org_id = $2 AND user_id = $3`, identityID, orgID, targetUserID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "delete failed: "+err.Error()) + } + if n, _ := res.RowsAffected(); n == 0 { + return JSONErrorWithEnvelope(c, http.StatusNotFound, "identity not found") + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"status": "deleted"}) +} + +// listOrgIdentities handles GET /api/v1/orgs/:org_id/identities — the full index +// used for author-mapping lookup/prefill. +func (s *Server) listOrgIdentities(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + rows, err := s.db.QueryContext(c.Request().Context(), ` + SELECT id, org_id, user_id, kind, value, created_by + FROM user_identities WHERE org_id = $1 ORDER BY kind, value`, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "query failed: "+err.Error()) + } + defer rows.Close() + + out := []UserIdentityDTO{} + for rows.Next() { + var d UserIdentityDTO + if err := rows.Scan(&d.ID, &d.OrgID, &d.UserID, &d.Kind, &d.Value, &d.CreatedBy); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "scan failed: "+err.Error()) + } + out = append(out, d) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"rows": out}) +} + +func normalizeIdentityValue(kind, value string) string { + v := strings.TrimSpace(value) + if kind == "email" { + return strings.ToLower(v) + } + return strings.ToLower(v) +} diff --git a/internal/api/organizations/org_service.go b/internal/api/organizations/org_service.go index e0b149c65..5c2f7dca0 100644 --- a/internal/api/organizations/org_service.go +++ b/internal/api/organizations/org_service.go @@ -491,7 +491,8 @@ func (s *OrganizationService) GetOrganizationMembers(orgID int64, limit, offset obs.billing_period_end as license_expires_at, ur.active_subscription_id, s.razorpay_subscription_id, - u.onboarding_api_key + u.onboarding_api_key, + u.review_level FROM users u INNER JOIN user_roles ur ON u.id = ur.user_id INNER JOIN roles r ON ur.role_id = r.id @@ -538,6 +539,7 @@ func (s *OrganizationService) GetOrganizationMembers(orgID int64, limit, offset &activeSubscriptionID, &razorpaySubscriptionID, &onboardingKey, + &user.ReviewLevel, ) if err != nil { s.logger.Printf("Error scanning member row: %v", err) diff --git a/internal/api/outcome_handlers.go b/internal/api/outcome_handlers.go new file mode 100644 index 000000000..ccf08a173 --- /dev/null +++ b/internal/api/outcome_handlers.go @@ -0,0 +1,116 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/labstack/echo/v4" + "github.com/livereview/internal/api/auth" +) + +// orgReviewOutcome handles POST /api/v1/orgs/:org_id/review-outcomes — the +// documented webhook endpoint CI/CD or incident tooling POSTs to report a +// rollback/incident against a known review id. Authenticated by API key or JWT +// via the standard org middleware chain. +func (s *Server) orgReviewOutcome(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + + var body struct { + ReviewID int64 `json:"review_id"` + OutcomeType string `json:"outcome_type"` + Reason string `json:"reason"` + Source string `json:"source"` + Detail map[string]interface{} `json:"detail"` + } + if err := c.Bind(&body); err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid body") + } + if body.ReviewID <= 0 { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "review_id is required") + } + if body.OutcomeType != "revert" && body.OutcomeType != "incident" && body.OutcomeType != "confirmed_safe" { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid outcome_type") + } + body.Reason = strings.TrimSpace(body.Reason) + if body.Reason == "" { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "reason is required") + } + source := body.Source + if source == "" { + source = "webhook" + } + if source != "webhook" && source != "ci" && source != "test" { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid source") + } + + // Confirm the review belongs to this org before writing. + var exists bool + if err := s.db.QueryRowContext(c.Request().Context(), + `SELECT EXISTS(SELECT 1 FROM reviews WHERE id = $1 AND org_id = $2)`, body.ReviewID, orgID).Scan(&exists); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "lookup failed: "+err.Error()) + } + if !exists { + return JSONErrorWithEnvelope(c, http.StatusNotFound, "review not found in this organization") + } + + if err := s.insertOutcome(c, orgID, body.ReviewID, 0, body.OutcomeType, source, body.Reason, body.Detail); err != nil { + return err + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"status": "recorded", "outcome_type": body.OutcomeType}) +} + +// listReviewOutcomes handles GET /api/v1/orgs/:org_id/review-outcomes — the +// "last 30 days" counter on the setup panel. +func (s *Server) listReviewOutcomes(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + + rows, err := s.db.QueryContext(c.Request().Context(), ` + SELECT review_id, reported_by_user_id, outcome_type, source, reason, detail, created_at + FROM review_outcomes + WHERE org_id = $1 AND created_at >= now() - interval '30 days' + ORDER BY created_at DESC LIMIT 500`, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "query failed: "+err.Error()) + } + defer rows.Close() + + type outcomeRow struct { + ReviewID int64 `json:"review_id"` + ReportedBy *int64 `json:"reported_by_user_id,omitempty"` + OutcomeType string `json:"outcome_type"` + Source string `json:"source"` + Reason string `json:"reason"` + Detail json.RawMessage `json:"detail"` + CreatedAt time.Time `json:"created_at"` + } + out := []outcomeRow{} + manualCount, webhookCount := 0, 0 + for rows.Next() { + var r outcomeRow + var detail []byte + if err := rows.Scan(&r.ReviewID, &r.ReportedBy, &r.OutcomeType, &r.Source, &r.Reason, &detail, &r.CreatedAt); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "scan failed: "+err.Error()) + } + r.Detail = detail + if r.Source == "manual" { + manualCount++ + } else if r.Source != "test" { + webhookCount++ + } + out = append(out, r) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{ + "rows": out, + "total": len(out), + "manual_count": manualCount, + "webhook_count": webhookCount, + }) +} diff --git a/internal/api/pull_request_review_trigger.go b/internal/api/pull_request_review_trigger.go index d707b7cd2..a1015647d 100644 --- a/internal/api/pull_request_review_trigger.go +++ b/internal/api/pull_request_review_trigger.go @@ -5,8 +5,11 @@ import ( "fmt" "log" "net/http" + "strconv" "github.com/labstack/echo/v4" + "github.com/livereview/internal/api/auth" + "github.com/livereview/pkg/models" ) // CreateReviewRequest is the body of POST /api/v1/reviews - trigger a new @@ -14,6 +17,9 @@ import ( // POST /connectors/trigger-review's raw-URL flow. type CreateReviewRequest struct { PullRequestID int64 `json:"pull_request_id"` + // FromPRLink is set by the PR comment's "Start a review" link: reuse a review still + // running from the last 10 minutes, and run the approval policy check when it finishes. + FromPRLink bool `json:"from_pr_link"` } // createReview handles POST /api/v1/reviews. It resolves the PR/MR's web_url @@ -50,6 +56,30 @@ func (s *Server) createReview(c echo.Context) error { return c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("failed to look up pull request: %v", err)}) } + if req.FromPRLink { + var ongoingID int64 + err := s.db.QueryRow( + `SELECT id FROM reviews + WHERE pull_request_id = $1 AND org_id = $2 + AND status IN ('created', 'in_progress') + AND created_at > now() - interval '10 minutes' + ORDER BY created_at DESC LIMIT 1`, + req.PullRequestID, orgID, + ).Scan(&ongoingID) + if err == nil { + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{ + "message": "Review already in progress", + "url": webURL, + "reviewId": fmt.Sprintf("%d", ongoingID), + "pull_request_id": req.PullRequestID, + "reused": true, + }) + } + if err != sql.ErrNoRows { + return c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("failed to check ongoing review: %v", err)}) + } + } + // LOC Quota preflight check — block before creating any DB records, same // as TriggerReviewV2. if blocked, pfErr := s.preflightLOCQuota(c); blocked { @@ -68,6 +98,15 @@ func (s *Server) createReview(c echo.Context) error { return c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()}) } s.enrichMetadata(ctx) + if req.FromPRLink { + // Marks the review so the worker runs the approval policy check once it completes. + if _, err := s.db.Exec( + `UPDATE reviews SET metadata = COALESCE(metadata, '{}'::jsonb) || '{"pr_link_policy": true}'::jsonb WHERE id = $1 AND org_id = $2`, + ctx.review.ID, orgID, + ); err != nil { + log.Printf("[WARN] createReview: failed to mark review %d for policy check: %v", ctx.review.ID, err) + } + } s.trackActivity(ctx) if err := s.launchBackgroundProcessing(ctx); err != nil { log.Printf("[ERROR] createReview: Failed to queue manual review: %v", err) @@ -81,3 +120,35 @@ func (s *Server) createReview(c echo.Context) error { "pull_request_id": req.PullRequestID, }) } + +// getPullRequestOrg handles GET /api/v1/pull-requests/:id/org for the PR comment's +// "Start a review" link, which doesn't know the PR's org yet. Auth-only route, so it +// checks membership itself and returns 404 for both "no such PR" and "not a member". +func (s *Server) getPullRequestOrg(c echo.Context) error { + user, ok := c.Get(string(auth.UserContextKey)).(*models.User) + if !ok || user == nil { + return c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "authentication required"}) + } + prID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || prID <= 0 { + return c.JSON(http.StatusBadRequest, ErrorResponse{Error: "invalid pull request id"}) + } + + // Same access rule as ValidateOrgAccess: a role in the org, or super admin. + var orgID int64 + err = s.db.QueryRow( + `SELECT pr.org_id FROM pull_requests pr + WHERE pr.id = $1 AND EXISTS ( + SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = $2 AND (ur.org_id = pr.org_id OR r.name = $3) + )`, + prID, user.ID, auth.RoleSuperAdmin, + ).Scan(&orgID) + if err == sql.ErrNoRows { + return c.JSON(http.StatusNotFound, ErrorResponse{Error: "pull request not found"}) + } + if err != nil { + return c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "failed to look up pull request"}) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"org_id": orgID}) +} diff --git a/internal/api/review_policy_actions.go b/internal/api/review_policy_actions.go new file mode 100644 index 000000000..99f641b3d --- /dev/null +++ b/internal/api/review_policy_actions.go @@ -0,0 +1,260 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/labstack/echo/v4" + "github.com/livereview/internal/api/auth" + "github.com/livereview/internal/jobqueue" + "github.com/livereview/internal/policyengine" + "github.com/livereview/internal/riskscore" +) + +// requireSession rejects API-key-authenticated requests (sensitive account +// actions require a live JWT session per the scoping guardrails). +func requireSession(c echo.Context) error { + if c.Get(authMethodContextKey) != authMethodSession { + return JSONErrorWithEnvelope(c, http.StatusForbidden, "this action requires an active user session") + } + return nil +} + +// getReviewPolicyLog handles GET /api/v1/reviews/:id/policy-log. +func (s *Server) getReviewPolicyLog(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + reviewID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid review id") + } + + rows, err := s.db.QueryContext(c.Request().Context(), ` + SELECT stage, verdict, detail FROM review_policy_log + WHERE review_id = $1 AND org_id = $2 ORDER BY id`, reviewID, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "query failed: "+err.Error()) + } + defer rows.Close() + + type stageRow struct { + Stage string `json:"stage"` + Verdict string `json:"verdict"` + Detail json.RawMessage `json:"detail"` + } + out := []stageRow{} + for rows.Next() { + var r stageRow + if err := rows.Scan(&r.Stage, &r.Verdict, &r.Detail); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "scan failed: "+err.Error()) + } + out = append(out, r) + } + + // The feature snapshot for the risk stage's explainability view. + var snapshot struct { + ModelVersion int `json:"model_version"` + Features []byte `json:"-"` + Score float64 `json:"score"` + } + features := []float64{} + var featuresRaw []byte + var snapshotScore float64 + var modelVersion int + if err := s.db.QueryRowContext(c.Request().Context(), ` + SELECT model_version, features, score FROM risk_feature_snapshots + WHERE review_id = $1 AND org_id = $2`, reviewID, orgID). + Scan(&modelVersion, &featuresRaw, &snapshotScore); err == nil { + _ = json.Unmarshal(featuresRaw, &features) + snapshot.ModelVersion = modelVersion + snapshot.Score = snapshotScore + } + + // Force-approve eligibility context: is the review blocked, and what's the floor? + blocked := false + for _, r := range out { + if r.Stage == policyengine.StageFinal && r.Verdict == policyengine.FinalBlocked { + blocked = true + } + } + forceApproveMinLevel := 3 + if p, found, _ := policyengine.ResolvePolicy(c.Request().Context(), s.db, orgID, 0); found { + forceApproveMinLevel = p.ForceApproveMinLevel + } + + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{ + "stages": out, + "blocked": blocked, + "force_approve_min_level": forceApproveMinLevel, + "features": features, + "feature_names": riskscore.FeatureSpecs, + "model_version": modelVersion, + "score": snapshotScore, + }) +} + +// forceApproveReview handles POST /api/v1/reviews/:id/force-approve (session-only, +// gated by review_level OR owner/admin). +func (s *Server) forceApproveReview(c echo.Context) error { + if err := requireSession(c); err != nil { + return err + } + pc := auth.MustGetPermissionContext(c) + orgID := pc.GetOrgID() + reviewID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid review id") + } + + var body struct { + Reason string `json:"reason"` + } + if err := c.Bind(&body); err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid body") + } + body.Reason = strings.TrimSpace(body.Reason) + if body.Reason == "" { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "reason is required") + } + + // Resolve the policy floor for force-approve. + forceApproveMinLevel := 3 + if p, found, _ := policyengine.ResolvePolicy(c.Request().Context(), s.db, orgID, 0); found { + forceApproveMinLevel = p.ForceApproveMinLevel + } + + // Authorize: review_level >= floor OR owner/admin. + var actorLevel int + if err := s.db.QueryRowContext(c.Request().Context(), `SELECT review_level FROM users WHERE id = $1`, pc.GetUserID()).Scan(&actorLevel); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "failed to load user level") + } + if actorLevel < forceApproveMinLevel && !pc.IsOwner && !pc.IsSuperAdmin { + return JSONErrorWithEnvelope(c, http.StatusForbidden, "insufficient review level or role to force-approve") + } + + // Only blocked -> approved; there is no symmetric force-request-changes. + var finalVerdict string + if err := s.db.QueryRowContext(c.Request().Context(), ` + SELECT verdict FROM review_policy_log + WHERE review_id = $1 AND org_id = $2 AND stage = 'final' ORDER BY id DESC LIMIT 1`, + reviewID, orgID).Scan(&finalVerdict); err != nil { + return JSONErrorWithEnvelope(c, http.StatusNotFound, "no policy evaluation found for this review") + } + if finalVerdict != policyengine.FinalBlocked { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "review is not blocked") + } + + _, err = s.db.ExecContext(c.Request().Context(), ` + INSERT INTO review_policy_overrides (review_id, org_id, actor_user_id, original_verdict, forced_verdict, reason) + VALUES ($1, $2, $3, $4, $5, $6)`, + reviewID, orgID, pc.GetUserID(), finalVerdict, policyengine.FinalApproved, body.Reason) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "failed to record override: "+err.Error()) + } + + if err := s.jobQueue.QueuePRPolicyForceApprove(c.Request().Context(), jobqueue.PRPolicyForceApproveJobArgs{ + OrgID: orgID, + ReviewID: reviewID, + ActorUserID: pc.GetUserID(), + Reason: body.Reason, + }); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "failed to queue force-approve: "+err.Error()) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"status": "force-approved"}) +} + +// markReviewOutcome handles POST /api/v1/reviews/:id/outcomes (manual in-app +// "Mark as reverted/incident" action). +func (s *Server) markReviewOutcome(c echo.Context) error { + pc := auth.MustGetPermissionContext(c) + orgID := pc.GetOrgID() + reviewID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid review id") + } + + var body struct { + OutcomeType string `json:"outcome_type"` + Reason string `json:"reason"` + } + if err := c.Bind(&body); err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid body") + } + body.Reason = strings.TrimSpace(body.Reason) + if body.Reason == "" { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "reason is required") + } + if body.OutcomeType != "revert" && body.OutcomeType != "incident" && body.OutcomeType != "confirmed_safe" { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid outcome_type") + } + + // Confirm the review belongs to this org before writing (never trust a + // caller-supplied review id without checking ownership first). + var exists bool + if err := s.db.QueryRowContext(c.Request().Context(), + `SELECT EXISTS(SELECT 1 FROM reviews WHERE id = $1 AND org_id = $2)`, reviewID, orgID).Scan(&exists); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "lookup failed: "+err.Error()) + } + if !exists { + return JSONErrorWithEnvelope(c, http.StatusNotFound, "review not found in this organization") + } + + if err := s.insertOutcome(c, orgID, reviewID, pc.GetUserID(), body.OutcomeType, "manual", body.Reason, nil); err != nil { + return err + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"status": "recorded", "outcome_type": body.OutcomeType}) +} + +// insertOutcome inserts a review_outcomes row and (for non-test sources) fires +// the real-time online model nudge. +func (s *Server) insertOutcome(c echo.Context, orgID, reviewID, userID int64, outcomeType, source, reason string, detail map[string]interface{}) error { + // Permission lockdown switch (default open): when set, only owner/admin may + // report an outcome in-app. Webhooks are always authenticated by API key. + if source == "manual" && s.restrictOutcomeReporting(orgID) { + pc := auth.MustGetPermissionContext(c) + if !pc.IsOwner && !pc.IsSuperAdmin { + return JSONErrorWithEnvelope(c, http.StatusForbidden, "outcome reporting is restricted to admins") + } + } + + detailJSON := "{}" + if detail != nil { + if b, err := json.Marshal(detail); err == nil { + detailJSON = string(b) + } + } + var reportedBy interface{} + if userID > 0 { + reportedBy = userID + } + if _, err := s.db.ExecContext(c.Request().Context(), ` + INSERT INTO review_outcomes (review_id, org_id, reported_by_user_id, outcome_type, source, reason, detail) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + reviewID, orgID, reportedBy, outcomeType, source, reason, detailJSON); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "failed to record outcome: "+err.Error()) + } + + // Real-time online nudge (path 1), skipped for test events. + if source != "test" { + store := riskscore.NewStore(s.db) + if err := store.ApplyOutcome(c.Request().Context(), orgID, reviewID, outcomeType); err != nil { + // Never fail the request over a learning update. + _ = err + } + } + return nil +} + +// restrictOutcomeReporting returns the org's lockdown switch. +func (s *Server) restrictOutcomeReporting(orgID int64) bool { + var restrict bool + if err := s.db.QueryRow(`SELECT COALESCE(restrict_outcome_reporting_to_admins, false) + FROM auto_approve_policies WHERE org_id = $1 AND repository_id IS NULL`, orgID).Scan(&restrict); err != nil { + return false + } + return restrict +} diff --git a/internal/api/review_policy_actions_test.go b/internal/api/review_policy_actions_test.go new file mode 100644 index 000000000..c82a1453b --- /dev/null +++ b/internal/api/review_policy_actions_test.go @@ -0,0 +1,134 @@ +package api + +import ( + "database/sql" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "testing" + + "github.com/labstack/echo/v4" + _ "github.com/lib/pq" + + "github.com/livereview/internal/api/auth" +) + +// Regression test: markReviewOutcome (POST /api/v1/reviews/:id/outcomes) used +// to write a review_outcomes row without ever checking that the review +// actually belongs to the caller's org — unlike its webhook twin +// (orgReviewOutcome), which does. A member of org B could report a +// revert/incident against any review id from org A just by guessing it. This +// boots the real route with the real middleware chain and a real API key, and +// confirms org B is now rejected (404) and no row is written. +func TestMarkReviewOutcomeRejectsCrossOrgReview(t *testing.T) { + if testing.Short() { + t.Skip("skipping database integration test") + } + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + dsn = "postgres://livereview:livereview_password_123@localhost:5432/livereview?sslmode=disable" + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Ping(); err != nil { + t.Skipf("db not reachable: %v", err) + } + + // The live dev server's background dashboard-cache refresh job may insert a + // row for a new org before cleanup runs; that FK has no cascade, so it must + // be cleared first or the org delete silently no-ops. + cleanupOrg := func(orgID int64) { + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM dashboard_cache WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM orgs WHERE id = $1`, orgID) + }) + } + + // ---- org A owns the review ---- + var orgAID int64 + if err := db.QueryRow(`INSERT INTO orgs (name) VALUES ('outcome-isolation-org-a') RETURNING id`).Scan(&orgAID); err != nil { + t.Fatalf("insert org a: %v", err) + } + cleanupOrg(orgAID) + + var reviewID int64 + if err := db.QueryRow(`INSERT INTO reviews (repository, status, org_id, metadata) VALUES ('a/repo', 'completed', $1, '{}'::jsonb) RETURNING id`, orgAID).Scan(&reviewID); err != nil { + t.Fatalf("insert review: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM reviews WHERE id = $1`, reviewID) }) + + // ---- org B is the attacker's own org, with its own owner + API key ---- + var orgBID int64 + if err := db.QueryRow(`INSERT INTO orgs (name) VALUES ('outcome-isolation-org-b') RETURNING id`).Scan(&orgBID); err != nil { + t.Fatalf("insert org b: %v", err) + } + cleanupOrg(orgBID) + + var userBID int64 + if err := db.QueryRow(`INSERT INTO users (email, password_hash) VALUES ('outcome-isolation-b@example.com', 'x') RETURNING id`).Scan(&userBID); err != nil { + t.Fatalf("insert user b: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userBID) }) + + const ownerRoleID = 2 // seeded fixed row, see roles table + if _, err := db.Exec(`INSERT INTO user_roles (user_id, org_id, role_id) VALUES ($1, $2, $3)`, userBID, orgBID, ownerRoleID); err != nil { + t.Fatalf("insert user_roles: %v", err) + } + // user_roles has no cascading FK from users/orgs, so it must be cleaned up + // explicitly (and before the user/org deletes it's registered ahead of, since + // t.Cleanup runs funcs in LIFO order) or a rerun collides on users_email_key. + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM user_roles WHERE user_id = $1 AND org_id = $2`, userBID, orgBID) }) + + apiKeyMgr := NewAPIKeyManager(db) + _, plainKey, err := apiKeyMgr.CreateAPIKey(userBID, orgBID, "outcome-isolation-b", []string{}, nil) + if err != nil { + t.Fatalf("create api key: %v", err) + } + + // ---- boot the real route with the real middleware chain ---- + s := &Server{db: db, echo: echo.New()} + authMiddleware := auth.NewAuthMiddleware(nil, db) + + reviewsGroup := s.echo.Group("/api/v1/reviews") + reviewsGroup.Use(RequireAuthOrAPIKey(nil, db)) + reviewsGroup.Use(authMiddleware.BuildOrgContextFromHeader()) + reviewsGroup.Use(authMiddleware.ValidateOrgAccess()) + reviewsGroup.Use(authMiddleware.BuildPermissionContext()) + reviewsGroup.POST("/:id/outcomes", s.markReviewOutcome) + + srv := httptest.NewServer(s.echo) + defer srv.Close() + + body := `{"outcome_type":"revert","reason":"cross-org isolation regression test"}` + req, err := http.NewRequest(http.MethodPost, + srv.URL+"/api/v1/reviews/"+strconv.FormatInt(reviewID, 10)+"/outcomes", strings.NewReader(body)) + if err != nil { + t.Fatalf("build request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", plainKey) + req.Header.Set("X-Org-Context", strconv.FormatInt(orgBID, 10)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 (review not found in this organization), got %d", resp.StatusCode) + } + + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM review_outcomes WHERE review_id = $1`, reviewID).Scan(&count); err != nil { + t.Fatalf("query review_outcomes: %v", err) + } + if count != 0 { + t.Fatalf("expected no review_outcomes row to be written across orgs, found %d", count) + } +} diff --git a/internal/api/risk_model_handlers.go b/internal/api/risk_model_handlers.go new file mode 100644 index 000000000..041947317 --- /dev/null +++ b/internal/api/risk_model_handlers.go @@ -0,0 +1,92 @@ +package api + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/livereview/internal/api/auth" + "github.com/livereview/internal/riskscore" +) + +// getRiskModel handles GET /api/v1/risk-model — the Risk Model sub-tab's data: +// the active version/sample-count (model-confidence indicator), the category +// overrides, and the recent score distribution for the calibration chart. +func (s *Server) getRiskModel(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + + scorer := riskscore.NewScorer(s.db) + info, err := scorer.Info(c.Request().Context(), orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "failed to load model: "+err.Error()) + } + + store := riskscore.NewStore(s.db) + overrides, err := store.LoadOverrides(c.Request().Context(), orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "failed to load overrides: "+err.Error()) + } + // Fill in categories the org hasn't explicitly overridden with their + // starting default so the UI's sliders render at the real effective + // value (e.g. Security pre-set to 1.5x) rather than always showing 1x + // until someone saves a row. + for _, cat := range riskscore.OverrideCategories() { + if _, ok := overrides[cat]; !ok { + overrides[cat] = riskscore.DefaultCategoryMultiplier(cat) + } + } + scores, err := riskscore.LoadRecentScores(c.Request().Context(), s.db, orgID, 2000) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "failed to load calibration: "+err.Error()) + } + + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{ + "model": info, + "overrides": overrides, + "scores": scores, + }) +} + +// putRiskModelOverrides handles PUT /api/v1/risk-model/overrides — save category +// multipliers (instant effect, decoupled from the retrain cadence). +func (s *Server) putRiskModelOverrides(c echo.Context) error { + pc := auth.MustGetPermissionContext(c) + if err := pc.RequireOrgOwner(); err != nil { + return JSONErrorWithEnvelope(c, http.StatusForbidden, err.Error()) + } + orgID := pc.GetOrgID() + + var body struct { + Overrides map[string]float64 `json:"overrides"` + } + if err := c.Bind(&body); err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid body") + } + + store := riskscore.NewStore(s.db) + for category, multiplier := range body.Overrides { + if err := store.UpsertOverride(c.Request().Context(), orgID, category, multiplier); err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid override for "+category+": "+err.Error()) + } + } + overrides, _ := store.LoadOverrides(c.Request().Context(), orgID) + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"overrides": overrides}) +} + +// retrainRiskModel handles POST /api/v1/risk-model/retrain — the on-demand batch +// refit (same as the daily job). +func (s *Server) retrainRiskModel(c echo.Context) error { + pc := auth.MustGetPermissionContext(c) + if err := pc.RequireOrgOwner(); err != nil { + return JSONErrorWithEnvelope(c, http.StatusForbidden, err.Error()) + } + orgID := pc.GetOrgID() + + res, err := riskscore.NewStore(s.db).Refit(c.Request().Context(), orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "retrain failed: "+err.Error()) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"result": res}) +} diff --git a/internal/api/server.go b/internal/api/server.go index b6508b33a..e51d01b7a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1283,6 +1283,12 @@ func (s *Server) setupRoutes() { orgGroup.PUT("/users/:user_id/role", s.userHandlers.ChangeUserRole) orgGroup.POST("/users/:user_id/force-password-reset", s.userHandlers.ForcePasswordReset) orgGroup.GET("/users/:user_id/audit-log", s.userHandlers.GetUserAuditLog) + orgGroup.POST("/review-outcomes", s.orgReviewOutcome) + orgGroup.GET("/review-outcomes", s.listReviewOutcomes) + orgGroup.GET("/users/:user_id/identities", s.listUserIdentities) + orgGroup.POST("/users/:user_id/identities", s.addUserIdentity) + orgGroup.DELETE("/users/:user_id/identities/:id", s.removeUserIdentity) + orgGroup.GET("/identities", s.listOrgIdentities) // Super admin routes - Production adminGroup := v1.Group("/admin") @@ -1421,6 +1427,45 @@ func (s *Server) setupRoutes() { ciRulesetsGroup.DELETE("/:id", ciRulesetsHandler.Delete) ciRulesetsGroup.GET("/:id/evaluate", ciRulesetsHandler.Evaluate) + // Auto-approve policy (organization-scoped) — the risk-based policy engine + // (docs/risk-policy-engine.md). Reuses ci_rulesets as blocking gates. + autoApprovePoliciesHandler := NewAutoApprovePoliciesHandler(s.db) + autoApproveGroup := v1.Group("/auto-approve-policies") + autoApproveGroup.Use(RequireAuthOrAPIKey(s.tokenService, s.db)) + autoApproveGroup.Use(authMiddleware.BuildOrgContextFromHeader()) + autoApproveGroup.Use(authMiddleware.ValidateOrgAccess()) + autoApproveGroup.Use(authMiddleware.BuildPermissionContext()) + autoApproveGroup.GET("", autoApprovePoliciesHandler.List) + autoApproveGroup.POST("", autoApprovePoliciesHandler.Create) + autoApproveGroup.GET("/resolved", autoApprovePoliciesHandler.Resolved) + autoApproveGroup.POST("/simulate", autoApprovePoliciesHandler.Simulate) + autoApproveGroup.GET("/:id", autoApprovePoliciesHandler.Get) + autoApproveGroup.PUT("/:id", autoApprovePoliciesHandler.Update) + autoApproveGroup.DELETE("/:id", autoApprovePoliciesHandler.Delete) + + // Auto-approve insights: score distribution, decision counts, and outcome + // calibration over a selectable window — the "is this behaving + // reasonably" view (Reports → Auto Approve), as opposed to the + // per-review explain view or the policy config above. + autoApproveInsightsHandler := NewAutoApproveInsightsHandler(s.db) + autoApproveInsightsGroup := v1.Group("/auto-approve-insights") + autoApproveInsightsGroup.Use(RequireAuthOrAPIKey(s.tokenService, s.db)) + autoApproveInsightsGroup.Use(authMiddleware.BuildOrgContextFromHeader()) + autoApproveInsightsGroup.Use(authMiddleware.ValidateOrgAccess()) + autoApproveInsightsGroup.Use(authMiddleware.BuildPermissionContext()) + autoApproveInsightsGroup.GET("", autoApproveInsightsHandler.Insights) + + // Risk model (org-wide classifier): read info/calibration, set category + // overrides, trigger an on-demand refit. + riskModelGroup := v1.Group("/risk-model") + riskModelGroup.Use(RequireAuthOrAPIKey(s.tokenService, s.db)) + riskModelGroup.Use(authMiddleware.BuildOrgContextFromHeader()) + riskModelGroup.Use(authMiddleware.ValidateOrgAccess()) + riskModelGroup.Use(authMiddleware.BuildPermissionContext()) + riskModelGroup.GET("", s.getRiskModel) + riskModelGroup.PUT("/overrides", s.putRiskModelOverrides) + riskModelGroup.POST("/retrain", s.retrainRiskModel) + // Development mode: Org-scoped and Admin test endpoints if s.devMode { // TEST: Org-scoped test endpoint @@ -1509,6 +1554,10 @@ func (s *Server) setupRoutes() { pullRequestsGroup.Use(apimiddleware.BuildPlanContext()) pullRequestsGroup.GET("", s.ListPullRequests) + // Resolves a PR's org for the PR comment's "Start a review" link; the org isn't known + // yet, so this is auth-only and the handler checks org membership itself. + v1.GET("/pull-requests/:id/org", s.getPullRequestOrg, authMiddleware.RequireAuth()) + // GitLab profile validation endpoint v1.POST("/gitlab/validate-profile", s.ValidateGitLabProfile) @@ -1674,6 +1723,9 @@ func (s *Server) setupRoutes() { reviewsGroup.GET("/:id/summary", reviewEventsHandler.GetReviewSummary) reviewsGroup.GET("/:id/accounting", reviewEventsHandler.GetReviewAccounting) reviewsGroup.GET("/:id/commits", s.GetReviewCommits) + reviewsGroup.GET("/:id/policy-log", s.getReviewPolicyLog) + reviewsGroup.POST("/:id/force-approve", s.forceApproveReview) + reviewsGroup.POST("/:id/outcomes", s.markReviewOutcome) // Subscription endpoints (organization scoped) subscriptionsHandler := NewSubscriptionsHandler(s.db) @@ -2160,6 +2212,8 @@ type ReviewsQuery struct { Order string `form:"order" query:"order" json:"order,omitempty" jsonschema:"description=Sort direction: asc or desc (default desc)"` DateFrom string `form:"date_from" query:"date_from" json:"date_from,omitempty" jsonschema:"description=Only include reviews with last activity (completed/started/created) on or after this date. Accepts YYYY-MM-DD or RFC3339"` DateTo string `form:"date_to" query:"date_to" json:"date_to,omitempty" jsonschema:"description=Only include reviews with last activity (completed/started/created) on or before this date (inclusive of the whole day if bare YYYY-MM-DD). Accepts YYYY-MM-DD or RFC3339"` + + PullRequestID int64 `form:"pull_request_id" query:"pull_request_id" json:"pull_request_id,omitempty" jsonschema:"description=Only include reviews of this pull/merge request (internal pull_requests.id)"` } type ReviewsListResponse struct { @@ -2335,6 +2389,18 @@ func (s *Server) getReviews(c echo.Context) error { countQuery += clause } + if v := c.QueryParam("pull_request_id"); v != "" { + prID, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid pull_request_id") + } + clause := fmt.Sprintf(" AND pull_request_id = $%d", argIndex) + baseQuery += clause + countQuery += clause + args = append(args, prID) + argIndex++ + } + // Add search functionality search := c.QueryParam("search") if search != "" { diff --git a/internal/api/users/user_service.go b/internal/api/users/user_service.go index c69f23ab6..94ed5b143 100644 --- a/internal/api/users/user_service.go +++ b/internal/api/users/user_service.go @@ -106,6 +106,7 @@ type UserWithRole struct { RoleID int64 `json:"role_id"` OrgID int64 `json:"org_id"` OnboardingAPIKey string `json:"onboarding_api_key,omitempty"` + ReviewLevel int `json:"review_level"` } // CreateUserRequest represents the request to create a new user @@ -119,11 +120,12 @@ type CreateUserRequest struct { // UpdateUserRequest represents the request to update a user type UpdateUserRequest struct { - FirstName *string `json:"first_name"` - LastName *string `json:"last_name"` - IsActive *bool `json:"is_active"` - RoleID *int64 `json:"role_id"` - Password *string `json:"password,omitempty"` + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` + IsActive *bool `json:"is_active"` + RoleID *int64 `json:"role_id"` + Password *string `json:"password,omitempty"` + ReviewLevel *int `json:"review_level"` } // CreateUserInOrg creates a new user in the specified organization @@ -654,7 +656,7 @@ func (us *UserService) GetUserInOrg(orgID, userID int64) (*UserWithRole, error) SELECT u.id, u.email, u.first_name, u.last_name, u.is_active, u.last_login_at, u.created_at, u.updated_at, u.created_by_user_id, u.deactivated_at, u.deactivated_by_user_id, u.password_reset_required, - r.name as role, r.id as role_id, ur.org_id, u.onboarding_api_key + r.name as role, r.id as role_id, ur.org_id, u.onboarding_api_key, u.review_level FROM users u JOIN user_roles ur ON u.id = ur.user_id JOIN roles r ON ur.role_id = r.id @@ -663,7 +665,7 @@ func (us *UserService) GetUserInOrg(orgID, userID int64) (*UserWithRole, error) &user.ID, &user.Email, &user.FirstName, &user.LastName, &user.IsActive, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, &user.CreatedByUserID, &user.DeactivatedAt, &user.DeactivatedByUserID, &user.PasswordResetRequired, - &user.Role, &user.RoleID, &user.OrgID, &onboardingKey, + &user.Role, &user.RoleID, &user.OrgID, &onboardingKey, &user.ReviewLevel, ) if err != nil { @@ -700,7 +702,7 @@ func (us *UserService) ListUsersInOrg(orgID int64, offset, limit int) ([]*UserWi SELECT u.id, u.email, u.first_name, u.last_name, u.is_active, u.last_login_at, u.created_at, u.updated_at, u.created_by_user_id, u.deactivated_at, u.deactivated_by_user_id, u.password_reset_required, - r.name as role, r.id as role_id, ur.org_id, u.onboarding_api_key + r.name as role, r.id as role_id, ur.org_id, u.onboarding_api_key, u.review_level FROM users u JOIN user_roles ur ON u.id = ur.user_id JOIN roles r ON ur.role_id = r.id @@ -722,7 +724,7 @@ func (us *UserService) ListUsersInOrg(orgID int64, offset, limit int) ([]*UserWi &user.ID, &user.Email, &user.FirstName, &user.LastName, &user.IsActive, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, &user.CreatedByUserID, &user.DeactivatedAt, &user.DeactivatedByUserID, &user.PasswordResetRequired, - &user.Role, &user.RoleID, &user.OrgID, &onboardingKey, + &user.Role, &user.RoleID, &user.OrgID, &onboardingKey, &user.ReviewLevel, ) if err != nil { return nil, 0, fmt.Errorf("failed to scan user: %w", err) @@ -772,6 +774,13 @@ func (us *UserService) UpdateUserInOrg(orgID, userID, updatedByUserID int64, req } } + if req.ReviewLevel != nil { + setParts = append(setParts, fmt.Sprintf("review_level = $%d", argIndex)) + args = append(args, *req.ReviewLevel) + auditDetails["review_level"] = *req.ReviewLevel + argIndex++ + } + if req.Password != nil && *req.Password != "" { hashedPassword, hashErr := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) if hashErr != nil { diff --git a/internal/api/webhook_orchestrator_v2.go b/internal/api/webhook_orchestrator_v2.go index 7a86f9df1..fc75278c2 100644 --- a/internal/api/webhook_orchestrator_v2.go +++ b/internal/api/webhook_orchestrator_v2.go @@ -99,6 +99,11 @@ type prStateConverter interface { ConvertPullRequestStateEvent(headers map[string]string, body []byte) (*prsync.PullRequestStateEvent, bool, error) } +// prStateEventAlsoUnified lists providers whose PR state events must also run the normal flow after the sync is queued. +// Only Bitbucket needs it (reviewer assignment); for Azure and Gitea that flow rejects PR events with a 422, +// which makes Azure put the Service Hook on probation. +var prStateEventAlsoUnified = map[string]bool{"bitbucket": true} + // ProcessWebhookEvent is the main entry point for webhook processing (replaces individual handlers) func (wo *WebhookOrchestratorV2) ProcessWebhookEvent(c echo.Context) error { startTime := time.Now() @@ -174,11 +179,14 @@ func (wo *WebhookOrchestratorV2) ProcessWebhookEvent(c echo.Context) error { log.Printf("[ERROR] Failed to queue PR state sync job: %v", err) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to queue pr state sync"}) } - return c.JSON(http.StatusOK, map[string]string{ - "status": "accepted", - "provider": providerName, - "event_type": "pr_state_sync", - }) + // These providers' PR events also feed the normal flow (e.g. reviewer assignment), so keep going. + if !prStateEventAlsoUnified[providerName] { + return c.JSON(http.StatusOK, map[string]string{ + "status": "accepted", + "provider": providerName, + "event_type": "pr_state_sync", + }) + } } } diff --git a/internal/batch/batch.go b/internal/batch/batch.go index 2bb77f8d7..29429764c 100644 --- a/internal/batch/batch.go +++ b/internal/batch/batch.go @@ -361,6 +361,7 @@ type BatchResult struct { FileSummary string // New: file-level summary if present TechnicalSummaries []prompts.TechnicalSummary Comments []*models.ReviewComment + DiffClassification *models.DiffClassification // diff-level semantic classification (per-batch) Error error BatchID string } @@ -497,13 +498,46 @@ func (p *BatchProcessor) AggregateAndCombineOutputs(ctx context.Context, llm llm // Output: one general summary, only EXTERNAL comments for posting return &models.ReviewResult{ - Summary: generalSummary, - Comments: externalComments, - InternalComments: internalComments, - Quiz: quiz, + Summary: generalSummary, + Comments: externalComments, + InternalComments: internalComments, + Quiz: quiz, + DiffClassification: pickDiffClassification(results), }, nil } +// pickDiffClassification reduces per-batch diff classifications to a single +// review-level one: highest confidence wins, ties broken toward the riskier +// class. Returns nil when no batch produced one. +func pickDiffClassification(results []*BatchResult) *models.DiffClassification { + var best *models.DiffClassification + for _, r := range results { + if r == nil || r.DiffClassification == nil { + continue + } + dc := r.DiffClassification + if best == nil || dc.Confidence > best.Confidence || + (dc.Confidence == best.Confidence && classRiskOrder(dc.Class) > classRiskOrder(best.Class)) { + best = dc + } + } + return best +} + +// classRiskOrder ranks diff classes by risk for tie-breaking (higher = riskier). +func classRiskOrder(class string) int { + switch class { + case "security-sensitive": + return 3 + case "logic-change": + return 2 + case "config-change", "dependency-change", "data-migration": + return 1 + default: + return 0 + } +} + // quizJSONStart/quizJSONEnd are the exact sentinel lines the "quiz" prompt // instructions (prompts.QuizOutputDelimiters) tell the model to wrap the // quiz JSON array in, appended after the markdown summary in the same diff --git a/internal/canonicaldoc/canonicaldoc.go b/internal/canonicaldoc/canonicaldoc.go new file mode 100644 index 000000000..4efc5276c --- /dev/null +++ b/internal/canonicaldoc/canonicaldoc.go @@ -0,0 +1,188 @@ +// Package canonicaldoc builds the stable, provider-agnostic "canonical review +// document" every jq ruleset is evaluated against, and evaluates jq boolean +// expressions against it. It is shared by the CI/CD gate API +// (internal/api/ci_rulesets_handler.go) and the risk-policy engine's static +// gate stage (internal/policyengine), both of which reuse the same +// build/eval primitives so there is exactly one rule engine. +// +// A truthy jq result (anything except false/null) means "block". +package canonicaldoc + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + + "github.com/itchyny/gojq" + "github.com/livereview/pkg/models" + storagereviews "github.com/livereview/storage/reviews" +) + +// Finding is one row of the "findings" array in the canonical doc. +type Finding struct { + Severity string `json:"severity"` + Confidence string `json:"confidence"` + Category string `json:"category"` + Subcategory string `json:"subcategory"` + Type string `json:"type"` + FilePath string `json:"file_path,omitempty"` + LineNumber *int `json:"line_number,omitempty"` +} + +// Counts carries pre-aggregated severity/category counts, zero-filled for the +// known severities so jq expressions like `.counts.by_severity.critical > 0` +// never fail with "null cannot be compared" on a clean review. +type Counts struct { + BySeverity map[string]int `json:"by_severity"` + ByCategory map[string]int `json:"by_category"` + Total int `json:"total"` +} + +// ReviewDoc is the canonical document shape jq rulesets evaluate against. +type ReviewDoc struct { + ReviewID int64 `json:"review_id"` + OrgID int64 `json:"org_id"` + Repository string `json:"repository"` + Provider string `json:"provider"` + Status string `json:"status"` + Findings []Finding `json:"findings"` + Counts Counts `json:"counts"` + // DiffClassification is surfaced (when present) so jq rules can reference the + // diff-level semantic classification produced by the review LLM + // (see docs/risk-policy-engine.md §5). Absent for CLI/legacy reviews. + DiffClassification *models.DiffClassification `json:"diff_classification,omitempty"` +} + +// KnownSeverities is the fixed 3-value severity model the AI review prompt emits. +var KnownSeverities = []string{"critical", "warning", "info"} + +// header is the minimal review row needed to build the canonical doc's header. +type header struct { + ID int64 + Repository string + Provider string + Status string +} + +// Build constructs the canonical review document for a review scoped strictly +// to orgID - never trust a caller-supplied review_id without confirming org +// ownership first. +func Build(ctx context.Context, db *sql.DB, orgID, reviewID int64) (*ReviewDoc, error) { + var h header + var provider sql.NullString + err := db.QueryRowContext(ctx, ` + SELECT id, repository, provider, status + FROM reviews + WHERE id = $1 AND org_id = $2`, reviewID, orgID).Scan(&h.ID, &h.Repository, &provider, &h.Status) + if err != nil { + return nil, err + } + h.Provider = provider.String + + doc := &ReviewDoc{ + ReviewID: h.ID, + OrgID: orgID, + Repository: h.Repository, + Provider: h.Provider, + Status: h.Status, + Findings: []Finding{}, + Counts: Counts{ + BySeverity: map[string]int{}, + ByCategory: map[string]int{}, + }, + } + for _, sev := range KnownSeverities { + doc.Counts.BySeverity[sev] = 0 + } + + taxonomy := storagereviews.NewTaxonomyReportStore(db) + rows, _, err := taxonomy.ListFindings(ctx, storagereviews.TaxonomyFilter{OrgID: orgID, ReviewID: reviewID}, 500, 0, storagereviews.TaxonomyFindingsOptions{}) + if err != nil { + return nil, fmt.Errorf("loading findings: %w", err) + } + for _, r := range rows { + sev := strings.ToLower(r.Severity) + cat := strings.ToLower(r.Category) + f := Finding{ + Severity: sev, + Confidence: strings.ToLower(r.Confidence), + Category: cat, + Subcategory: strings.ToLower(r.Subcategory), + Type: strings.ToLower(r.IssueType), + LineNumber: r.LineNumber, + } + if r.FilePath != nil { + f.FilePath = *r.FilePath + } + doc.Findings = append(doc.Findings, f) + doc.Counts.BySeverity[sev]++ + if cat != "" { + doc.Counts.ByCategory[cat]++ + } + doc.Counts.Total++ + } + + // Surface the diff-level classification if the review carried one. + doc.DiffClassification = loadDiffClassification(ctx, db, orgID, reviewID) + + return doc, nil +} + +// loadDiffClassification reads the diff-level classification out of the review's +// persisted review_result metadata (written by the review LLM), if present. +func loadDiffClassification(ctx context.Context, db *sql.DB, orgID, reviewID int64) *models.DiffClassification { + var raw []byte + err := db.QueryRowContext(ctx, + `SELECT metadata->'review_result'->'diffClassification' FROM reviews WHERE id = $1 AND org_id = $2`, + reviewID, orgID).Scan(&raw) + if err != nil || len(raw) == 0 || string(raw) == "null" { + return nil + } + var dc models.DiffClassification + if err := json.Unmarshal(raw, &dc); err != nil { + return nil + } + return &dc +} + +// RunJQBool evaluates expr against doc and returns whether the first produced +// value is truthy (jq truthiness: everything except false/null), plus the raw +// first result for display/debugging. +func RunJQBool(expr string, doc interface{}) (blocked bool, result interface{}, err error) { + query, err := gojq.Parse(expr) + if err != nil { + return false, nil, fmt.Errorf("invalid jq expression: %w", err) + } + + // Round-trip through JSON so the doc matches what gojq expects + // (map[string]interface{}/[]interface{}/plain scalars). + raw, err := json.Marshal(doc) + if err != nil { + return false, nil, fmt.Errorf("marshaling document: %w", err) + } + // nosemgrep: go.lang.security.deserialization.unsafe-deserialization-interface.go-unsafe-deserialization-interface -- raw is our own json.Marshal output from just above; gojq requires generic map/slice/scalar input, and encoding/json into interface{} can only produce those, never arbitrary types. + var input interface{} + if err := json.Unmarshal(raw, &input); err != nil { + return false, nil, fmt.Errorf("unmarshaling document: %w", err) + } + + iter := query.Run(input) + v, ok := iter.Next() + if !ok { + return false, nil, fmt.Errorf("jq expression produced no output") + } + if e, ok := v.(error); ok { + return false, nil, fmt.Errorf("jq evaluation error: %w", e) + } + switch t := v.(type) { + case bool: + blocked = t + case nil: + blocked = false + default: + blocked = true + } + return blocked, v, nil +} diff --git a/internal/canonicaldoc/canonicaldoc_test.go b/internal/canonicaldoc/canonicaldoc_test.go new file mode 100644 index 000000000..a90e9af2e --- /dev/null +++ b/internal/canonicaldoc/canonicaldoc_test.go @@ -0,0 +1,102 @@ +package canonicaldoc + +import ( + "context" + "database/sql" + "os" + "testing" + + _ "github.com/lib/pq" +) + +func testDB(t *testing.T) *sql.DB { + if testing.Short() { + t.Skip("skipping database integration test") + } + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + dsn = "postgres://livereview:livereview_password_123@localhost:5432/livereview?sslmode=disable" + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Ping(); err != nil { + t.Skipf("db not reachable: %v", err) + } + return db +} + +// Build must never return a review that belongs to a different org, even +// when given a syntactically valid review id. +func TestBuildRejectsCrossOrgReview(t *testing.T) { + db := testDB(t) + + // The live dev server's background dashboard-cache refresh job may insert a + // row for a new org before the test ends; that FK has no cascade, so it + // must be cleared before the org delete or the delete silently no-ops. + cleanupOrg := func(orgID int64) { + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM dashboard_cache WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM orgs WHERE id = $1`, orgID) + }) + } + + var orgAID, orgBID int64 + if err := db.QueryRow(`INSERT INTO orgs (name) VALUES ('canonicaldoc-org-a') RETURNING id`).Scan(&orgAID); err != nil { + t.Fatalf("insert org a: %v", err) + } + cleanupOrg(orgAID) + if err := db.QueryRow(`INSERT INTO orgs (name) VALUES ('canonicaldoc-org-b') RETURNING id`).Scan(&orgBID); err != nil { + t.Fatalf("insert org b: %v", err) + } + cleanupOrg(orgBID) + + var reviewID int64 + if err := db.QueryRow(`INSERT INTO reviews (repository, status, org_id, metadata) VALUES ('a/repo', 'completed', $1, '{}'::jsonb) RETURNING id`, orgAID).Scan(&reviewID); err != nil { + t.Fatalf("insert review: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM reviews WHERE id = $1`, reviewID) }) + + if _, err := Build(context.Background(), db, orgBID, reviewID); err == nil { + t.Fatalf("expected Build to fail when orgID doesn't own the review") + } + + doc, err := Build(context.Background(), db, orgAID, reviewID) + if err != nil { + t.Fatalf("Build with the owning org failed: %v", err) + } + if doc.ReviewID != reviewID || doc.OrgID != orgAID { + t.Fatalf("unexpected doc: %+v", doc) + } + for _, sev := range KnownSeverities { + if _, ok := doc.Counts.BySeverity[sev]; !ok { + t.Fatalf("expected by_severity to be zero-filled for %q", sev) + } + } +} + +func TestRunJQBoolTruthiness(t *testing.T) { + doc := &ReviewDoc{Counts: Counts{BySeverity: map[string]int{"critical": 1}}} + + blocked, _, err := RunJQBool(".counts.by_severity.critical > 0", doc) + if err != nil { + t.Fatalf("RunJQBool: %v", err) + } + if !blocked { + t.Fatalf("expected blocked=true for a critical finding") + } + + blocked, _, err = RunJQBool(".counts.by_severity.critical > 5", doc) + if err != nil { + t.Fatalf("RunJQBool: %v", err) + } + if blocked { + t.Fatalf("expected blocked=false when the condition doesn't hold") + } + + if _, _, err := RunJQBool("not valid jq [[", doc); err == nil { + t.Fatalf("expected an error for an invalid jq expression") + } +} diff --git a/internal/docindex/docs/.synced-commits.env b/internal/docindex/docs/.synced-commits.env index b8a9278c5..5bf3f8f31 100644 --- a/internal/docindex/docs/.synced-commits.env +++ b/internal/docindex/docs/.synced-commits.env @@ -1,4 +1,4 @@ GIT_LRC_COMMIT=1112c13df101abe2c0619ade06a652f1441a58f0 GIT_LRC_WIKI_COMMIT=a1bb17c392a46c1f3bf6cfad6396813ccf80a1a9 -HEXMOSHOMEPAGE_DOCS_COMMIT=49c80fb9bfd1a1416b3d31edc561f8ffcc0644c6 +HEXMOSHOMEPAGE_DOCS_COMMIT=306c7b807d0e1f9bbfc2366f7300e2363c56e0a1 LIVEREVIEW_WIKI_COMMIT=58ee4a1060bf109d889dd2cc969a776fb337d2f7 diff --git a/internal/docindex/docs/routes_guide/explore/repository-settings.md b/internal/docindex/docs/routes_guide/explore/repository-settings.md index b1d5af087..744a0bee4 100644 --- a/internal/docindex/docs/routes_guide/explore/repository-settings.md +++ b/internal/docindex/docs/routes_guide/explore/repository-settings.md @@ -23,6 +23,9 @@ admin. next PR review. If a review is using it right now, clearing is refused until it finishes. When the repo cache is turned off for the server, the section says so and admins get a link to Settings → Storage → Repo Cache. +- **Auto Approve** (`#auto-approve`) — override this repository's auto-approve + policy (inherit the org default, or set a repo-specific mode and risk + threshold: Conservative, Balanced, Aggressive or Custom). See [Settings → Auto Approve](../settings/auto-approve.md). ## Related pages diff --git a/internal/docindex/docs/routes_guide/reports/auto-approve.md b/internal/docindex/docs/routes_guide/reports/auto-approve.md new file mode 100644 index 000000000..304ef1c98 --- /dev/null +++ b/internal/docindex/docs/routes_guide/reports/auto-approve.md @@ -0,0 +1,51 @@ +# Reports → Auto Approve + +**Route:** `/reports/auto-approve` +**Component:** `ui/src/pages/Reports/AutoApproveReport.tsx` +**API:** `GET /api/v1/auto-approve-insights?window=7|30|90` +(`internal/api/auto_approve_insights_handlers.go`) + +## Purpose + +The "is the risk-based auto-approve policy behaving reasonably" view. The +per-review explain panel (`AutoApprovePanel.tsx`, on a review's detail page) +answers "why did this one PR get approved or blocked" — this page answers +the aggregate question across an org's real traffic, over a selectable +window: where real review scores land, how many PRs were approved/blocked/ +overridden, and — once production outcomes are reported — how often an +approval turned out to be wrong. + +## Sections + +| Section | Covers | +|---|---| +| Score distribution | A histogram of every review's computed risk score in the window, with the org's current percentile cutoff highlighted, so an admin can see directly how many real PRs fall on each side of the threshold before changing it. | +| Decisions | KPI counts (approved/blocked/overridden, override rate) plus a per-stage block breakdown (eligibility/static/risk/LLM) and a day-bucketed approved-vs-blocked trend chart. | +| Calibration | Reported production outcomes (`revert`/`incident`/`confirmed_safe`) cross-tabbed against what the policy actually decided, surfacing a false-approve rate. Shows a low-volume warning below 10 reported outcomes rather than presenting a rate that isn't yet meaningful. | + +## Who can access it + +Owner/admin only (`canManageCurrentOrg` client-side gate; the API's +`/auto-approve-insights` route requires org access via the standard +`RequireAuthOrAPIKey` → `BuildOrgContextFromHeader` → `ValidateOrgAccess` → +`BuildPermissionContext` chain, scoped strictly to the caller's `org_id`). + +## How to get here + +- Mega menu: **Reports → Auto Approve**. +- From **Settings → Auto Approve Policy → Production Feedback**, via the + "View full report" link. +- Directly at `/reports/auto-approve`. + +## Key actions + +- Switch the window (7/30/90 days) to see more or less history. +- Read the score histogram against the current threshold before adjusting + the risk percentile in Settings → Risk Model. +- Watch the calibration table as outcomes accumulate to judge whether the + policy's false-approve rate is acceptable. + +## Related pages + +[Settings → Auto Approve Policy](../settings/auto-approve.md), +[Reports → Taxonomy Reports](taxonomy-reports.md) diff --git a/internal/docindex/docs/routes_guide/reports/taxonomy-reports.md b/internal/docindex/docs/routes_guide/reports/taxonomy-reports.md index 9996bce9f..5394bd8f1 100644 --- a/internal/docindex/docs/routes_guide/reports/taxonomy-reports.md +++ b/internal/docindex/docs/routes_guide/reports/taxonomy-reports.md @@ -77,6 +77,7 @@ per-repository/provider breakdown, and category↔subcategory relations. ## Related pages [Reports → Onboarding Report](onboarding-report.md), +[Reports → Auto Approve](auto-approve.md), [Reviews list](../reviews/reviews-list.md), [Review detail](../reviews/review-detail.md) diff --git a/internal/docindex/docs/routes_guide/reviews/pr-review-link.md b/internal/docindex/docs/routes_guide/reviews/pr-review-link.md new file mode 100644 index 000000000..0d7fb49d3 --- /dev/null +++ b/internal/docindex/docs/routes_guide/reviews/pr-review-link.md @@ -0,0 +1,56 @@ +# PR Review Link (start a review from a PR description link) + +**Route:** `/pr/:id/review` +**Component:** `ui/src/pages/Reviews/PullRequestReviewTrigger.tsx` + +## Purpose + +When a new pull/merge request is opened on GitHub, GitLab, Bitbucket, Gitea, or Azure DevOps in a repo with the +LiveReview webhook installed, LiveReview adds a section with two links to the end of its description: + +- **Start a review** → this page. It starts a LiveReview review of that PR and + opens the review's detail page. +- **Review history** → the [Reviews list](reviews-list.md) filtered to that + PR (`/reviews?pull_request_id=`). + +`:id` is LiveReview's internal pull request id, not the GitHub/GitLab PR number. +The section is added once per PR, only when it's opened (not for PRs that +already existed, reopens, or pushes). + +## Who can access it + +Any authenticated member of the org the PR belongs to. Logged-out users are +asked to log in and then sent back to the link. If the user's active org is a +different one, LiveReview switches to the PR's org automatically. Users who +aren't members of that org see "pull request wasn't found". + +## Key actions + +- Opening the link starts a review right away; there is no confirm step. +- If a review of the same PR started less than 10 minutes ago and is still + running, the link opens that review instead of starting another one. +- Each later click starts a new review (counts against the org's review quota). +- When a review started from this link finishes, LiveReview runs the org's + [auto-approve policy](../settings/auto-approve.md) on it. The result is shown + in two places: + - **PR description:** under the two links, an "Auto-approval decisions" + table with one row per policy-checked review, latest first: Review (link), + Decision (✅ Approved / ❌ Not approved), and Reason (the failing + stage and why, or "All policy checks passed"). + - **Separate PR comment:** the verdict plus a stage-by-stage breakdown + (eligibility, static rules, risk score, LLM classification, final decision). + How the verdict is applied depends on the provider: + - GitHub / Gitea: an approve or "Request changes" review (a plain comment + if the provider refuses, e.g. the bot authored the PR). + - GitLab: a comment; the MR is approved when it passes. + - Bitbucket: a comment, then Approve or "Request changes". + - Azure DevOps: a comment thread, then the bot votes Approved or + "Waiting for author". + - The decisions table shows the last 10 policy-checked reviews. +- Reviews started any other way (reviewer assignment, @mentions, Explore) don't + run the policy check. + +## Related pages + +- [Reviews (list)](reviews-list.md) +- [Review Detail](review-detail.md) diff --git a/internal/docindex/docs/routes_guide/reviews/review-detail.md b/internal/docindex/docs/routes_guide/reviews/review-detail.md index 8cd7b93b3..e1b1fcc3e 100644 --- a/internal/docindex/docs/routes_guide/reviews/review-detail.md +++ b/internal/docindex/docs/routes_guide/reviews/review-detail.md @@ -25,6 +25,20 @@ Any authenticated org member; the review must belong to their org - View the commits included in this review (up to a preview limit, expandable). - Navigate back to the reviews list. +## Auto Approve tab + +Reviews started from a PR/MR "Start a review" link show an **Auto Approve** tab +that renders the risk-policy funnel stage-by-stage (eligibility → static rules +→ risk score → LLM classification → final decision), each with its pass/fail +reason. If the page is open when a review finishes, it switches to Findings and +a pulsing dot (green = approved, red = not approved) appears on this tab until +you open it. The info bar also shows an **Auto-approve** stat next to Risk +assessment: Approved, Not approved, or Pending. For a blocked review, owners/admins (or users at/above the policy's +`force_approve_min_level`) can **force approve** with a reason — recorded in the +audit log and posted to the PR/MR. Any member can **mark a production outcome** +(revert / incident / confirmed-safe) with a reason, which feeds the risk model. +See [Settings → Auto Approve](../settings/auto-approve.md). + ## Blast-radius scoring and ordering Reviews run through the git-lrc CLI upload their own blast-radius report. PR diff --git a/internal/docindex/docs/routes_guide/reviews/reviews-list.md b/internal/docindex/docs/routes_guide/reviews/reviews-list.md index 47debe750..332252f4a 100644 --- a/internal/docindex/docs/routes_guide/reviews/reviews-list.md +++ b/internal/docindex/docs/routes_guide/reviews/reviews-list.md @@ -20,10 +20,15 @@ Any authenticated org member. - Click a row to open [Review Detail](review-detail.md). - Start a new review via "New Review" (`/reviews/new`). - Navigate to scheduled reviews (`/reviews/scheduled`). +- View one pull request's review history: `/reviews?pull_request_id=` + (the "View review history" link LiveReview adds to the description of new GitHub/GitLab + PRs). Switches to the PR's org if needed. A "Filtered to one pull request" + chip shows above the table; click **Clear** to see all reviews again. ## Related pages - [Review Detail](review-detail.md) +- [PR Review Link](pr-review-link.md) - [New Review](new-review.md) - [Scheduled Reviews](scheduled-reviews.md) - [CI/CD Gates](ci-cd-gates.md) diff --git a/internal/docindex/docs/routes_guide/settings/auto-approve.md b/internal/docindex/docs/routes_guide/settings/auto-approve.md new file mode 100644 index 000000000..05b6706a3 --- /dev/null +++ b/internal/docindex/docs/routes_guide/settings/auto-approve.md @@ -0,0 +1,68 @@ +# Settings → Auto Approve + +**Route:** `/settings#auto-approve` +**Who sees it:** org **owner** (or super_admin) +(`canManageCurrentOrg` in `ui/src/pages/Settings/Settings.tsx`). + +## Purpose + +Configure the risk-based auto-approve policy that decides, after every review +started from a PR/MR "Start a review" link, whether the review is automatically +approved or changes are requested — without a human clicking approve. See +`docs/risk-policy-engine.md` for the full design, and +`internal/policyengine` for the engine. + +The decision is a funnel, evaluated in order, each stage recorded in +`review_policy_log` and rendered in the review's **Auto Approve** tab: + +1. **Eligibility** — the PR author must map to a LiveReview user (via their + Git username/email, matched through `user_identities`) with a + `review_level` at or above the policy's `min_author_level`. Explicitly + whitelisted users pass regardless of level; bots default to level 0. +2. **Static rules** — the configured CI/CD rulesets (`ci_rulesets`) run as + hard blocking gates (they also run under "always approve"). +3. **Risk score** — a trained logistic model scores the review and blocks it + if the score is at or above the configured percentile of the org's score + distribution. +4. **LLM classification** — the review LLM's diff-level `diffClassification` + must have confidence at/above the required level and no disqualifying + class (security-sensitive). + +## Layout + +Five sub-tabs: + +- **Risk Model** — the active model version + training-sample count (model + confidence), category weight overrides (blast radius / security / critical / + diff size, instant effect), a manual **Refit model now** button, and a + **Simulate on recent reviews** replay of the draft policy. +- **Static Rules** — pick which CI rulesets are blocking gates. +- **Eligibility** — `min_author_level` and `force_approve_min_level`. +- **General** — risk threshold (Conservative = safest 20%, Balanced = 50%, + Aggressive = 80%, or Custom for any other number), required LLM confidence, landing + delay (human-rejection window), daily approve cap, and the + "restrict outcome reporting to admins" switch. +- **Production Feedback** — the outcome webhook URL and the last-30-days + outcome counter. + +## Key actions + +- **Save policy** upserts the organization-default policy (a single row in + `auto_approve_policies` with `repository_id IS NULL`). Individual repos can + override it from [repository settings](../explore/repository-settings.md). +- **Refit model now** runs a batch refit over all labeled outcomes and + promotes it only if its AUC is at least as good as the current model. +- **Simulate** replays the draft against recent completed reviews (dry-run, + nothing persisted). +- Report production outcomes (revert / incident / confirmed-safe) manually + from a review's **Auto Approve** tab, or POST them to the webhook. Each + non-test outcome nudges the model in real time. + +## Related pages + +[Settings overview](settings-overview.md) · +[CI Rulesets](../reviews/ci-cd-gates.md) · +[Repository settings](../explore/repository-settings.md) · +[Review detail](../reviews/review-detail.md) · +[Reports → Auto Approve](../reports/auto-approve.md) (score distribution, +decision counts, and outcome calibration for this policy, over time) diff --git a/internal/docindex/docs/routes_guide/settings/settings-overview.md b/internal/docindex/docs/routes_guide/settings/settings-overview.md index 6761dd02b..835505623 100644 --- a/internal/docindex/docs/routes_guide/settings/settings-overview.md +++ b/internal/docindex/docs/routes_guide/settings/settings-overview.md @@ -19,6 +19,7 @@ tabs. Which tabs are visible depends on the user's role | Deployment | `deployment` | super_admin or org owner | | License | `license` | super_admin or org owner, self-hosted only (tab absent in cloud) | | Prompts | `prompts` | super_admin, or org owner/member | +| Auto Approve | `auto-approve` | org owner (or super_admin) | | Learnings | `learnings` | any org member | | API Keys | `api-keys` | any org member | | MCP Integration | `mcp` | any org member | @@ -35,7 +36,8 @@ the `subscriptions` tab. Each tab has its own file in this folder: [Instance](instance.md), [SMTP](smtp.md), [Storage](storage.md), [Deployment](deployment.md), -[License](license.md), [Prompts](prompts.md), [Learnings](learnings.md), +[License](license.md), [Prompts](prompts.md), [Auto Approve](auto-approve.md), +[Learnings](learnings.md), [API Keys](api-keys.md), [MCP Integration](mcp-integration.md), [User Management](user-management.md), [Integrations](integrations.md), [Plan & Usage](plan-and-usage.md). diff --git a/internal/docindex/docs/routes_guide/settings/user-management.md b/internal/docindex/docs/routes_guide/settings/user-management.md index 2afb9af62..1e7892f19 100644 --- a/internal/docindex/docs/routes_guide/settings/user-management.md +++ b/internal/docindex/docs/routes_guide/settings/user-management.md @@ -22,7 +22,10 @@ License Assignment is part of onboarding someone. - Add a single user (`/settings/users/add`) or bulk-import users (`/settings/users/add/bulk`) — owner action. - Edit a user's role or details (`/settings/users/edit/:userId`) — owner - action. + action, including the user's **review level** (their seniority dial for + auto-approve eligibility; unmapped authors default to 1) and their + **alternative identities** (Git usernames/emails that map their provider + account to this LiveReview user). - Deactivate a user or force a password reset — owner action. - Non-owners see the roster read-only. diff --git a/internal/jobqueue/jobqueue.go b/internal/jobqueue/jobqueue.go index 9d8779c23..91c04c3bd 100644 --- a/internal/jobqueue/jobqueue.go +++ b/internal/jobqueue/jobqueue.go @@ -36,6 +36,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/livereview/internal/providers" "github.com/livereview/internal/providers/gitea" + "github.com/livereview/internal/policyengine" "github.com/livereview/internal/seed_demo" networkjobqueue "github.com/livereview/network/jobqueue" storagejobqueue "github.com/livereview/storage/jobqueue" @@ -2418,7 +2419,9 @@ func NewJobQueue(databaseURL string, db *sql.DB) (*JobQueue, error) { diffWorker := &DiffReviewWorker{db: db, pool: pool} prStore := pullrequests.NewStore(db) repoPRSyncWorker := &RepoPRSyncWorker{db: db, store: prStore} - prStateSyncWorker := &PRStateSyncWorker{db: db, store: prStore} + prStateSyncWorker := &PRStateSyncWorker{db: db, store: prStore, webhookStore: store} + prPolicyCheckWorker := &PRPolicyCheckWorker{db: db, webhookStore: store, engine: policyengine.NewEngine(db)} + prPolicyPublishWorker := &PRPolicyPublishWorker{db: db, webhookStore: store} reconciliationWorker := &ReconciliationSweepWorker{db: db, pool: pool, stalenessThreshold: config.RepoSyncConfig.StalenessThreshold} scheduledReviewWorker := &ScheduledReviewWorker{db: db} preloadedChangesArchivalWorker := &PreloadedChangesArchivalWorker{db: db} @@ -2434,12 +2437,16 @@ func NewJobQueue(databaseURL string, db *sql.DB) (*JobQueue, error) { river.AddWorker(workers, &UpdateOrgUsageWorker{db: db, pool: pool}) river.AddWorker(workers, repoPRSyncWorker) river.AddWorker(workers, prStateSyncWorker) + river.AddWorker(workers, prPolicyCheckWorker) + river.AddWorker(workers, prPolicyPublishWorker) + river.AddWorker(workers, &PRPolicyForceApproveWorker{db: db, webhookStore: store}) river.AddWorker(workers, reconciliationWorker) river.AddWorker(workers, preloadedChangesArchivalWorker) river.AddWorker(workers, preloadedChangesArchivalSweepWorker) river.AddWorker(workers, preloadedChangesArchivalPurgeWorker) river.AddWorker(workers, seedDemoWorker) river.AddWorker(workers, &BlastRadiusWorker{db: db}) + river.AddWorker(workers, &RiskModelRefitWorker{db: db}) coordinatorInterval := config.RepoSyncConfig.CoordinatorInterval if coordinatorInterval <= 0 { @@ -2541,6 +2548,13 @@ func NewJobQueue(databaseURL string, db *sql.DB) (*JobQueue, error) { }, &river.PeriodicJobOpts{RunOnStart: false}, ), + river.NewPeriodicJob( + river.PeriodicInterval(24*time.Hour), + func() (river.JobArgs, *river.InsertOpts) { + return RiskModelRefitJobArgs{}, &river.InsertOpts{Queue: "repo_sync", MaxAttempts: 3} + }, + &river.PeriodicJobOpts{ID: "risk_model_refit", RunOnStart: false}, + ), river.NewPeriodicJob( archivalSchedule, func() (river.JobArgs, *river.InsertOpts) { @@ -2574,6 +2588,7 @@ func NewJobQueue(databaseURL string, db *sql.DB) (*JobQueue, error) { diffWorker.jq = jq reconciliationWorker.jq = jq scheduledReviewWorker.jq = jq + prPolicyCheckWorker.jq = jq return jq, nil } diff --git a/internal/jobqueue/pr_policy_claim_sql_test.go b/internal/jobqueue/pr_policy_claim_sql_test.go new file mode 100644 index 000000000..fd19cb2e4 --- /dev/null +++ b/internal/jobqueue/pr_policy_claim_sql_test.go @@ -0,0 +1,85 @@ +package jobqueue + +import ( + "context" + "database/sql" + "os" + "testing" + + _ "github.com/lib/pq" + "github.com/riverqueue/river" +) + +// Regression test: PRPolicyPublishWorker and PRPolicyForceApproveWorker once +// built their idempotency-claim UPDATE with a malformed string +// (`'{"key":"'" || now() || "'"}'::jsonb`), which is a Postgres syntax error +// on every single invocation — Force Approve and the landing-delay publish +// path never actually posted anything, though the audit trail looked fine. +// This runs the real worker against a real Postgres DB so the claim +// statement is actually executed, not just read. +func testDB(t *testing.T) *sql.DB { + if testing.Short() { + t.Skip("skipping database integration test") + } + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + dsn = "postgres://livereview:livereview_password_123@localhost:5432/livereview?sslmode=disable" + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Ping(); err != nil { + t.Skipf("db not reachable: %v", err) + } + return db +} + +func seedPolicyTestOrgAndReview(t *testing.T, db *sql.DB) (orgID, reviewID int64) { + if err := db.QueryRow(`INSERT INTO orgs (name) VALUES ('pr-policy-claim-sql-test-org') RETURNING id`).Scan(&orgID); err != nil { + t.Fatalf("insert org: %v", err) + } + t.Cleanup(func() { + // The live dev server's background dashboard-cache refresh job may insert + // a row for this org before cleanup runs; that FK has no cascade, so it + // must be cleared first or the org delete silently no-ops. + _, _ = db.Exec(`DELETE FROM dashboard_cache WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM orgs WHERE id = $1`, orgID) + }) + + if err := db.QueryRow(` + INSERT INTO reviews (repository, status, org_id, metadata) + VALUES ('test/repo', 'completed', $1, '{}'::jsonb) RETURNING id`, orgID).Scan(&reviewID); err != nil { + t.Fatalf("insert review: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM reviews WHERE id = $1`, reviewID) }) + return orgID, reviewID +} + +func TestPRPolicyPublishWorkerClaimSQLIsValid(t *testing.T) { + db := testDB(t) + orgID, reviewID := seedPolicyTestOrgAndReview(t, db) + + w := &PRPolicyPublishWorker{db: db} + job := &river.Job[PRPolicyPublishJobArgs]{Args: PRPolicyPublishJobArgs{OrgID: orgID, ReviewID: reviewID}} + + // No pull_request_id on this review, so Work runs the claim UPDATE (exactly + // the statement that used to be a SQL syntax error), then releases the + // claim and returns nil since there's nothing to publish to. + if err := w.Work(context.Background(), job); err != nil { + t.Fatalf("Work() returned an error (claim SQL likely broken again): %v", err) + } +} + +func TestPRPolicyForceApproveWorkerClaimSQLIsValid(t *testing.T) { + db := testDB(t) + orgID, reviewID := seedPolicyTestOrgAndReview(t, db) + + w := &PRPolicyForceApproveWorker{db: db} + job := &river.Job[PRPolicyForceApproveJobArgs]{Args: PRPolicyForceApproveJobArgs{OrgID: orgID, ReviewID: reviewID, ActorUserID: 0, Reason: "test"}} + + if err := w.Work(context.Background(), job); err != nil { + t.Fatalf("Work() returned an error (claim SQL likely broken again): %v", err) + } +} diff --git a/internal/jobqueue/pr_policy_force_approve.go b/internal/jobqueue/pr_policy_force_approve.go new file mode 100644 index 000000000..c6424a777 --- /dev/null +++ b/internal/jobqueue/pr_policy_force_approve.go @@ -0,0 +1,118 @@ +package jobqueue + +import ( + "context" + "database/sql" + "fmt" + "log" + + providergithub "github.com/livereview/internal/providers/github" + providergitlab "github.com/livereview/internal/providers/gitlab" + storagejobqueue "github.com/livereview/storage/jobqueue" + "github.com/riverqueue/river" +) + +// PRPolicyForceApproveJobArgs posts a force-approval to the PR/MR after a human +// overrode a blocked policy verdict (the review_policy_overrides row is written +// by the API handler before this job is enqueued). +type PRPolicyForceApproveJobArgs struct { + OrgID int64 `json:"org_id"` + ReviewID int64 `json:"review_id"` + ActorUserID int64 `json:"actor_user_id"` + Reason string `json:"reason"` +} + +func (PRPolicyForceApproveJobArgs) Kind() string { return "pr_policy_force_approve" } + +type PRPolicyForceApproveWorker struct { + river.WorkerDefaults[PRPolicyForceApproveJobArgs] + db *sql.DB + webhookStore *storagejobqueue.WebhookStore +} + +func (w *PRPolicyForceApproveWorker) Work(ctx context.Context, job *river.Job[PRPolicyForceApproveJobArgs]) error { + args := job.Args + + // Idempotency claim so River retries don't approve twice. + res, err := w.db.ExecContext(ctx, + `UPDATE reviews SET metadata = metadata || jsonb_build_object('policy_force_published_at', now()) + WHERE id = $1 AND org_id = $2 AND NOT (metadata ? 'policy_force_published_at')`, + args.ReviewID, args.OrgID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return nil + } + release := func() { + _, _ = w.db.ExecContext(ctx, `UPDATE reviews SET metadata = metadata - 'policy_force_published_at' WHERE id = $1`, args.ReviewID) + } + + var pullRequestID sql.NullInt64 + if err := w.db.QueryRowContext(ctx, + `SELECT pull_request_id FROM reviews WHERE id = $1 AND org_id = $2`, + args.ReviewID, args.OrgID).Scan(&pullRequestID); err != nil { + release() + return err + } + if !pullRequestID.Valid { + release() + return nil + } + + var repositoryID int64 + var number int + if err := w.db.QueryRowContext(ctx, + `SELECT repository_id, number FROM pull_requests WHERE id = $1 AND org_id = $2`, + pullRequestID.Int64, args.OrgID).Scan(&repositoryID, &number); err != nil { + release() + return err + } + info, err := loadRepositoryConnectorInfo(w.db, repositoryID) + if err != nil { + release() + return err + } + + actorName := fmt.Sprintf("user %d", args.ActorUserID) + if args.ActorUserID > 0 { + var email string + if err := w.db.QueryRowContext(ctx, `SELECT email FROM users WHERE id = $1`, args.ActorUserID).Scan(&email); err == nil && email != "" { + actorName = email + } + } + + body := fmt.Sprintf("## ⚠️ LiveReview policy: Force-approved\n\n"+ + "Force-approved by **%s** despite a policy block.\n\n"+ + "**Reason:** %s\n\n"+ + "This override is recorded in LiveReview's audit log.\n", actorName, args.Reason) + + switch info.Provider { + case "github", "github-com", "github-enterprise": + if err := providergithub.SubmitPullRequestReview(ctx, info.ProviderURL, info.token(), info.FullName, number, "APPROVE", body); err != nil { + log.Printf("[WARN] pr policy force-approve: APPROVE failed for review %d, posting as comment: %v", args.ReviewID, err) + return providergithub.PostIssueComment(ctx, info.ProviderURL, info.token(), info.FullName, number, body) + } + return nil + case "gitlab", "gitlab-com", "gitlab-self-hosted": + client := providergitlab.NewHTTPClient(info.ProviderURL, info.token()) + if err := client.CreateMRGeneralComment(info.ProviderRepoID, number, body); err != nil { + return err + } + if err := client.ApproveMR(info.ProviderRepoID, number); err != nil { + log.Printf("[WARN] pr policy force-approve: approve failed for review %d: %v", args.ReviewID, err) + } + return nil + default: + return nil + } +} + +// QueuePRPolicyForceApprove enqueues the force-approve publish. +func (jq *JobQueue) QueuePRPolicyForceApprove(ctx context.Context, args PRPolicyForceApproveJobArgs) error { + _, err := jq.client.Insert(ctx, args, &river.InsertOpts{Queue: "repo_sync", MaxAttempts: 5}) + if err != nil { + return fmt.Errorf("failed to queue force-approve: %w", err) + } + return nil +} diff --git a/internal/jobqueue/pr_policy_worker.go b/internal/jobqueue/pr_policy_worker.go new file mode 100644 index 000000000..110822801 --- /dev/null +++ b/internal/jobqueue/pr_policy_worker.go @@ -0,0 +1,360 @@ +package jobqueue + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + "time" + + "github.com/livereview/internal/policyengine" + providerbitbucket "github.com/livereview/internal/providers/bitbucket" + providergitea "github.com/livereview/internal/providers/gitea" + providergithub "github.com/livereview/internal/providers/github" + providergitlab "github.com/livereview/internal/providers/gitlab" + storagejobqueue "github.com/livereview/storage/jobqueue" + "github.com/riverqueue/river" +) + +// PRPolicyCheckJobArgs runs the risk-based approval policy on a finished review +// that was started from the PR comment's "Start a review" link +// (metadata.pr_link_policy). See docs/risk-policy-engine.md. +type PRPolicyCheckJobArgs struct { + OrgID int64 `json:"org_id"` + ReviewID int64 `json:"review_id"` +} + +func (PRPolicyCheckJobArgs) Kind() string { return "pr_policy_check" } + +type PRPolicyCheckWorker struct { + river.WorkerDefaults[PRPolicyCheckJobArgs] + db *sql.DB + webhookStore *storagejobqueue.WebhookStore + engine *policyengine.Engine + jq *JobQueue +} + +func (w *PRPolicyCheckWorker) Work(ctx context.Context, job *river.Job[PRPolicyCheckJobArgs]) error { + args := job.Args + + var status string + var pullRequestID sql.NullInt64 + var flagged bool + err := w.db.QueryRowContext(ctx, ` + SELECT status, pull_request_id, + COALESCE((metadata->>'pr_link_policy')::boolean, false) + FROM reviews WHERE id = $1 AND org_id = $2`, + args.ReviewID, args.OrgID, + ).Scan(&status, &pullRequestID, &flagged) + if err == sql.ErrNoRows { + return nil + } + if err != nil { + return fmt.Errorf("failed to load review %d: %w", args.ReviewID, err) + } + if status != "completed" || !pullRequestID.Valid || !flagged { + return nil + } + + // Claim: the policy_result key doubles as an idempotency guard so River + // retries never evaluate (or approve) twice. + claim := `{"status":"evaluating"}` + res0, err := w.db.ExecContext(ctx, + `UPDATE reviews SET metadata = metadata || jsonb_build_object('policy_result', $2::jsonb) + WHERE id = $1 AND NOT (metadata ? 'policy_result')`, + args.ReviewID, claim) + if err != nil { + return fmt.Errorf("failed to claim policy check for review %d: %w", args.ReviewID, err) + } + if n, _ := res0.RowsAffected(); n == 0 { + return nil // already evaluated + } + release := func() { + if _, e := w.db.ExecContext(ctx, `UPDATE reviews SET metadata = metadata - 'policy_result' WHERE id = $1`, args.ReviewID); e != nil { + log.Printf("[WARN] pr policy: failed to release claim for review %d: %v", args.ReviewID, e) + } + } + + res, err := w.engine.Run(ctx, args.OrgID, args.ReviewID, false) + if errors.Is(err, policyengine.ErrPolicyDisabled) { + release() + return nil // feature off; nothing to post + } + if err != nil { + release() + return fmt.Errorf("policy evaluation failed for review %d: %w", args.ReviewID, err) + } + + // Persist the funnel result (the policy-log table is the rendering source of + // truth; this JSON is what the landing-delay publish job re-reads). + resultJSON, _ := json.Marshal(res) + if _, err := w.db.ExecContext(ctx, + `UPDATE reviews SET metadata = metadata || jsonb_build_object('policy_result', $2::jsonb) WHERE id = $1`, + args.ReviewID, string(resultJSON)); err != nil { + release() + return fmt.Errorf("failed to store policy result for review %d: %w", args.ReviewID, err) + } + + // Landing delay: instead of publishing immediately, queue a follow-up publish + // job scheduled after the human-rejection window. + if res.Approved && res.LandingDelaySeconds > 0 { + if err := w.queuePublish(ctx, args.OrgID, args.ReviewID, time.Duration(res.LandingDelaySeconds)*time.Second); err != nil { + release() + return err + } + return nil + } + + if err := w.publish(ctx, args, pullRequestID.Int64, res); err != nil { + release() + return err + } + return nil +} + +// queuePublish enqueues the landing-delay publish follow-up. +func (w *PRPolicyCheckWorker) queuePublish(ctx context.Context, orgID, reviewID int64, delay time.Duration) error { + if w.jq == nil { + return fmt.Errorf("job queue unavailable for landing-delay publish") + } + _, err := w.jq.client.Insert(ctx, PRPolicyPublishJobArgs{OrgID: orgID, ReviewID: reviewID}, + &river.InsertOpts{ + Queue: "repo_sync", + MaxAttempts: 5, + ScheduledAt: time.Now().Add(delay), + }) + if err != nil { + return fmt.Errorf("failed to queue landing-delay publish: %w", err) + } + return nil +} + +// publish posts the policy comment and approves (or requests changes on) the PR/MR. +func (w *PRPolicyCheckWorker) publish(ctx context.Context, args PRPolicyCheckJobArgs, pullRequestID int64, res *policyengine.FunnelResult) error { + var repositoryID int64 + var number int + if err := w.db.QueryRowContext(ctx, + `SELECT repository_id, number FROM pull_requests WHERE id = $1 AND org_id = $2`, + pullRequestID, args.OrgID, + ).Scan(&repositoryID, &number); err != nil { + return fmt.Errorf("failed to load pull request %d: %w", pullRequestID, err) + } + info, err := loadRepositoryConnectorInfo(w.db, repositoryID) + if err != nil { + return fmt.Errorf("failed to load connector for repository %d: %w", repositoryID, err) + } + host, err := w.webhookStore.GetWebhookPublicEndpoint(ctx) + if err != nil { + log.Printf("[WARN] pr policy: public URL not configured, posting without review link: %v", err) + } + body := buildPolicySummary(res) + "\n\n" + buildPolicyDetails(host, args.ReviewID, res) + if host != "" { + // The description's review history table is a convenience; don't fail the publish over it. + if err := refreshPRSection(ctx, w.db, info, host, pullRequestID, number); err != nil { + log.Printf("[WARN] pr policy: failed to update PR description for review %d: %v", args.ReviewID, err) + } + } + + switch info.Provider { + case "github", "github-com", "github-enterprise": + event := "REQUEST_CHANGES" + if res.Approved { + event = "APPROVE" + } + if err := providergithub.SubmitPullRequestReview(ctx, info.ProviderURL, info.token(), info.FullName, number, event, body); err != nil { + // GitHub refuses approve/request-changes when the bot authored the PR; still show the result. + log.Printf("[WARN] pr policy: %s failed for review %d, posting as comment: %v", event, args.ReviewID, err) + return providergithub.PostIssueComment(ctx, info.ProviderURL, info.token(), info.FullName, number, body) + } + return nil + case "gitlab", "gitlab-com", "gitlab-self-hosted": + client := providergitlab.NewHTTPClient(info.ProviderURL, info.token()) + if err := client.CreateMRGeneralComment(info.ProviderRepoID, number, body); err != nil { + return err + } + // GitLab Free has no "request changes", so a failed policy is comment-only. + if res.Approved { + if err := client.ApproveMR(info.ProviderRepoID, number); err != nil { + log.Printf("[WARN] pr policy: approve failed for review %d: %v", args.ReviewID, err) + } + } + return nil + case "gitea": + event := "REQUEST_CHANGES" + if res.Approved { + event = "APPROVED" + } + if err := providergitea.SubmitPullRequestReview(ctx, info.ProviderURL, info.token(), info.FullName, number, event, body); err != nil { + // Gitea refuses reviews on the bot's own PRs; still show the result. + log.Printf("[WARN] pr policy: %s failed for review %d, posting as comment: %v", event, args.ReviewID, err) + return providergitea.PostIssueComment(ctx, info.ProviderURL, info.token(), info.FullName, number, body) + } + return nil + case "bitbucket": + if err := providerbitbucket.PostPRComment(ctx, info.Email, info.token(), info.FullName, number, body); err != nil { + return err + } + if err := providerbitbucket.SetPRVerdict(ctx, info.Email, info.token(), info.FullName, number, res.Approved); err != nil { + log.Printf("[WARN] pr policy: bitbucket verdict failed for review %d: %v", args.ReviewID, err) + } + return nil + case "azuredevops": + client, err := azureClient(info) + if err != nil { + return err + } + if err := client.PostPRThread(ctx, info.FullName, number, body); err != nil { + return err + } + if err := client.SetPRVote(ctx, info.FullName, number, res.Approved); err != nil { + log.Printf("[WARN] pr policy: azure devops vote failed for review %d: %v", args.ReviewID, err) + } + return nil + default: + return nil + } +} + +// PRPolicyPublishJobArgs re-publishes the policy comment after the landing delay. +type PRPolicyPublishJobArgs struct { + OrgID int64 `json:"org_id"` + ReviewID int64 `json:"review_id"` +} + +func (PRPolicyPublishJobArgs) Kind() string { return "pr_policy_publish" } + +type PRPolicyPublishWorker struct { + river.WorkerDefaults[PRPolicyPublishJobArgs] + db *sql.DB + webhookStore *storagejobqueue.WebhookStore +} + +func (w *PRPolicyPublishWorker) Work(ctx context.Context, job *river.Job[PRPolicyPublishJobArgs]) error { + args := job.Args + + // Guard against double-posting. + res0, err := w.db.ExecContext(ctx, + `UPDATE reviews SET metadata = metadata || jsonb_build_object('policy_published_at', now()) + WHERE id = $1 AND org_id = $2 AND NOT (metadata ? 'policy_published_at')`, + args.ReviewID, args.OrgID) + if err != nil { + return err + } + if n, _ := res0.RowsAffected(); n == 0 { + return nil // already published + } + release := func() { + _, _ = w.db.ExecContext(ctx, `UPDATE reviews SET metadata = metadata - 'policy_published_at' WHERE id = $1`, args.ReviewID) + } + + var pullRequestID sql.NullInt64 + var resultJSON []byte + if err := w.db.QueryRowContext(ctx, + `SELECT pull_request_id, COALESCE(metadata->'policy_result', '{}'::jsonb) FROM reviews WHERE id = $1 AND org_id = $2`, + args.ReviewID, args.OrgID).Scan(&pullRequestID, &resultJSON); err != nil { + release() + return err + } + if !pullRequestID.Valid { + release() + return nil + } + var res policyengine.FunnelResult + if err := json.Unmarshal(resultJSON, &res); err != nil { + release() + return fmt.Errorf("failed to decode policy result for review %d: %w", args.ReviewID, err) + } + + checker := &PRPolicyCheckWorker{db: w.db, webhookStore: w.webhookStore} + if err := checker.publish(ctx, PRPolicyCheckJobArgs{OrgID: args.OrgID, ReviewID: args.ReviewID}, pullRequestID.Int64, &res); err != nil { + release() + return err + } + return nil +} + +// buildPolicySummary is the one-line verdict used as the approve / request-changes review text. +func buildPolicySummary(res *policyengine.FunnelResult) string { + if res.Approved { + return "✅ **LiveReview policy: Approved.**" + } + for _, st := range res.Stages { + if st.Verdict == policyengine.VerdictFail { + return fmt.Sprintf("❌ **LiveReview policy: Not approved** (blocked at %s).", titleCaseStage(st.Stage)) + } + } + return "❌ **LiveReview policy: Not approved.**" +} + +// buildPolicyDetails renders the funnel stage by stage for the policy comment, so it shows *why* the policy decided. +func buildPolicyDetails(host string, reviewID int64, res *policyengine.FunnelResult) string { + var b strings.Builder + verdict := "❌ Policy: Not approved" + if res.Approved { + verdict = "✅ Policy: Approved" + } + review := fmt.Sprintf("Review #%d", reviewID) + if host != "" { + review = fmt.Sprintf("[Review #%d](%s/#/reviews/%d)", reviewID, strings.TrimSuffix(host, "/"), reviewID) + } + fmt.Fprintf(&b, "#### %s · %s\n\n", verdict, review) + + b.WriteString("| Stage | Result | Detail |\n|---|---|---|\n") + for _, st := range res.Stages { + reason := "" + if r, ok := st.Detail["reason"].(string); ok { + reason = strings.ReplaceAll(r, "|", "\\|") + } + fmt.Fprintf(&b, "| %s | %s | %s |\n", titleCaseStage(st.Stage), stageIcon(st.Verdict), reason) + } + b.WriteString("\n") + return b.String() +} + +func stageIcon(verdict string) string { + switch verdict { + case policyengine.VerdictFail, policyengine.FinalBlocked: + return "❌" + case policyengine.VerdictSkip: + return "⏭️" + default: + return "✅" + } +} + +func titleCaseStage(s string) string { + switch s { + case "eligibility": + return "Eligibility" + case "static": + return "Static rules" + case "risk": + return "Risk score" + case "llm": + return "LLM classification" + case "final": + return "Final decision" + default: + return s + } +} + +// QueuePRPolicyCheckIfFlagged enqueues the policy check only for reviews started from the PR link. +func (jq *JobQueue) QueuePRPolicyCheckIfFlagged(ctx context.Context, orgID, reviewID int64) error { + var flagged bool + if err := jq.db.QueryRowContext(ctx, + `SELECT COALESCE((metadata->>'pr_link_policy')::boolean, false) FROM reviews WHERE id = $1 AND org_id = $2`, + reviewID, orgID, + ).Scan(&flagged); err != nil || !flagged { + return err + } + _, err := jq.client.Insert(ctx, PRPolicyCheckJobArgs{OrgID: orgID, ReviewID: reviewID}, + &river.InsertOpts{Queue: "repo_sync", MaxAttempts: 5}) + if err != nil { + return fmt.Errorf("failed to queue PR policy check job: %w", err) + } + return nil +} diff --git a/internal/jobqueue/repo_sync_worker.go b/internal/jobqueue/repo_sync_worker.go index 7b695246b..f09a0a543 100644 --- a/internal/jobqueue/repo_sync_worker.go +++ b/internal/jobqueue/repo_sync_worker.go @@ -3,16 +3,23 @@ package jobqueue import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "log" + "strings" "time" "github.com/jackc/pgx/v5/pgxpool" + "github.com/livereview/internal/policyengine" "github.com/livereview/internal/providers" + providerazure "github.com/livereview/internal/providers/azuredevops" + providerbitbucket "github.com/livereview/internal/providers/bitbucket" + providergitea "github.com/livereview/internal/providers/gitea" providergithub "github.com/livereview/internal/providers/github" providergitlab "github.com/livereview/internal/providers/gitlab" "github.com/livereview/internal/prsync" + storagejobqueue "github.com/livereview/storage/jobqueue" "github.com/livereview/storage/providers/pullrequests" "github.com/riverqueue/river" ) @@ -30,6 +37,7 @@ type repositoryConnectorInfo struct { ProviderURL string PATToken string AccessToken string + Email string // Bitbucket basic-auth user, from connector metadata } func (info repositoryConnectorInfo) token() string { @@ -43,12 +51,12 @@ func loadRepositoryConnectorInfo(db *sql.DB, repositoryID int64) (*repositoryCon var info repositoryConnectorInfo err := db.QueryRow(` SELECT r.id, r.org_id, r.connector_id, r.provider, r.provider_repo_id, r.full_name, r.last_synced_at, - i.provider_url, COALESCE(i.pat_token, ''), COALESCE(i.access_token, '') + i.provider_url, COALESCE(i.pat_token, ''), COALESCE(i.access_token, ''), COALESCE(i.metadata->>'email', '') FROM repositories r JOIN integration_tokens i ON i.id = r.connector_id WHERE r.id = $1 `, repositoryID).Scan(&info.RepositoryID, &info.OrgID, &info.ConnectorID, &info.Provider, &info.ProviderRepoID, - &info.FullName, &info.LastSyncedAt, &info.ProviderURL, &info.PATToken, &info.AccessToken) + &info.FullName, &info.LastSyncedAt, &info.ProviderURL, &info.PATToken, &info.AccessToken, &info.Email) if err != nil { return nil, err } @@ -196,8 +204,9 @@ func (PRStateSyncJobArgs) Kind() string { return "pr_state_sync" } type PRStateSyncWorker struct { river.WorkerDefaults[PRStateSyncJobArgs] - db *sql.DB - store *pullrequests.Store + db *sql.DB + store *pullrequests.Store + webhookStore *storagejobqueue.WebhookStore } func (w *PRStateSyncWorker) Work(ctx context.Context, job *river.Job[PRStateSyncJobArgs]) error { @@ -228,7 +237,7 @@ func (w *PRStateSyncWorker) Work(ctx context.Context, job *river.Job[PRStateSync args.ConnectorID, event.RepositoryProviderID, err) } - _, err = w.store.UpsertPullRequest(pullrequests.PullRequestUpsert{ + pullRequestID, err := w.store.UpsertPullRequest(pullrequests.PullRequestUpsert{ RepositoryID: repositoryID, OrgID: args.OrgID, Provider: args.Provider, @@ -252,9 +261,223 @@ func (w *PRStateSyncWorker) Work(ctx context.Context, job *river.Job[PRStateSync if err != nil { return fmt.Errorf("failed to upsert pull request from webhook event: %w", err) } + + if action, _ := event.Metadata["action"].(string); action == "opened" || action == "open" { + return w.postLinksComment(ctx, repositoryID, pullRequestID, event.Number) + } + return nil +} + +// The LiveReview section in a PR description sits between these markers so it can be replaced in place. +// They are markdown comments, which every provider hides (Bitbucket shows HTML comments as text). +const ( + prSectionStart = "[//]: # (livereview:links)" + prSectionEnd = "[//]: # (livereview:end)" + // Markers used before the switch, still matched so older sections get replaced. + legacySectionStart = "" + legacySectionEnd = "" +) + +// postLinksComment adds the "Start a review / Review history" links to the end of a new PR/MR's description. +// The links_comment_posted_at claim stops duplicate webhook deliveries from adding it twice. +func (w *PRStateSyncWorker) postLinksComment(ctx context.Context, repositoryID, pullRequestID int64, number int) error { + host, err := w.webhookStore.GetWebhookPublicEndpoint(ctx) + if err != nil || host == "" { + log.Printf("[WARN] pr links: skipping pull_request_id=%d, public URL not configured: %v", pullRequestID, err) + return nil + } + + res, err := w.db.ExecContext(ctx, + `UPDATE pull_requests SET links_comment_posted_at = now() WHERE id = $1 AND links_comment_posted_at IS NULL`, + pullRequestID) + if err != nil { + return fmt.Errorf("failed to claim pr links for pull_request_id=%d: %w", pullRequestID, err) + } + if n, _ := res.RowsAffected(); n == 0 { + return nil + } + + info, err := loadRepositoryConnectorInfo(w.db, repositoryID) + if err == nil { + err = writePRSection(ctx, info, number, buildPRLinksSection(host, pullRequestID, "")) + } + if err != nil { + // Release the claim so River's retry can add it. + if _, resetErr := w.db.ExecContext(ctx, `UPDATE pull_requests SET links_comment_posted_at = NULL WHERE id = $1`, pullRequestID); resetErr != nil { + log.Printf("[WARN] pr links: failed to release claim for pull_request_id=%d: %v", pullRequestID, resetErr) + } + return fmt.Errorf("failed to add pr links for pull_request_id=%d: %w", pullRequestID, err) + } return nil } +// buildPRLinksSection renders the LiveReview section; history is the review history table, or "". +func buildPRLinksSection(host string, pullRequestID int64, history string) string { + host = strings.TrimSuffix(host, "/") + return fmt.Sprintf(`%s + +--- +### 🔍 LiveReview + +- ▶️ **[Start a review](%s/#/pr/%d/review)**: reviews all changes in this PR against its target branch +- 📜 **[Review history](%s/#/reviews?pull_request_id=%d)**: all past reviews of this PR + +%s%s +`, prSectionStart, host, pullRequestID, host, pullRequestID, history, prSectionEnd) +} + +// refreshPRSection rewrites the LiveReview section with one history row per policy-checked review of this PR. +// Rows are rebuilt from the DB each time, so retries never duplicate them. +func refreshPRSection(ctx context.Context, db *sql.DB, info *repositoryConnectorInfo, host string, pullRequestID int64, number int) error { + rows, err := db.QueryContext(ctx, + // ponytail: only the last 10 rows, so the table stays under description size limits (Azure DevOps: 4000 chars). + `SELECT id, policy FROM ( + SELECT id, metadata->'policy_result' AS policy FROM reviews + WHERE pull_request_id = $1 AND metadata ? 'policy_result' ORDER BY id DESC LIMIT 10 + ) recent ORDER BY id DESC`, pullRequestID) + if err != nil { + return err + } + defer rows.Close() + + host = strings.TrimSuffix(host, "/") + var b strings.Builder + b.WriteString("#### Auto-approval decisions\n\n| Review | Decision | Reason |\n|---|---|---|\n") + for rows.Next() { + var reviewID int64 + var raw []byte + if err := rows.Scan(&reviewID, &raw); err != nil { + return err + } + var res policyengine.FunnelResult + if err := json.Unmarshal(raw, &res); err != nil { + continue + } + decision := "✅ Approved" + if !res.Approved { + decision = "❌ Not approved" + } + fmt.Fprintf(&b, "| [#%d](%s/#/reviews/%d) | %s | %s |\n", reviewID, host, reviewID, decision, strings.ReplaceAll(decisionReason(&res), "|", "\\|")) + } + if err := rows.Err(); err != nil { + return err + } + b.WriteString("\n") + return writePRSection(ctx, info, number, buildPRLinksSection(host, pullRequestID, b.String())) +} + +// decisionReason is the readable "why" for one decision row: each failing stage's reason, or that all passed. +func decisionReason(res *policyengine.FunnelResult) string { + if res.Approved { + return "All policy checks passed" + } + var parts []string + for _, st := range res.Stages { + if st.Verdict != policyengine.VerdictFail { + continue + } + reason, _ := st.Detail["reason"].(string) + if reason != "" { + reason = strings.ToUpper(reason[:1]) + reason[1:] + } + parts = append(parts, reason) + } + if len(parts) == 0 { + // Mode overrides (e.g. never_approve) block in the final stage only. + for _, st := range res.Stages { + if st.Stage == policyengine.StageFinal { + reason, _ := st.Detail["reason"].(string) + return reason + } + } + } + return strings.Join(parts, "; ") +} + +// upsertPRSection swaps the old LiveReview section in body for section, or appends section if there is none. +// Sections from before the end marker existed were always last, so they are replaced up to the end of body. +func upsertPRSection(body, section string) string { + startMarker, endMarker := prSectionStart, prSectionEnd + start := strings.Index(body, startMarker) + if start < 0 { + startMarker, endMarker = legacySectionStart, legacySectionEnd + start = strings.Index(body, startMarker) + } + if start < 0 { + if strings.TrimSpace(body) == "" { + return section + } + return strings.TrimRight(body, "\n") + "\n\n" + section + } + rest := "" + if end := strings.Index(body[start:], endMarker); end >= 0 { + rest = body[start+end+len(endMarker):] + section = strings.TrimRight(section, "\n") + } + return body[:start] + section + rest +} + +// writePRSection reads the PR/MR description and saves it with the LiveReview section updated. +func writePRSection(ctx context.Context, info *repositoryConnectorInfo, number int, section string) error { + var get func() (string, error) + var set func(string) error + switch info.Provider { + case "github", "github-com", "github-enterprise": + get = func() (string, error) { + return providergithub.GetPRBody(ctx, info.ProviderURL, info.token(), info.FullName, number) + } + set = func(b string) error { + return providergithub.SetPRBody(ctx, info.ProviderURL, info.token(), info.FullName, number, b) + } + case "gitlab", "gitlab-com", "gitlab-self-hosted": + client := providergitlab.NewHTTPClient(info.ProviderURL, info.token()) + get = func() (string, error) { + mr, err := client.GetMergeRequest(info.ProviderRepoID, number) + if err != nil { + return "", err + } + return mr.Description, nil + } + set = func(b string) error { return client.UpdateMRDescription(info.ProviderRepoID, number, b) } + case "gitea": + get = func() (string, error) { + return providergitea.GetPRBody(ctx, info.ProviderURL, info.token(), info.FullName, number) + } + set = func(b string) error { + return providergitea.SetPRBody(ctx, info.ProviderURL, info.token(), info.FullName, number, b) + } + case "bitbucket": + get = func() (string, error) { + return providerbitbucket.GetPRBody(ctx, info.Email, info.token(), info.FullName, number) + } + set = func(b string) error { + return providerbitbucket.SetPRBody(ctx, info.Email, info.token(), info.FullName, number, b) + } + case "azuredevops": + client, err := azureClient(info) + if err != nil { + return err + } + get = func() (string, error) { return client.GetPRDescription(ctx, info.FullName, number) } + set = func(b string) error { return client.SetPRDescription(ctx, info.FullName, number, b) } + default: + return nil + } + + body, err := get() + if err != nil { + return err + } + if next := upsertPRSection(body, section); next != body { + return set(next) + } + return nil +} + +func azureClient(info *repositoryConnectorInfo) (*providerazure.Provider, error) { + return providerazure.NewProvider(providerazure.Config{BaseURL: info.ProviderURL, Token: info.token()}) +} + // ReconciliationSweepJobArgs is the periodic coordinator job: it finds // repositories whose PR/MR data hasn't been synced recently (either never, or // staler than the configured threshold - which naturally excludes repos whose diff --git a/internal/jobqueue/review_worker.go b/internal/jobqueue/review_worker.go index c64927588..9998937df 100644 --- a/internal/jobqueue/review_worker.go +++ b/internal/jobqueue/review_worker.go @@ -89,7 +89,7 @@ func (w *ManualReviewWorker) Work(ctx context.Context, job *river.Job[ManualRevi log.Error().Msg("[review_worker] Database connection not available on JobQueue") return fmt.Errorf("database connection not available") } - return reviewprocessor.ProcessManualReview(ctx, w.jq.db, args.OrgID, args.PlanCode, args.ActorUserID, args.ActorEmail, args.ReviewID, args.RequestJSON, + err := reviewprocessor.ProcessManualReview(ctx, w.jq.db, args.OrgID, args.PlanCode, args.ActorUserID, args.ActorEmail, args.ReviewID, args.RequestJSON, func(ctx context.Context, model string, batch license.QuotaBatchInput, extraMeta map[string]interface{}) error { operationID := fmt.Sprintf("manual-review:%d", args.ReviewID) idempotencyKey := operationID @@ -109,6 +109,12 @@ func (w *ManualReviewWorker) Work(ctx context.Context, job *river.Job[ManualRevi }) }, ) + if err == nil { + if qErr := w.jq.QueuePRPolicyCheckIfFlagged(ctx, args.OrgID, args.ReviewID); qErr != nil { + log.Warn().Err(qErr).Int64("review_id", args.ReviewID).Msg("[review_worker] failed to queue PR policy check") + } + } + return err } // DiffReviewJobArgs represents the arguments for an asynchronous diff review job. @@ -326,6 +332,7 @@ func (w *DiffReviewWorker) Work(ctx context.Context, job *river.Job[DiffReviewJo summary := "" var comments []*models.ReviewComment var quiz []models.QuizQuestion + var diffClassification *models.DiffClassification failureReason := "" if result != nil { @@ -390,6 +397,7 @@ func (w *DiffReviewWorker) Work(ctx context.Context, job *river.Job[DiffReviewJo summary = result.Summary comments = result.Comments quiz = result.Quiz + diffClassification = result.DiffClassification } else { failureReason = "review processing returned no result" if logger != nil { @@ -400,11 +408,12 @@ func (w *DiffReviewWorker) Work(ctx context.Context, job *river.Job[DiffReviewJo // 10. Persist final results and update status type diffReviewResultPayload struct { - Summary string `json:"summary"` - Comments []*models.ReviewComment `json:"comments"` - Quiz []models.QuizQuestion `json:"quiz,omitempty"` + Summary string `json:"summary"` + Comments []*models.ReviewComment `json:"comments"` + Quiz []models.QuizQuestion `json:"quiz,omitempty"` + DiffClassification *models.DiffClassification `json:"diffClassification,omitempty"` } - payload := diffReviewResultPayload{Summary: summary, Comments: comments, Quiz: quiz} + payload := diffReviewResultPayload{Summary: summary, Comments: comments, Quiz: quiz, DiffClassification: diffClassification} meta := map[string]interface{}{"review_result": payload} if failureReason != "" { meta["failure_reason"] = failureReason diff --git a/internal/jobqueue/risk_model_refit_worker.go b/internal/jobqueue/risk_model_refit_worker.go new file mode 100644 index 000000000..08cfbc700 --- /dev/null +++ b/internal/jobqueue/risk_model_refit_worker.go @@ -0,0 +1,42 @@ +package jobqueue + +import ( + "context" + "database/sql" + + "github.com/livereview/internal/riskscore" + "github.com/riverqueue/river" +) + +// RiskModelRefitJobArgs runs the daily stabilizing batch refit over every org +// that has labeled outcomes (docs/risk-policy-engine.md §3, path 2). +type RiskModelRefitJobArgs struct{} + +func (RiskModelRefitJobArgs) Kind() string { return "risk_model_refit" } + +type RiskModelRefitWorker struct { + river.WorkerDefaults[RiskModelRefitJobArgs] + db *sql.DB +} + +func (w *RiskModelRefitWorker) Work(ctx context.Context, job *river.Job[RiskModelRefitJobArgs]) error { + rows, err := w.db.QueryContext(ctx, `SELECT DISTINCT org_id FROM risk_feature_snapshots`) + if err != nil { + return err + } + defer rows.Close() + + store := riskscore.NewStore(w.db) + for rows.Next() { + var orgID int64 + if err := rows.Scan(&orgID); err != nil { + return err + } + // Best-effort per org: one org failing shouldn't block the sweep. + if _, err := store.Refit(ctx, orgID); err != nil { + // log and continue + _ = err + } + } + return rows.Err() +} diff --git a/internal/policyengine/eligibility.go b/internal/policyengine/eligibility.go new file mode 100644 index 000000000..395f19732 --- /dev/null +++ b/internal/policyengine/eligibility.go @@ -0,0 +1,107 @@ +package policyengine + +import ( + "context" + "database/sql" + "strings" +) + +// AuthorInfo describes how the PR author was resolved to a review level. +type AuthorInfo struct { + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + Provider string `json:"provider,omitempty"` + IsBot bool `json:"is_bot"` + UserID int64 `json:"user_id,omitempty"` // 0 = unmapped to a LiveReview user + Level int `json:"level"` // resolved level (default 1 when unmapped) + Matched bool `json:"matched"` // a user_identities mapping was found +} + +// resolveAuthor maps the PR author to a LiveReview user (via user_identities) and +// resolves their review_level. Unmapped non-bot authors default to level 1 +// (fail-closed: the lowest bar); bots default to level 0. +func (e *Engine) resolveAuthor(ctx context.Context, orgID, reviewID int64) (*AuthorInfo, error) { + var prID sql.NullInt64 + if err := e.db.QueryRowContext(ctx, + `SELECT pull_request_id FROM reviews WHERE id = $1 AND org_id = $2`, reviewID, orgID).Scan(&prID); err != nil { + return nil, err + } + + var username, email, provider string + if prID.Valid { + var u, em, p sql.NullString + err := e.db.QueryRowContext(ctx, + `SELECT COALESCE(author_username, ''), COALESCE(author_email, ''), COALESCE(provider, '') + FROM pull_requests WHERE id = $1 AND org_id = $2`, prID.Int64, orgID).Scan(&u, &em, &p) + if err != nil && err != sql.ErrNoRows { + return nil, err + } + username, email, provider = u.String, em.String, p.String + } else { + // No linked PR: fall back to the review's own author fields. + var u, em, p sql.NullString + if err := e.db.QueryRowContext(ctx, + `SELECT COALESCE(author_username, ''), COALESCE(user_email, ''), COALESCE(provider, '') + FROM reviews WHERE id = $1 AND org_id = $2`, reviewID, orgID).Scan(&u, &em, &p); err != nil { + return nil, err + } + username, email, provider = u.String, em.String, p.String + } + + ai := &AuthorInfo{ + Username: strings.TrimSpace(username), + Email: strings.ToLower(strings.TrimSpace(email)), + Provider: provider, + Level: 1, // fail-closed default + } + ai.IsBot = isBotLogin(ai.Username) + + // Look up user_identities by username and (if present) email. + usernameKey := strings.ToLower(strings.TrimSpace(username)) + emailKey := ai.Email + + var userID sql.NullInt64 + err := e.db.QueryRowContext(ctx, ` + SELECT user_id FROM user_identities + WHERE org_id = $1 AND kind = 'username' AND value = $2 + LIMIT 1`, orgID, usernameKey).Scan(&userID) + if err == nil && userID.Valid { + return e.withMappedUser(ctx, ai, userID.Int64) + } + if emailKey != "" { + err = e.db.QueryRowContext(ctx, ` + SELECT user_id FROM user_identities + WHERE org_id = $1 AND kind = 'email' AND value = $2 + LIMIT 1`, orgID, emailKey).Scan(&userID) + if err == nil && userID.Valid { + return e.withMappedUser(ctx, ai, userID.Int64) + } + } + + return ai, nil +} + +// withMappedUser fills in the mapped user's level (bots keep level 0 unless the +// mapping makes them a real user, in which case their level applies). +func (e *Engine) withMappedUser(ctx context.Context, ai *AuthorInfo, userID int64) (*AuthorInfo, error) { + var level int + if err := e.db.QueryRowContext(ctx, `SELECT review_level FROM users WHERE id = $1`, userID).Scan(&level); err != nil { + return nil, err + } + ai.UserID = userID + ai.Matched = true + ai.Level = level + return ai, nil +} + +// isBotLogin detects provider bot accounts (GitHub's "[bot]" suffix, and common +// automation suffixes) from a login. +func isBotLogin(username string) bool { + u := strings.ToLower(strings.TrimSpace(username)) + if u == "" { + return false + } + return strings.HasSuffix(u, "[bot]") || + strings.HasSuffix(u, "-bot") || + strings.HasSuffix(u, "_bot") +} diff --git a/internal/policyengine/engine.go b/internal/policyengine/engine.go new file mode 100644 index 000000000..e5ba0f23b --- /dev/null +++ b/internal/policyengine/engine.go @@ -0,0 +1,442 @@ +package policyengine + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/livereview/internal/canonicaldoc" + "github.com/livereview/internal/riskscore" +) + +// ErrPolicyDisabled is returned when no auto-approve policy is configured for +// the review's org/repo (the feature is off). +var ErrPolicyDisabled = errors.New("auto-approve policy not configured") + +// StageResult is one funnel stage's outcome, as written to review_policy_log. +type StageResult struct { + Stage string `json:"stage"` + Verdict string `json:"verdict"` + Detail map[string]interface{} `json:"detail"` +} + +// FunnelResult is the full outcome of one policy evaluation. +type FunnelResult struct { + Approved bool `json:"approved"` + Mode string `json:"mode"` + ModeOverride bool `json:"mode_override"` + Stages []StageResult `json:"stages"` + Score float64 `json:"score,omitempty"` + Percentile int `json:"percentile,omitempty"` + ThresholdCutoff float64 `json:"threshold_cutoff,omitempty"` + Author *AuthorInfo `json:"author,omitempty"` + // LandingDelaySeconds is the post-approve human-rejection window, copied + // from the resolved policy so the worker can schedule the delayed publish. + LandingDelaySeconds int `json:"landing_delay_seconds,omitempty"` +} + +// Engine runs the policy funnel against a database. +type Engine struct { + db *sql.DB + scorer *riskscore.Scorer + store *riskscore.Store +} + +// NewEngine returns an Engine backed by db. +func NewEngine(db *sql.DB) *Engine { + return &Engine{db: db, scorer: riskscore.NewScorer(db), store: riskscore.NewStore(db)} +} + +func (e *Engine) reason(st StageResult) string { + if r, ok := st.Detail["reason"].(string); ok { + return r + } + return "" +} + +func skipStage(stage string) StageResult { + return StageResult{Stage: stage, Verdict: VerdictSkip, Detail: map[string]interface{}{"reason": "skipped (earlier stage blocked)"}} +} + +func skipModeStage(stage string) StageResult { + return StageResult{Stage: stage, Verdict: VerdictSkip, Detail: map[string]interface{}{"reason": "skipped: mode override"}} +} + +// reviewRepositoryID resolves the repository_id for a review (via its linked +// pull request), or 0 when there is none. +func (e *Engine) reviewRepositoryID(ctx context.Context, orgID, reviewID int64) int64 { + var repoID int64 + err := e.db.QueryRowContext(ctx, ` + SELECT COALESCE(pr.repository_id, 0) + FROM reviews r LEFT JOIN pull_requests pr ON pr.id = r.pull_request_id + WHERE r.id = $1 AND r.org_id = $2`, reviewID, orgID).Scan(&repoID) + if err != nil { + return 0 + } + return repoID +} + +// Run evaluates the policy funnel for a review. dryRun skips all writes +// (review_policy_log rows and the feature snapshot) for the simulate endpoint. +func (e *Engine) Run(ctx context.Context, orgID, reviewID int64, dryRun bool) (*FunnelResult, error) { + repoID := e.reviewRepositoryID(ctx, orgID, reviewID) + p, found, err := ResolvePolicy(ctx, e.db, orgID, repoID) + if err != nil { + return nil, err + } + if !found { + return nil, ErrPolicyDisabled + } + return e.runWithPolicy(ctx, orgID, reviewID, p, dryRun) +} + +// RunWithPolicy evaluates the funnel against an explicit policy (the simulate +// endpoint's draft policy), bypassing DB resolution. dryRun skips all writes. +func (e *Engine) RunWithPolicy(ctx context.Context, orgID, reviewID int64, p *Policy, dryRun bool) (*FunnelResult, error) { + return e.runWithPolicy(ctx, orgID, reviewID, p, dryRun) +} + +func (e *Engine) runWithPolicy(ctx context.Context, orgID, reviewID int64, p *Policy, dryRun bool) (*FunnelResult, error) { + res := &FunnelResult{Mode: p.Mode, Stages: []StageResult{}, LandingDelaySeconds: p.LandingDelaySeconds} + + doc, docErr := canonicaldoc.Build(ctx, e.db, orgID, reviewID) + + switch p.Mode { + case ModeNeverApprove: + res.ModeOverride = true + res.Stages = append(res.Stages, StageResult{Stage: StageFinal, Verdict: FinalBlocked, + Detail: map[string]interface{}{"reason": "mode override: never_approve"}}) + e.persist(ctx, orgID, reviewID, res, dryRun) + return res, nil + + case ModeAlwaysApprove: + res.ModeOverride = true + // The static jq gate still runs even under always_approve: a repo-wide + // "trust this repo" setting must never silence a secrets-leak detector. + var staticStage StageResult + if docErr == nil { + staticStage = e.staticStage(ctx, orgID, reviewID, p, doc) + } else { + staticStage = StageResult{Stage: StageStatic, Verdict: VerdictSkip, + Detail: map[string]interface{}{"reason": "no findings document"}} + } + res.Stages = append(res.Stages, staticStage) + res.Stages = append(res.Stages, skipModeStage(StageEligibility), skipModeStage(StageRisk), skipModeStage(StageLLM)) + + if staticStage.Verdict == VerdictFail { + res.Approved = false + res.Stages = append(res.Stages, StageResult{Stage: StageFinal, Verdict: FinalBlocked, + Detail: map[string]interface{}{"reason": "blocked by static ruleset despite always_approve: " + e.reason(staticStage)}}) + } else { + res.Approved = true + res.Stages = append(res.Stages, StageResult{Stage: StageFinal, Verdict: FinalApproved, + Detail: map[string]interface{}{"reason": "mode override: always_approve"}}) + } + e.persist(ctx, orgID, reviewID, res, dryRun) + return res, nil + } + + // custom (the full funnel) + author, authStage := e.eligibilityStage(ctx, orgID, reviewID, p) + res.Author = author + res.Stages = append(res.Stages, authStage) + blocked := authStage.Verdict == VerdictFail + + // static + if !blocked { + if docErr != nil { + st := StageResult{Stage: StageStatic, Verdict: VerdictFail, + Detail: map[string]interface{}{"reason": "failed to build findings document: " + docErr.Error()}} + res.Stages = append(res.Stages, st) + blocked = true + } else { + st := e.staticStage(ctx, orgID, reviewID, p, doc) + res.Stages = append(res.Stages, st) + blocked = st.Verdict == VerdictFail + } + } else { + res.Stages = append(res.Stages, skipStage(StageStatic)) + } + + // risk + llm: both are cheap checks against the diff's already-persisted + // findings/classification (the risk score's own feature vector already + // folds in doc.DiffClassification — see riskscore.ExtractFeatures), so + // neither needs to gate the other the way eligibility/static do. Run both + // once eligibility+static pass, so a failing risk score never hides what + // the LLM classification gate would have said, and vice versa. + gateOK := !blocked && docErr == nil + riskFailed, llmFailed := false, false + + if gateOK { + rt := e.riskStage(ctx, orgID, reviewID, p, doc, dryRun) + res.Stages = append(res.Stages, rt.StageResult) + res.Score = rt.Score + res.Percentile = rt.Percentile + res.ThresholdCutoff = rt.Cutoff + riskFailed = rt.Verdict == VerdictFail + } else { + res.Stages = append(res.Stages, skipStage(StageRisk)) + } + + if gateOK { + lt := e.llmStage(p, doc) + res.Stages = append(res.Stages, lt) + llmFailed = lt.Verdict == VerdictFail + } else { + res.Stages = append(res.Stages, skipStage(StageLLM)) + } + + blocked = blocked || riskFailed || llmFailed + + // daily approve cap (only affects approval, recorded on the final stage) + finalReason := "approved" + if !blocked { + if capped, reason := e.dailyCapExceeded(ctx, orgID, p); capped { + blocked = true + finalReason = reason + } + } else { + finalReason = e.blockReason(res.Stages) + } + + res.Approved = !blocked + verdict := FinalBlocked + if res.Approved { + verdict = FinalApproved + } + res.Stages = append(res.Stages, StageResult{Stage: StageFinal, Verdict: verdict, + Detail: map[string]interface{}{"reason": finalReason}}) + + e.persist(ctx, orgID, reviewID, res, dryRun) + return res, nil +} + +// blockReason joins every failing stage's reason — risk and llm can now both +// fail independently (see runWithPolicy), so a single-stage summary would +// silently drop whichever one didn't happen to be first. +func (e *Engine) blockReason(stages []StageResult) string { + var reasons []string + for _, st := range stages { + if st.Verdict == VerdictFail { + reasons = append(reasons, fmt.Sprintf("%s: %s", st.Stage, e.reason(st))) + } + } + if len(reasons) == 0 { + return "blocked" + } + return strings.Join(reasons, "; ") +} + +// riskStageResult is the risk stage's outcome plus the score fields surfaced on +// the funnel result. +type riskStageResult struct { + StageResult + Score float64 + Percentile int + Cutoff float64 +} + +// eligibilityStage resolves the author and applies the whitelist + min-level rule. +func (e *Engine) eligibilityStage(ctx context.Context, orgID, reviewID int64, p *Policy) (*AuthorInfo, StageResult) { + author, err := e.resolveAuthor(ctx, orgID, reviewID) + if err != nil { + return nil, StageResult{Stage: StageEligibility, Verdict: VerdictFail, + Detail: map[string]interface{}{"reason": "failed to resolve author: " + err.Error()}} + } + + // Whitelist bypasses the level gate. + if author.Matched && containsInt64(p.EligibilityWhitelistUserIDs, author.UserID) { + return author, StageResult{Stage: StageEligibility, Verdict: VerdictPass, + Detail: map[string]interface{}{"reason": "author whitelisted", "level": author.Level, "matched": author.Matched}} + } + + level := author.Level + if author.IsBot && !author.Matched { + level = 0 + } + + if level >= p.MinAuthorLevel { + return author, StageResult{Stage: StageEligibility, Verdict: VerdictPass, + Detail: map[string]interface{}{"reason": fmt.Sprintf("author level %d >= required %d", level, p.MinAuthorLevel), "level": level, "matched": author.Matched}} + } + return author, StageResult{Stage: StageEligibility, Verdict: VerdictFail, + Detail: map[string]interface{}{ + "reason": fmt.Sprintf("author level %d < required %d", level, p.MinAuthorLevel), + "level": level, + "matched": author.Matched, + "unmapped": !author.Matched, + "author_name": author.Username, + }} +} + +// staticStage runs the configured blocking jq rulesets (reusing the CI/CD gate). +func (e *Engine) staticStage(ctx context.Context, orgID, reviewID int64, p *Policy, doc *canonicaldoc.ReviewDoc) StageResult { + if len(p.BlockingRulesetIDs) == 0 { + return StageResult{Stage: StageStatic, Verdict: VerdictPass, + Detail: map[string]interface{}{"reason": "no blocking rulesets configured"}} + } + for _, rsID := range p.BlockingRulesetIDs { + expr, name, err := e.loadRuleset(ctx, orgID, rsID) + if err != nil { + return StageResult{Stage: StageStatic, Verdict: VerdictFail, + Detail: map[string]interface{}{"reason": fmt.Sprintf("ruleset %d unavailable: %v", rsID, err)}} + } + blocked, _, evalErr := canonicaldoc.RunJQBool(expr, doc) + if evalErr != nil { + return StageResult{Stage: StageStatic, Verdict: VerdictFail, + Detail: map[string]interface{}{"reason": fmt.Sprintf("ruleset %q failed to evaluate: %v", name, evalErr)}} + } + if blocked { + return StageResult{Stage: StageStatic, Verdict: VerdictFail, + Detail: map[string]interface{}{"reason": fmt.Sprintf("blocked by ruleset %q", name), "ruleset_id": rsID, "ruleset_name": name}} + } + } + return StageResult{Stage: StageStatic, Verdict: VerdictPass, + Detail: map[string]interface{}{"reason": fmt.Sprintf("passed %d blocking ruleset(s)", len(p.BlockingRulesetIDs))}} +} + +// loadRuleset returns a ruleset's jq expression + name, org-scoped. +func (e *Engine) loadRuleset(ctx context.Context, orgID, rulesetID int64) (expr, name string, err error) { + err = e.db.QueryRowContext(ctx, + `SELECT jq_expr, name FROM ci_rulesets WHERE id = $1 AND org_id = $2`, rulesetID, orgID).Scan(&expr, &name) + return expr, name, err +} + +// riskStage scores the review against the trained model and compares it to the +// percentile threshold. +func (e *Engine) riskStage(ctx context.Context, orgID, reviewID int64, p *Policy, doc *canonicaldoc.ReviewDoc, dryRun bool) riskStageResult { + br, err := riskscore.LoadBlastStats(ctx, e.db, orgID, reviewID) + if err != nil { + br = riskscore.BlastStats{} + } + features := riskscore.ExtractFeatures(br, doc) + score, err := e.scorer.ScoreFor(ctx, orgID, features) + if err != nil { + return riskStageResult{StageResult: StageResult{Stage: StageRisk, Verdict: VerdictFail, + Detail: map[string]interface{}{"reason": "risk model unavailable: " + err.Error()}}} + } + + scores, _ := riskscore.LoadRecentScores(ctx, e.db, orgID, 1000) + cutoff := riskscore.PercentileCutoff(scores, p.RiskThresholdPercentile) + pct := riskscore.PercentileOf(score, scores) + + if !dryRun { + info, infoErr := e.scorer.Info(ctx, orgID) + if infoErr == nil { + _ = e.store.SaveFeatureSnapshot(ctx, reviewID, orgID, info.Version, features, score) + } + } + + detail := map[string]interface{}{ + "score": score, + "percentile": pct, + "cutoff": cutoff, + "threshold": p.RiskThresholdPercentile, + "features": featureTable(features), + } + if score >= cutoff { + detail["reason"] = fmt.Sprintf("Risk score %.2f (P%d) is above the threshold of %.2f (P%d)", score, pct, cutoff, p.RiskThresholdPercentile) + return riskStageResult{StageResult: StageResult{Stage: StageRisk, Verdict: VerdictFail, Detail: detail}, Score: score, Percentile: pct, Cutoff: cutoff} + } + detail["reason"] = fmt.Sprintf("Risk score %.2f (P%d) is within the threshold of %.2f (P%d)", score, pct, cutoff, p.RiskThresholdPercentile) + return riskStageResult{StageResult: StageResult{Stage: StageRisk, Verdict: VerdictPass, Detail: detail}, Score: score, Percentile: pct, Cutoff: cutoff} +} + +// featureTable renders the feature vector as a human-readable list for the UI's +// explainability view. +func featureTable(features []float64) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(features)) + for i, v := range features { + if i >= len(riskscore.FeatureSpecs) { + break + } + out = append(out, map[string]interface{}{ + "feature": riskscore.FeatureSpecs[i].Name, + "value": v, + }) + } + return out +} + +// llmStage gates on the diff-level semantic classification (confidence + +// disqualifying class). +func (e *Engine) llmStage(p *Policy, doc *canonicaldoc.ReviewDoc) StageResult { + dc := doc.DiffClassification + if dc == nil { + return StageResult{Stage: StageLLM, Verdict: VerdictFail, + Detail: map[string]interface{}{"reason": "no diff classification available"}} + } + if dc.Confidence < p.RequireLLMConfidence { + return StageResult{Stage: StageLLM, Verdict: VerdictFail, + Detail: map[string]interface{}{ + "reason": fmt.Sprintf("classification confidence %d < required %d", dc.Confidence, p.RequireLLMConfidence), + "class": dc.Class, + "confidence": dc.Confidence, + }} + } + if isDisqualifyingClass(dc.Class) { + return StageResult{Stage: StageLLM, Verdict: VerdictFail, + Detail: map[string]interface{}{"reason": "disqualifying class: " + dc.Class, "class": dc.Class, "confidence": dc.Confidence}} + } + return StageResult{Stage: StageLLM, Verdict: VerdictPass, + Detail: map[string]interface{}{"reason": fmt.Sprintf("class %q, confidence %d", dc.Class, dc.Confidence), "class": dc.Class, "confidence": dc.Confidence}} +} + +// isDisqualifyingClass implements RADAR's "any risk signal disqualifies": a +// security-sensitive diff never auto-approves regardless of score. +func isDisqualifyingClass(class string) bool { + switch class { + case "security-sensitive": + return true + default: + return false + } +} + +// dailyCapExceeded reports whether the org's daily auto-approve cap has been +// reached (nil cap = unlimited). +func (e *Engine) dailyCapExceeded(ctx context.Context, orgID int64, p *Policy) (bool, string) { + if p.DailyApproveCap == nil { + return false, "" + } + var count int + err := e.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM review_policy_log + WHERE org_id = $1 AND stage = 'final' AND verdict = 'approved' + AND created_at >= date_trunc('day', now())`, orgID).Scan(&count) + if err != nil { + return false, "" + } + if count >= *p.DailyApproveCap { + return true, fmt.Sprintf("daily approve cap reached (%d/%d)", count, *p.DailyApproveCap) + } + return false, "" +} + +// persist writes each stage to review_policy_log (skipped in dryRun). It +// replaces any prior evaluation of this review so a worker retry doesn't leave +// duplicate funnel rows. +func (e *Engine) persist(ctx context.Context, orgID, reviewID int64, res *FunnelResult, dryRun bool) { + if dryRun { + return + } + _, _ = e.db.ExecContext(ctx, `DELETE FROM review_policy_log WHERE review_id = $1 AND org_id = $2`, reviewID, orgID) + for _, st := range res.Stages { + detail, _ := json.Marshal(st.Detail) + _, _ = e.db.ExecContext(ctx, ` + INSERT INTO review_policy_log (review_id, org_id, stage, verdict, detail) + VALUES ($1, $2, $3, $4, $5)`, reviewID, orgID, st.Stage, st.Verdict, detail) + } +} + +func containsInt64(slice []int64, v int64) bool { + for _, x := range slice { + if x == v { + return true + } + } + return false +} diff --git a/internal/policyengine/policy.go b/internal/policyengine/policy.go new file mode 100644 index 000000000..764c4120b --- /dev/null +++ b/internal/policyengine/policy.go @@ -0,0 +1,114 @@ +// Package policyengine implements the risk-based auto-approve policy funnel: +// eligibility -> static jq gates -> trained risk score -> LLM classification +// -> final decision. See docs/risk-policy-engine.md. It reuses the CI/CD gate's +// canonical-document + jq evaluation (internal/canonicaldoc) for the static +// stage and the trained model (internal/riskscore) for the risk stage. +package policyengine + +import ( + "context" + "database/sql" + + "github.com/lib/pq" +) + +// Policy modes. +const ( + ModeInherit = "inherit" + ModeCustom = "custom" + ModeAlwaysApprove = "always_approve" + ModeNeverApprove = "never_approve" +) + +// Stage verdicts. +const ( + VerdictPass = "pass" + VerdictFail = "fail" + VerdictSkip = "skip" +) + +// Funnel stages. +const ( + StageEligibility = "eligibility" + StageStatic = "static" + StageRisk = "risk" + StageLLM = "llm" + StageFinal = "final" +) + +// Final verdicts. +const ( + FinalApproved = "approved" + FinalBlocked = "blocked" +) + +// Policy is one auto_approve_policies row (org-default or repo-scoped). +type Policy struct { + ID int64 + OrgID int64 + RepositoryID *int64 + Mode string + MinAuthorLevel int + EligibilityWhitelistUserIDs []int64 + RiskThresholdPercentile int + RequireLLMConfidence int + BlockingRulesetIDs []int64 + LandingDelaySeconds int + DailyApproveCap *int + RestrictOutcomeReportingToAdmins bool + ForceApproveMinLevel int +} + +// ResolvePolicy returns the effective policy for a review in orgID/repositoryID: +// the repo-scoped row if present and not 'inherit', else the org-default row, +// else (found=false) the feature is off. repositoryID 0 means "no repo scope". +func ResolvePolicy(ctx context.Context, db *sql.DB, orgID, repositoryID int64) (*Policy, bool, error) { + if repositoryID > 0 { + p, err := loadPolicy(ctx, db, orgID, &repositoryID) + if err != nil { + return nil, false, err + } + if p != nil && p.Mode != ModeInherit { + return p, true, nil + } + } + + p, err := loadPolicy(ctx, db, orgID, nil) + if err != nil { + return nil, false, err + } + if p == nil { + return nil, false, nil + } + return p, true, nil +} + +// loadPolicy loads one row; repositoryID nil selects the org-default row. +func loadPolicy(ctx context.Context, db *sql.DB, orgID int64, repositoryID *int64) (*Policy, error) { + row := db.QueryRowContext(ctx, ` + SELECT id, org_id, repository_id, mode, min_author_level, + eligibility_whitelist_user_ids, risk_threshold_percentile, + require_llm_confidence, blocking_ruleset_ids, landing_delay_seconds, + daily_approve_cap, restrict_outcome_reporting_to_admins, force_approve_min_level + FROM auto_approve_policies + WHERE org_id = $1 AND repository_id IS NOT DISTINCT FROM $2`, orgID, repositoryID) + + var p Policy + var repoID sql.NullInt64 + err := row.Scan( + &p.ID, &p.OrgID, &repoID, &p.Mode, &p.MinAuthorLevel, + pq.Array(&p.EligibilityWhitelistUserIDs), &p.RiskThresholdPercentile, + &p.RequireLLMConfidence, pq.Array(&p.BlockingRulesetIDs), &p.LandingDelaySeconds, + &p.DailyApproveCap, &p.RestrictOutcomeReportingToAdmins, &p.ForceApproveMinLevel, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + if repoID.Valid { + p.RepositoryID = &repoID.Int64 + } + return &p, nil +} diff --git a/internal/policyengine/policy_test.go b/internal/policyengine/policy_test.go new file mode 100644 index 000000000..55cb838cd --- /dev/null +++ b/internal/policyengine/policy_test.go @@ -0,0 +1,141 @@ +package policyengine + +import ( + "context" + "database/sql" + "os" + "testing" + + _ "github.com/lib/pq" +) + +func testDB(t *testing.T) *sql.DB { + if testing.Short() { + t.Skip("skipping database integration test") + } + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + dsn = "postgres://livereview:livereview_password_123@localhost:5432/livereview?sslmode=disable" + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Ping(); err != nil { + t.Skipf("db not reachable: %v", err) + } + return db +} + +func seedOrg(t *testing.T, db *sql.DB, name string) int64 { + var orgID int64 + if err := db.QueryRow(`INSERT INTO orgs (name) VALUES ($1) RETURNING id`, name).Scan(&orgID); err != nil { + t.Fatalf("insert org: %v", err) + } + t.Cleanup(func() { + // The live dev server's background dashboard-cache refresh job may have + // already inserted a row for this org by the time the test ends; its FK + // has no cascade, so it must be cleared first or the org delete leaks. + _, _ = db.Exec(`DELETE FROM dashboard_cache WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM orgs WHERE id = $1`, orgID) + }) + return orgID +} + +func insertPolicy(t *testing.T, db *sql.DB, orgID int64, repoID *int64, mode string, minLevel int) int64 { + var id int64 + if err := db.QueryRow(` + INSERT INTO auto_approve_policies (org_id, repository_id, mode, min_author_level) + VALUES ($1, $2, $3, $4) RETURNING id`, orgID, repoID, mode, minLevel).Scan(&id); err != nil { + t.Fatalf("insert policy: %v", err) + } + // auto_approve_policies.org_id has no ON DELETE CASCADE, so this must be + // cleaned up before seedOrg's org delete (t.Cleanup runs LIFO, and this is + // registered after seedOrg's, so it runs first) or the org delete fails + // silently and leaks on every run. + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM auto_approve_policies WHERE id = $1`, id) }) + return id +} + +// No policy configured at all: ResolvePolicy reports the feature as off. +func TestResolvePolicyNoneConfigured(t *testing.T) { + db := testDB(t) + orgID := seedOrg(t, db, "policy-resolve-none") + + _, found, err := ResolvePolicy(context.Background(), db, orgID, 0) + if err != nil { + t.Fatalf("ResolvePolicy: %v", err) + } + if found { + t.Fatalf("expected found=false when no policy row exists") + } +} + +// Only the org-default row exists: a repo-scoped lookup falls back to it. +func TestResolvePolicyFallsBackToOrgDefault(t *testing.T) { + db := testDB(t) + orgID := seedOrg(t, db, "policy-resolve-fallback") + insertPolicy(t, db, orgID, nil, ModeCustom, 2) + + p, found, err := ResolvePolicy(context.Background(), db, orgID, 999) + if err != nil { + t.Fatalf("ResolvePolicy: %v", err) + } + if !found { + t.Fatalf("expected found=true via org-default fallback") + } + if p.RepositoryID != nil { + t.Fatalf("expected the org-default row (repository_id nil), got %+v", p.RepositoryID) + } + if p.MinAuthorLevel != 2 { + t.Fatalf("expected org default's min_author_level=2, got %d", p.MinAuthorLevel) + } +} + +// A repo row with mode='inherit' defers wholesale to the org default, even +// though a repo-scoped row exists. +func TestResolvePolicyInheritDefersToOrgDefault(t *testing.T) { + db := testDB(t) + orgID := seedOrg(t, db, "policy-resolve-inherit") + insertPolicy(t, db, orgID, nil, ModeCustom, 3) + repoID := int64(42) + insertPolicy(t, db, orgID, &repoID, ModeInherit, 1) + + p, found, err := ResolvePolicy(context.Background(), db, orgID, repoID) + if err != nil { + t.Fatalf("ResolvePolicy: %v", err) + } + if !found { + t.Fatalf("expected found=true") + } + if p.RepositoryID != nil { + t.Fatalf("expected the org-default row to win over an 'inherit' repo row, got repository_id=%v", p.RepositoryID) + } + if p.MinAuthorLevel != 3 { + t.Fatalf("expected org default's min_author_level=3, got %d", p.MinAuthorLevel) + } +} + +// A repo row with mode='custom' takes precedence over the org default. +func TestResolvePolicyCustomRepoRowWins(t *testing.T) { + db := testDB(t) + orgID := seedOrg(t, db, "policy-resolve-custom-repo") + insertPolicy(t, db, orgID, nil, ModeCustom, 1) + repoID := int64(43) + insertPolicy(t, db, orgID, &repoID, ModeCustom, 5) + + p, found, err := ResolvePolicy(context.Background(), db, orgID, repoID) + if err != nil { + t.Fatalf("ResolvePolicy: %v", err) + } + if !found { + t.Fatalf("expected found=true") + } + if p.RepositoryID == nil || *p.RepositoryID != repoID { + t.Fatalf("expected the repo-scoped row to win, got repository_id=%v", p.RepositoryID) + } + if p.MinAuthorLevel != 5 { + t.Fatalf("expected repo row's min_author_level=5, got %d", p.MinAuthorLevel) + } +} diff --git a/internal/policyengine/scenario_test.go b/internal/policyengine/scenario_test.go new file mode 100644 index 000000000..a154718bb --- /dev/null +++ b/internal/policyengine/scenario_test.go @@ -0,0 +1,618 @@ +package policyengine + +// Scenario-corpus verification: see docs/risk-policy-explainability.md and the +// plan at docs/risk-policy-engine.md's "Active plan" section. Each case in +// testdata/scenarios.json is a plain-data recipe for one review's inputs plus +// the expected funnel outcome; this file's job is only to seed those inputs +// into real tables and run the actual production call, engine.RunWithPolicy +// -- never a parallel reimplementation of funnel logic. Every row this file +// writes is cleaned up via t.Cleanup (LIFO, registered right after each +// insert) regardless of pass/fail, so repeated runs -- including against a +// shared/live database -- leave zero residue. + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/livereview/internal/blastradius" + "github.com/livereview/internal/riskscore" + "github.com/livereview/internal/riskscore/referencemix" +) + +// --- scenario data shape (mirrors testdata/scenarios.json) --- + +type scenarioHunk struct { + Combined float64 `json:"combined"` + NewLines int `json:"new_lines"` + FilePath string `json:"file_path"` +} + +type scenarioFinding struct { + Severity string `json:"severity"` + Category string `json:"category"` + Type string `json:"type"` + Count int `json:"count"` +} + +type scenarioLLMClass struct { + Class string `json:"class"` + Confidence int `json:"confidence"` +} + +type scenarioDiff struct { + FileCount int `json:"file_count"` + LinesAdded int `json:"lines_added"` +} + +type scenarioPolicyOverrides struct { + Mode string `json:"mode"` + MinAuthorLevel *int `json:"min_author_level"` + RiskThresholdPercentile *int `json:"risk_threshold_percentile"` + RequireLLMConfidence *int `json:"require_llm_confidence"` + DailyApproveCap *int `json:"daily_approve_cap"` + BlockingRulesets []string `json:"blocking_rulesets"` // names, resolved against Scenario.Rulesets +} + +type scenarioRuleset struct { + Name string `json:"name"` + JQExpr string `json:"jq_expr"` +} + +type scenarioWant struct { + Approve bool `json:"approve"` + BlockedAt string `json:"blocked_at"` + ReasonContains string `json:"reason_contains"` +} + +// Scenario is one "common sense" case: a recipe for seeding a single review's +// inputs, plus the expected funnel outcome. +type Scenario struct { + Name string `json:"name"` + Rationale string `json:"rationale"` + // Bucket groups scenarios for the verification report (e.g. "eligibility", + // "llm-gate") -- an explicit field in the data, not a name-prefix guess a + // report-generation script would otherwise have to re-derive. + Bucket string `json:"bucket"` + BlastHunks []scenarioHunk `json:"blast_hunks"` + Findings []scenarioFinding `json:"findings"` + LLMClass *scenarioLLMClass `json:"llm_class"` + Diff scenarioDiff `json:"diff"` + AuthorLevel int `json:"author_level"` + AuthorIsBot bool `json:"author_is_bot"` + BotUsername string `json:"bot_username"` + AuthorUnmapped bool `json:"author_unmapped"` + Whitelisted bool `json:"whitelisted"` + Policy scenarioPolicyOverrides `json:"policy"` + Rulesets []scenarioRuleset `json:"rulesets"` + SeedRecentScores []float64 `json:"seed_recent_scores"` + SeedApprovalsToday int `json:"seed_approvals_today"` + Want scenarioWant `json:"want"` +} + +func loadScenarios(t *testing.T) []Scenario { + t.Helper() + raw, err := os.ReadFile("testdata/scenarios.json") + if err != nil { + t.Fatalf("reading scenarios.json: %v", err) + } + var out []Scenario + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("parsing scenarios.json: %v", err) + } + return out +} + +var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`) + +func sanitize(name string) string { + return strings.Trim(nonAlnum.ReplaceAllString(strings.ToLower(name), "-"), "-") +} + +// --- Policy construction (defaults mirror auto_approve_policies' column defaults) --- + +func (sc Scenario) buildPolicy(whitelistUserID int64, rulesetIDs []int64) *Policy { + p := &Policy{ + Mode: ModeCustom, + MinAuthorLevel: 1, + RiskThresholdPercentile: 20, + RequireLLMConfidence: 8, + ForceApproveMinLevel: 3, + } + if sc.Policy.Mode != "" { + p.Mode = sc.Policy.Mode + } + if sc.Policy.MinAuthorLevel != nil { + p.MinAuthorLevel = *sc.Policy.MinAuthorLevel + } + if sc.Policy.RiskThresholdPercentile != nil { + p.RiskThresholdPercentile = *sc.Policy.RiskThresholdPercentile + } + if sc.Policy.RequireLLMConfidence != nil { + p.RequireLLMConfidence = *sc.Policy.RequireLLMConfidence + } + if sc.Policy.DailyApproveCap != nil { + p.DailyApproveCap = sc.Policy.DailyApproveCap + } + if sc.Whitelisted && whitelistUserID > 0 { + p.EligibilityWhitelistUserIDs = []int64{whitelistUserID} + } + p.BlockingRulesetIDs = rulesetIDs + return p +} + +// insertRuleset creates a ci_rulesets row (the same table/rows CI/CD gates +// use) and returns its id. +func insertRuleset(t *testing.T, db *sql.DB, orgID int64, name, jqExpr string) int64 { + t.Helper() + var id int64 + err := db.QueryRow(` + INSERT INTO ci_rulesets (org_id, name, jq_expr) VALUES ($1, $2, $3) RETURNING id`, + orgID, name, jqExpr).Scan(&id) + if err != nil { + t.Fatalf("insert ci_ruleset: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM ci_rulesets WHERE id = $1`, id) }) + return id +} + +// resolveRulesets creates every ruleset a scenario declares and resolves its +// policy.blocking_rulesets names to the ids the funnel actually reads. +func resolveRulesets(t *testing.T, db *sql.DB, orgID int64, sc Scenario) []int64 { + t.Helper() + byName := map[string]int64{} + for _, rs := range sc.Rulesets { + byName[rs.Name] = insertRuleset(t, db, orgID, rs.Name, rs.JQExpr) + } + var ids []int64 + for _, name := range sc.Policy.BlockingRulesets { + id, ok := byName[name] + if !ok { + t.Fatalf("policy.blocking_rulesets references undeclared ruleset %q", name) + } + ids = append(ids, id) + } + return ids +} + +// --- DB seeding helpers (own cleanup, LIFO-safe per the convention in policy_test.go) --- + +func insertUser(t *testing.T, db *sql.DB, email string, reviewLevel int) int64 { + t.Helper() + var id int64 + err := db.QueryRow(` + INSERT INTO users (email, password_hash, review_level) VALUES ($1, 'x', $2) RETURNING id`, + email, reviewLevel).Scan(&id) + if err != nil { + t.Fatalf("insert user: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, id) }) + return id +} + +func insertUserIdentity(t *testing.T, db *sql.DB, orgID, userID int64, kind, value string) { + t.Helper() + var id int64 + err := db.QueryRow(` + INSERT INTO user_identities (org_id, user_id, kind, value) VALUES ($1, $2, $3, $4) RETURNING id`, + orgID, userID, kind, value).Scan(&id) + if err != nil { + t.Fatalf("insert user_identity: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM user_identities WHERE id = $1`, id) }) +} + +// buildReviewMetadata renders a scenario's findings + LLM classification as the +// review_result JSONB blob canonicaldoc.Build reads from. +func buildReviewMetadata(sc Scenario) []byte { + comments := []map[string]interface{}{} + fileIdx := 0 + for _, f := range sc.Findings { + findingType := f.Type + if findingType == "" { + findingType = "issue" + } + for i := 0; i < f.Count; i++ { + comments = append(comments, map[string]interface{}{ + "severity": f.Severity, + "confidence": "high", + "category": f.Category, + "type": findingType, + "file_path": fmt.Sprintf("finding-%d.go", fileIdx), + "line": 1, + }) + fileIdx++ + } + } + reviewResult := map[string]interface{}{"comments": comments} + if sc.LLMClass != nil { + reviewResult["diffClassification"] = map[string]interface{}{ + "class": sc.LLMClass.Class, + "confidence": sc.LLMClass.Confidence, + } + } + raw, _ := json.Marshal(map[string]interface{}{"review_result": reviewResult}) + return raw +} + +func insertReview(t *testing.T, db *sql.DB, orgID int64, authorUsername, userEmail, provider string, metadata []byte) int64 { + t.Helper() + var id int64 + err := db.QueryRow(` + INSERT INTO reviews (repository, status, org_id, user_email, provider, author_username, metadata) + VALUES ('scenario/test-repo', 'completed', $1, $2, $3, $4, $5) RETURNING id`, + orgID, userEmail, provider, authorUsername, metadata).Scan(&id) + if err != nil { + t.Fatalf("insert review: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM reviews WHERE id = $1`, id) }) + return id +} + +// expandHunks pads a scenario's explicit "risk" hunks with zero-combined filler +// hunks so the review's total file count / lines added matches sc.Diff exactly +// (LoadBlastStats' FileCount/LinesAdded come from every hunk row, not just the +// risk-relevant ones) without diluting sum/max/weighted_avg/high_count, since +// filler hunks carry 0 new_lines whenever risk hunks already account for every +// line in sc.Diff.LinesAdded. +func expandHunks(sc Scenario) []scenarioHunk { + hunks := append([]scenarioHunk{}, sc.BlastHunks...) + for i := range hunks { + if hunks[i].FilePath == "" { + hunks[i].FilePath = fmt.Sprintf("risk-%d.go", i) + } + } + remainingFiles := sc.Diff.FileCount - len(hunks) + if remainingFiles <= 0 { + return hunks + } + // All filler lines go on the first filler file when there are no risk + // hunks at all (lines_added is otherwise unaccounted for); filler files + // carry 0 lines whenever risk hunks already total sc.Diff.LinesAdded. + fillerLines := 0 + if len(sc.BlastHunks) == 0 { + fillerLines = sc.Diff.LinesAdded + } + for i := 0; i < remainingFiles; i++ { + lines := 0 + if i == 0 { + lines = fillerLines + } + hunks = append(hunks, scenarioHunk{Combined: 0, NewLines: lines, FilePath: fmt.Sprintf("filler-%d.go", i)}) + } + return hunks +} + +func insertBlastHunks(t *testing.T, db *sql.DB, orgID, reviewID int64, hunks []scenarioHunk) { + t.Helper() + for _, h := range hunks { + tier := blastradius.Tier(h.Combined) + _, err := db.Exec(` + INSERT INTO blast_radius_hunks (review_id, org_id, file_path, new_start, new_lines, combined, tier, math_mode) + VALUES ($1, $2, $3, 1, $4, $5, $6, '{}'::jsonb)`, + reviewID, orgID, h.FilePath, h.NewLines, h.Combined, tier) + if err != nil { + t.Fatalf("insert blast_radius_hunks: %v", err) + } + } + // blast_radius_hunks cascades on review delete (ON DELETE CASCADE), so no + // separate cleanup is required here. +} + +// seedRecentScores pre-populates risk_feature_snapshots with a synthetic score +// distribution so PercentileCutoff/PercentileOf have real data to calibrate +// against (a dry-run evaluation never writes its own snapshot, so without this +// an org's risk_threshold_percentile would always resolve to the cold-start +// default cutoff regardless of the configured percentile). +func seedRecentScores(t *testing.T, db *sql.DB, orgID, reviewID int64, scores []float64) { + t.Helper() + for _, s := range scores { + var id int64 + err := db.QueryRow(` + INSERT INTO risk_feature_snapshots (review_id, org_id, model_version, features, score) + VALUES ($1, $2, 1, '[]'::jsonb, $3) RETURNING id`, reviewID, orgID, s).Scan(&id) + if err != nil { + t.Fatalf("seed risk_feature_snapshots: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM risk_feature_snapshots WHERE id = $1`, id) }) + } +} + +// seedApprovalsToday pre-populates review_policy_log with n already-approved +// final verdicts today, so dailyCapExceeded sees real history to count against +// (dry-run evaluations never persist their own log rows). +func seedApprovalsToday(t *testing.T, db *sql.DB, orgID, reviewID int64, n int) { + t.Helper() + for i := 0; i < n; i++ { + var id int64 + err := db.QueryRow(` + INSERT INTO review_policy_log (review_id, org_id, stage, verdict, detail) + VALUES ($1, $2, 'final', 'approved', '{}'::jsonb) RETURNING id`, reviewID, orgID).Scan(&id) + if err != nil { + t.Fatalf("seed review_policy_log: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM review_policy_log WHERE id = $1`, id) }) + } +} + +// blockedAtStage extracts which funnel stage actually blocked a review, for +// assertions: the first non-final stage with a "fail" verdict, or the +// "mode_override" sentinel when the funnel never evaluated per-stage at all +// (never_approve, or a cap/mode block recorded only on the final stage). +func blockedAtStage(res *FunnelResult) string { + if res.Approved { + return "" + } + for _, st := range res.Stages { + if st.Stage != StageFinal && st.Verdict == VerdictFail { + return st.Stage + } + } + return "mode_override" +} + +func finalReason(res *FunnelResult) string { + for _, st := range res.Stages { + if st.Stage == StageFinal { + if r, ok := st.Detail["reason"].(string); ok { + return r + } + } + } + return "" +} + +// runScenario seeds one scenario's review and runs it through the real +// production funnel call, exactly as the /simulate endpoint does. +func runScenario(t *testing.T, db *sql.DB, orgID int64, sc Scenario) *FunnelResult { + t.Helper() + ctx := context.Background() + + var authorUsername, authorEmail, provider string + var whitelistUserID int64 + + suffix := fmt.Sprintf("%s-%d", sanitize(sc.Name), time.Now().UnixNano()) + + switch { + case sc.AuthorIsBot: + authorUsername = sc.BotUsername + provider = "github" + case sc.AuthorUnmapped: + // No users/user_identities row at all: resolveAuthor's lookup finds + // nothing and falls through to the fail-closed default (level 1, + // matched=false) -- deliberately not a bot-looking username. + authorUsername = "new-contributor-" + suffix + authorEmail = fmt.Sprintf("unmapped-%s@scenario.test", suffix) + provider = "github" + default: + email := fmt.Sprintf("author-%s@scenario.test", suffix) + userID := insertUser(t, db, email, sc.AuthorLevel) + insertUserIdentity(t, db, orgID, userID, "email", email) + authorUsername = "author-" + suffix + authorEmail = email + provider = "github" + if sc.Whitelisted { + whitelistUserID = userID + } + } + + metadata := buildReviewMetadata(sc) + reviewID := insertReview(t, db, orgID, authorUsername, authorEmail, provider, metadata) + insertBlastHunks(t, db, orgID, reviewID, expandHunks(sc)) + + if len(sc.SeedRecentScores) > 0 { + seedRecentScores(t, db, orgID, reviewID, sc.SeedRecentScores) + } + if sc.SeedApprovalsToday > 0 { + seedApprovalsToday(t, db, orgID, reviewID, sc.SeedApprovalsToday) + } + + rulesetIDs := resolveRulesets(t, db, orgID, sc) + policy := sc.buildPolicy(whitelistUserID, rulesetIDs) + engine := NewEngine(db) + res, err := engine.RunWithPolicy(ctx, orgID, reviewID, policy, true) + if err != nil { + t.Fatalf("[%s] RunWithPolicy: %v", sc.Name, err) + } + return res +} + +// TestScenarios runs the full testdata/scenarios.json corpus against the real +// policy funnel (engine.RunWithPolicy, dry-run) and asserts each case's +// expected outcome. Run with -v to see the printed table, which doubles as the +// explainability doc's worked-example evidence. +// scenarioFeatureRow is one feature's full arithmetic for the report's +// expandable "show the math" row: raw/transformed value, its weight, its +// category multiplier, and what it contributed to z. +type scenarioFeatureRow struct { + Name string `json:"name"` + Category string `json:"category"` + Value float64 `json:"value"` + Weight float64 `json:"weight"` + Multiplier float64 `json:"multiplier"` + Contribution float64 `json:"contribution"` +} + +// scenarioStageRow is one funnel stage's verdict + reason, for the report's +// per-scenario funnel trace. +type scenarioStageRow struct { + Stage string `json:"stage"` + Verdict string `json:"verdict"` + Reason string `json:"reason"` +} + +// scenarioDump is the full machine-readable record TestScenarios logs per +// case -- the report is built directly from these lines (grep the +// "SCDATA:" prefix out of `go test -v` output), never from hand-transcribed +// numbers. Uses the real Prior()/DefaultCategoryMultiplier (the same seed +// weights every throwaway-org scenario is actually scored with, since none +// of them configure risk_factor_overrides) against the real feature values +// the engine itself computed and logged on the risk stage -- not a +// recomputation. +// scenarioTierRow is this scenario's approve/block decision under one +// percentile setting, measured against the shared referencemix distribution +// -- the same 60-review mix internal/riskscore's threshold experiment uses, +// so every scenario's reported percentile and per-tier decision lines up +// with the numbers the Thresholds tab already shows. +type scenarioTierRow struct { + Percentile int `json:"percentile"` + Cutoff float64 `json:"cutoff"` + Approved bool `json:"approved"` +} + +type scenarioDump struct { + Name string `json:"name"` + Bucket string `json:"bucket"` + Rationale string `json:"rationale"` + Approved bool `json:"approved"` + BlockedAt string `json:"blocked_at"` + Score float64 `json:"score"` + PercentileRank int `json:"percentile_rank"` + Bias float64 `json:"bias"` + Z float64 `json:"z"` + Features []scenarioFeatureRow `json:"features"` + Stages []scenarioStageRow `json:"stages"` + Tiers []scenarioTierRow `json:"tiers"` +} + +// referenceMixScores is computed once per test run (it's a fixed, +// reproducible 60-review mix -- see internal/riskscore/referencemix) and +// reused to rank every scenario's score against the same distribution. +var referenceMixScoresOnce []float64 + +func referenceMixScores() []float64 { + if referenceMixScoresOnce != nil { + return referenceMixScoresOnce + } + prior := riskscore.Prior() + for _, ex := range referencemix.Build(riskscore.FeatureCount()) { + referenceMixScoresOnce = append(referenceMixScoresOnce, riskscore.Score(prior, ex.Features, nil)) + } + return referenceMixScoresOnce +} + +// reportTiers is the fixed set of percentile settings the report compares, +// including both edges (P0 blocks everything, P100 approves everything but +// the single riskiest review in the reference mix -- see +// internal/riskscore/threshold_experiment_test.go for the proof). +var reportTiers = []int{0, 10, 20, 50, 80, 100} + +func buildScenarioDump(sc Scenario, res *FunnelResult) scenarioDump { + prior := riskscore.Prior() + dump := scenarioDump{ + Name: sc.Name, + Bucket: sc.Bucket, + Rationale: sc.Rationale, + Approved: res.Approved, + BlockedAt: blockedAtStage(res), + Score: res.Score, + Bias: prior[0], + Z: prior[0], + } + + var rawFeatures []interface{} + riskRan := false + for _, st := range res.Stages { + dump.Stages = append(dump.Stages, scenarioStageRow{Stage: st.Stage, Verdict: st.Verdict, Reason: fmt.Sprintf("%v", st.Detail["reason"])}) + if st.Stage == StageRisk { + if st.Verdict != VerdictSkip { + riskRan = true + } + if f, ok := st.Detail["features"].([]map[string]interface{}); ok { + for _, m := range f { + rawFeatures = append(rawFeatures, m) + } + } + } + } + + for i, spec := range riskscore.FeatureSpecs { + var value float64 + if i < len(rawFeatures) { + if m, ok := rawFeatures[i].(map[string]interface{}); ok { + if v, ok := m["value"].(float64); ok { + value = v + } + } + } + weight := prior[i+1] + multiplier := riskscore.DefaultCategoryMultiplier(spec.Category) + if spec.Category == "" { + multiplier = 1 + } + contribution := weight * multiplier * value + dump.Z += contribution + dump.Features = append(dump.Features, scenarioFeatureRow{ + Name: spec.Name, Category: spec.Category, Value: value, + Weight: weight, Multiplier: multiplier, Contribution: contribution, + }) + } + + // Percentile rank and per-tier decisions only mean something when the + // risk stage actually produced a real score; a scenario blocked earlier + // (eligibility/static) has res.Score == 0 (Go's zero value, never + // computed), and ranking that zero against the reference mix would + // falsely read as "extremely safe" rather than "never scored." + if riskRan { + mix := referenceMixScores() + dump.PercentileRank = riskscore.PercentileOf(dump.Score, mix) + for _, p := range reportTiers { + cutoff := riskscore.PercentileCutoff(mix, p) + dump.Tiers = append(dump.Tiers, scenarioTierRow{ + Percentile: p, Cutoff: cutoff, Approved: dump.Score < cutoff, + }) + } + } + return dump +} + +func TestScenarios(t *testing.T) { + db := testDB(t) + orgID := seedOrg(t, db, "scenario-corpus") + + for _, sc := range loadScenarios(t) { + sc := sc + t.Run(sc.Name, func(t *testing.T) { + res := runScenario(t, db, orgID, sc) + + gotApprove := res.Approved + gotBlockedAt := blockedAtStage(res) + + status := "PASS" + if gotApprove != sc.Want.Approve { + status = "FAIL" + } + if !sc.Want.Approve && sc.Want.BlockedAt != "" && gotBlockedAt != sc.Want.BlockedAt { + status = "FAIL" + } + reason := finalReason(res) + if sc.Want.ReasonContains != "" && !strings.Contains(reason, sc.Want.ReasonContains) { + status = "FAIL" + } + t.Logf("%-4s %-55s want_approve=%-5v got_approve=%-5v blocked_at=%-12s score=%.3f // %s", + status, sc.Name, sc.Want.Approve, gotApprove, gotBlockedAt, res.Score, sc.Rationale) + + dumpJSON, err := json.Marshal(buildScenarioDump(sc, res)) + if err != nil { + t.Fatalf("[%s] marshal scenario dump: %v", sc.Name, err) + } + t.Logf("SCDATA:%s", dumpJSON) + + if gotApprove != sc.Want.Approve { + t.Errorf("[%s] approve = %v, want %v (reason: %s)", sc.Name, gotApprove, sc.Want.Approve, reason) + } + if !sc.Want.Approve && sc.Want.BlockedAt != "" && gotBlockedAt != sc.Want.BlockedAt { + t.Errorf("[%s] blocked_at = %q, want %q (reason: %s)", sc.Name, gotBlockedAt, sc.Want.BlockedAt, reason) + } + if sc.Want.ReasonContains != "" && !strings.Contains(reason, sc.Want.ReasonContains) { + t.Errorf("[%s] final reason %q does not contain %q", sc.Name, reason, sc.Want.ReasonContains) + } + }) + } +} diff --git a/internal/policyengine/testdata/scenarios.json b/internal/policyengine/testdata/scenarios.json new file mode 100644 index 000000000..2dc4777ff --- /dev/null +++ b/internal/policyengine/testdata/scenarios.json @@ -0,0 +1,1190 @@ +[ + { + "name": "readme-only-edit", + "rationale": "Docs-only change, no code risk surface at all. Should approve with a low score.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "docs-only", + "confidence": 9 + }, + "diff": { + "file_count": 1, + "lines_added": 8 + }, + "author_level": 2, + "want": { + "approve": true + }, + "bucket": "baseline-shape" + }, + { + "name": "formatting-only-change", + "rationale": "Whitespace/formatting-only diff across a few files, no findings. Should approve.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 3, + "lines_added": 15 + }, + "author_level": 2, + "want": { + "approve": true + }, + "bucket": "baseline-shape" + }, + { + "name": "test-only-change-with-lockfile-bump", + "rationale": "A moderately sized but low-risk-shape diff (tests + a dependency lockfile bump), no findings. Diff size alone should not block.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "test-only", + "confidence": 9 + }, + "diff": { + "file_count": 4, + "lines_added": 120 + }, + "author_level": 2, + "want": { + "approve": true + }, + "bucket": "baseline-shape" + }, + { + "name": "huge-refactor-critical-security-finding", + "rationale": "Large diff, high blast radius, and a critical security finding. Every signal says risky; must block at the risk stage.", + "blast_hunks": [ + { + "combined": 80, + "new_lines": 300, + "file_path": "a/core.go" + } + ], + "findings": [ + { + "severity": "critical", + "category": "security", + "count": 1 + } + ], + "llm_class": { + "class": "logic-change", + "confidence": 9 + }, + "diff": { + "file_count": 15, + "lines_added": 300 + }, + "author_level": 2, + "want": { + "approve": false, + "blocked_at": "risk" + }, + "bucket": "baseline-shape" + }, + { + "name": "high-blast-radius-silent-landmine-no-findings", + "rationale": "Zero findings (the LLM reviewer missed it, or there's nothing to flag line-by-line), but blast radius alone is very high. The silent-landmine case: blast radius must catch what findings miss.", + "blast_hunks": [ + { + "combined": 90, + "new_lines": 50, + "file_path": "a/core.go" + } + ], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 3, + "lines_added": 50 + }, + "author_level": 2, + "want": { + "approve": false, + "blocked_at": "risk" + }, + "bucket": "baseline-shape" + }, + { + "name": "two-warnings-no-criticals-stays-safe", + "rationale": "A couple of warning-level findings and no criticals, small diff. Should still approve: non-critical findings alone shouldn't dominate the score.", + "blast_hunks": [], + "findings": [ + { + "severity": "warning", + "category": "maintainability", + "count": 2 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 1, + "lines_added": 2 + }, + "author_level": 2, + "want": { + "approve": true + }, + "bucket": "baseline-shape" + }, + { + "name": "conflicting-low-blast-critical-finding-blocks", + "rationale": "Blast radius is zero (a tiny, localized diff) but one finding is a critical security issue. A strong finding-level signal should block even when blast radius says nothing.", + "blast_hunks": [], + "findings": [ + { + "severity": "critical", + "category": "security", + "count": 1 + } + ], + "llm_class": { + "class": "security-sensitive-but-confident", + "confidence": 9 + }, + "diff": { + "file_count": 1, + "lines_added": 1 + }, + "author_level": 2, + "want": { + "approve": false, + "blocked_at": "risk" + }, + "bucket": "baseline-shape" + }, + { + "name": "author-below-min-level-blocked", + "rationale": "Author is below the policy's min_author_level and not whitelisted. Everything else about the diff is safe, isolating eligibility as the actual blocker.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 1, + "lines_added": 1 + }, + "author_level": 2, + "policy": { + "min_author_level": 3 + }, + "want": { + "approve": false, + "blocked_at": "eligibility" + }, + "bucket": "eligibility" + }, + { + "name": "author-at-min-level-passes", + "rationale": "Author's level exactly equals min_author_level. The boundary itself (>=) should pass, not just levels strictly above it.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 1, + "lines_added": 1 + }, + "author_level": 2, + "policy": { + "min_author_level": 2 + }, + "want": { + "approve": true + }, + "bucket": "eligibility" + }, + { + "name": "bot-author-blocked", + "rationale": "An unmapped bot login (e.g. a dependency-update bot) defaults to review level 0, below any realistic min_author_level, and is not whitelisted.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "dependency-change", + "confidence": 9 + }, + "diff": { + "file_count": 1, + "lines_added": 5 + }, + "author_is_bot": true, + "bot_username": "renovate[bot]", + "policy": { + "min_author_level": 1 + }, + "want": { + "approve": false, + "blocked_at": "eligibility" + }, + "bucket": "eligibility" + }, + { + "name": "whitelisted-low-level-author-passes", + "rationale": "Author is well below min_author_level, but explicitly whitelisted. The whitelist must bypass the level gate entirely.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 1, + "lines_added": 1 + }, + "author_level": 1, + "whitelisted": true, + "policy": { + "min_author_level": 5 + }, + "want": { + "approve": true + }, + "bucket": "eligibility" + }, + { + "name": "llm-missing-classification-blocks", + "rationale": "No diff-level LLM classification was produced at all (legacy/CLI review). The LLM gate must fail closed rather than silently pass.", + "blast_hunks": [], + "findings": [], + "llm_class": null, + "diff": { + "file_count": 1, + "lines_added": 1 + }, + "author_level": 2, + "want": { + "approve": false, + "blocked_at": "llm" + }, + "bucket": "llm-gate" + }, + { + "name": "llm-low-confidence-blocks", + "rationale": "A classification exists but its confidence is below the policy's required threshold. Must fail the confidence gate regardless of how safe the class itself looks.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 3 + }, + "diff": { + "file_count": 1, + "lines_added": 1 + }, + "author_level": 2, + "policy": { + "require_llm_confidence": 8 + }, + "want": { + "approve": false, + "blocked_at": "llm" + }, + "bucket": "llm-gate" + }, + { + "name": "llm-disqualifying-class-high-confidence-blocks", + "rationale": "The model is confident, but the class itself (security-sensitive) always disqualifies, per RADAR's 'any risk signal disqualifies' rule - confidence cannot buy its way past a disqualifying class.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "security-sensitive", + "confidence": 9 + }, + "diff": { + "file_count": 1, + "lines_added": 1 + }, + "author_level": 2, + "want": { + "approve": false, + "blocked_at": "llm" + }, + "bucket": "llm-gate" + }, + { + "name": "llm-safe-class-high-confidence-passes", + "rationale": "Sanity check that the LLM gate isn't overly strict: a confident, non-disqualifying classification on an otherwise clean diff should pass.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 1, + "lines_added": 1 + }, + "author_level": 2, + "want": { + "approve": true + }, + "bucket": "llm-gate" + }, + { + "name": "always-approve-mode-overrides-risk-and-eligibility", + "rationale": "mode=always_approve must skip eligibility/risk/llm entirely (even for a low-level author and a risky-looking diff) - only the static jq gate still runs, and with no blocking rulesets configured it passes.", + "blast_hunks": [ + { + "combined": 90, + "new_lines": 50, + "file_path": "a/core.go" + } + ], + "findings": [ + { + "severity": "critical", + "category": "security", + "count": 1 + } + ], + "llm_class": null, + "diff": { + "file_count": 10, + "lines_added": 50 + }, + "author_level": 1, + "policy": { + "mode": "always_approve", + "min_author_level": 5 + }, + "want": { + "approve": true + }, + "bucket": "mode-shortcut" + }, + { + "name": "never-approve-mode-always-blocks", + "rationale": "mode=never_approve must block unconditionally, even for an entirely clean diff that would otherwise sail through every stage.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 1, + "lines_added": 1 + }, + "author_level": 2, + "policy": { + "mode": "never_approve" + }, + "want": { + "approve": false, + "blocked_at": "mode_override", + "reason_contains": "never_approve" + }, + "bucket": "mode-shortcut" + }, + { + "name": "threshold-p80-lenient-approves-borderline-score", + "rationale": "A moderately risky score (~0.55-0.6) against a lenient P80 org calibration (seeded from a realistic recent-score distribution): the cutoff sits above this score, so it approves. Proves risk_threshold_percentile actually moves the decision, not just the raw score.", + "blast_hunks": [ + { + "combined": 8, + "new_lines": 10, + "file_path": "a/mod.go" + } + ], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 1, + "lines_added": 10 + }, + "author_level": 2, + "policy": { + "risk_threshold_percentile": 80 + }, + "seed_recent_scores": [ + 0.1, + 0.2, + 0.3, + 0.4, + 0.5, + 0.6, + 0.65, + 0.7, + 0.8, + 0.9 + ], + "want": { + "approve": true + }, + "bucket": "threshold-dial" + }, + { + "name": "threshold-p10-strict-blocks-same-score", + "rationale": "The exact same underlying review and score as threshold-p80-lenient-approves-borderline-score, but under a strict P10 org calibration: the cutoff sits below this score, so it blocks. Same model, same score, different dial setting, different outcome.", + "blast_hunks": [ + { + "combined": 8, + "new_lines": 10, + "file_path": "a/mod.go" + } + ], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 1, + "lines_added": 10 + }, + "author_level": 2, + "policy": { + "risk_threshold_percentile": 10 + }, + "seed_recent_scores": [ + 0.1, + 0.2, + 0.3, + 0.4, + 0.5, + 0.6, + 0.65, + 0.7, + 0.8, + 0.9 + ], + "want": { + "approve": false, + "blocked_at": "risk" + }, + "bucket": "threshold-dial" + }, + { + "name": "daily-cap-exceeded-blocks-otherwise-approvable", + "rationale": "An entirely clean, otherwise-approvable review, but the org's daily_approve_cap was already reached today. The cap must still block even though every funnel stage individually passed.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 1, + "lines_added": 1 + }, + "author_level": 2, + "policy": { + "daily_approve_cap": 2 + }, + "seed_approvals_today": 2, + "want": { + "approve": false, + "blocked_at": "mode_override", + "reason_contains": "daily approve cap" + }, + "bucket": "caps-timing" + }, + { + "name": "critical-finding-in-security-blocks", + "rationale": "A single critical-severity finding categorized as 'security', otherwise a clean diff. Isolates that category's weight path: critical severity plus its category-specific weight together are enough to block, for every taxonomy category, not just security.", + "blast_hunks": [], + "findings": [ + { + "severity": "critical", + "category": "security", + "count": 1 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": false, + "blocked_at": "risk" + }, + "bucket": "taxonomy-critical" + }, + { + "name": "critical-finding-in-reliability-blocks", + "rationale": "A single critical-severity finding categorized as 'reliability', otherwise a clean diff. Isolates that category's weight path: critical severity plus its category-specific weight together are enough to block, for every taxonomy category, not just security.", + "blast_hunks": [], + "findings": [ + { + "severity": "critical", + "category": "reliability", + "count": 1 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": false, + "blocked_at": "risk" + }, + "bucket": "taxonomy-critical" + }, + { + "name": "critical-finding-in-correctness-blocks", + "rationale": "A single critical-severity finding categorized as 'correctness', otherwise a clean diff. Isolates that category's weight path: critical severity plus its category-specific weight together are enough to block, for every taxonomy category, not just security.", + "blast_hunks": [], + "findings": [ + { + "severity": "critical", + "category": "correctness", + "count": 1 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": false, + "blocked_at": "risk" + }, + "bucket": "taxonomy-critical" + }, + { + "name": "critical-finding-in-performance-blocks", + "rationale": "A single critical-severity finding categorized as 'performance', otherwise a clean diff. Isolates that category's weight path: critical severity plus its category-specific weight together are enough to block, for every taxonomy category, not just security.", + "blast_hunks": [], + "findings": [ + { + "severity": "critical", + "category": "performance", + "count": 1 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": false, + "blocked_at": "risk" + }, + "bucket": "taxonomy-critical" + }, + { + "name": "critical-finding-in-scalability-blocks", + "rationale": "A single critical-severity finding categorized as 'scalability', otherwise a clean diff. Isolates that category's weight path: critical severity plus its category-specific weight together are enough to block, for every taxonomy category, not just security.", + "blast_hunks": [], + "findings": [ + { + "severity": "critical", + "category": "scalability", + "count": 1 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": false, + "blocked_at": "risk" + }, + "bucket": "taxonomy-critical" + }, + { + "name": "critical-finding-in-compliance-and-governance-blocks", + "rationale": "A single critical-severity finding categorized as 'compliance & governance', otherwise a clean diff. Isolates that category's weight path: critical severity plus its category-specific weight together are enough to block, for every taxonomy category, not just security.", + "blast_hunks": [], + "findings": [ + { + "severity": "critical", + "category": "compliance & governance", + "count": 1 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": false, + "blocked_at": "risk" + }, + "bucket": "taxonomy-critical" + }, + { + "name": "critical-finding-in-maintainability-blocks", + "rationale": "A single critical-severity finding categorized as 'maintainability', otherwise a clean diff. Isolates that category's weight path: critical severity plus its category-specific weight together are enough to block, for every taxonomy category, not just security.", + "blast_hunks": [], + "findings": [ + { + "severity": "critical", + "category": "maintainability", + "count": 1 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": false, + "blocked_at": "risk" + }, + "bucket": "taxonomy-critical" + }, + { + "name": "critical-finding-in-architecture-blocks", + "rationale": "A single critical-severity finding categorized as 'architecture', otherwise a clean diff. Isolates that category's weight path: critical severity plus its category-specific weight together are enough to block, for every taxonomy category, not just security.", + "blast_hunks": [], + "findings": [ + { + "severity": "critical", + "category": "architecture", + "count": 1 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": false, + "blocked_at": "risk" + }, + "bucket": "taxonomy-critical" + }, + { + "name": "critical-finding-in-developer-experience-blocks", + "rationale": "A single critical-severity finding categorized as 'developer experience', otherwise a clean diff. Isolates that category's weight path: critical severity plus its category-specific weight together are enough to block, for every taxonomy category, not just security.", + "blast_hunks": [], + "findings": [ + { + "severity": "critical", + "category": "developer experience", + "count": 1 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": false, + "blocked_at": "risk" + }, + "bucket": "taxonomy-critical" + }, + { + "name": "critical-finding-in-cost-blocks", + "rationale": "A single critical-severity finding categorized as 'cost', otherwise a clean diff. Isolates that category's weight path: critical severity plus its category-specific weight together are enough to block, for every taxonomy category, not just security.", + "blast_hunks": [], + "findings": [ + { + "severity": "critical", + "category": "cost", + "count": 1 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": false, + "blocked_at": "risk" + }, + "bucket": "taxonomy-critical" + }, + { + "name": "warning-finding-in-maintainability-stays-safe", + "rationale": "A single warning-severity 'maintainability' finding, clean diff otherwise. Low-weight categories should not block on a single non-critical finding, contrasting with the critical-severity version of this same category above.", + "blast_hunks": [], + "findings": [ + { + "severity": "warning", + "category": "maintainability", + "count": 1 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": true + }, + "bucket": "taxonomy-warning" + }, + { + "name": "warning-finding-in-architecture-stays-safe", + "rationale": "A single warning-severity 'architecture' finding, clean diff otherwise. Low-weight categories should not block on a single non-critical finding, contrasting with the critical-severity version of this same category above.", + "blast_hunks": [], + "findings": [ + { + "severity": "warning", + "category": "architecture", + "count": 1 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": true + }, + "bucket": "taxonomy-warning" + }, + { + "name": "warning-finding-in-developer-experience-stays-safe", + "rationale": "A single warning-severity 'developer experience' finding, clean diff otherwise. Low-weight categories should not block on a single non-critical finding, contrasting with the critical-severity version of this same category above.", + "blast_hunks": [], + "findings": [ + { + "severity": "warning", + "category": "developer experience", + "count": 1 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": true + }, + "bucket": "taxonomy-warning" + }, + { + "name": "warning-finding-in-cost-stays-safe", + "rationale": "A single warning-severity 'cost' finding, clean diff otherwise. Low-weight categories should not block on a single non-critical finding, contrasting with the critical-severity version of this same category above.", + "blast_hunks": [], + "findings": [ + { + "severity": "warning", + "category": "cost", + "count": 1 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": true + }, + "bucket": "taxonomy-warning" + }, + { + "name": "static-gate-blocks-on-tripped-ruleset", + "rationale": "A blocking ruleset's jq expression matches a finding in this review (a hardcoded-secret type). Risk/eligibility/llm are all otherwise safe, isolating the static gate as the blocker.", + "blast_hunks": [], + "findings": [ + { + "severity": "critical", + "category": "security", + "type": "hardcoded-secret", + "count": 1 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "rulesets": [ + { + "name": "no-secrets", + "jq_expr": "[.findings[] | select(.type == \"hardcoded-secret\")] | length > 0" + } + ], + "policy": { + "blocking_rulesets": [ + "no-secrets" + ] + }, + "want": { + "approve": false, + "blocked_at": "static" + }, + "bucket": "static-gate" + }, + { + "name": "static-gate-passes-when-rule-not-tripped", + "rationale": "The same no-secrets ruleset is attached, but this review has no matching finding -- the static gate should pass, not block defensively just because a ruleset exists.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "rulesets": [ + { + "name": "no-secrets", + "jq_expr": "[.findings[] | select(.type == \"hardcoded-secret\")] | length > 0" + } + ], + "policy": { + "blocking_rulesets": [ + "no-secrets" + ] + }, + "want": { + "approve": true + }, + "bucket": "static-gate" + }, + { + "name": "static-gate-multiple-rulesets-only-one-trips", + "rationale": "Two blocking rulesets are attached; only the second one's condition is actually met. The gate must still block, and the reason should name the ruleset that actually tripped.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "rulesets": [ + { + "name": "always-false", + "jq_expr": "false" + }, + { + "name": "always-true", + "jq_expr": "true" + } + ], + "policy": { + "blocking_rulesets": [ + "always-false", + "always-true" + ] + }, + "want": { + "approve": false, + "blocked_at": "static", + "reason_contains": "always-true" + }, + "bucket": "static-gate" + }, + { + "name": "static-gate-runs-even-under-always-approve", + "rationale": "mode=always_approve is a repo-wide 'trust this repo' shortcut, but the static jq gate must still run and still block on a tripped ruleset -- trust in a repo must never silently become 'ignore a secrets-leak detector'.", + "blast_hunks": [], + "findings": [], + "llm_class": null, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 1, + "rulesets": [ + { + "name": "always-true-2", + "jq_expr": "true" + } + ], + "policy": { + "mode": "always_approve", + "blocking_rulesets": [ + "always-true-2" + ] + }, + "want": { + "approve": false, + "blocked_at": "static" + }, + "bucket": "static-gate" + }, + { + "name": "static-gate-passes-under-always-approve-when-rule-not-tripped", + "rationale": "mode=always_approve with a blocking ruleset attached that does not trip: the mode override approves, since the one gate that still runs under this mode passed.", + "blast_hunks": [], + "findings": [], + "llm_class": null, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 1, + "rulesets": [ + { + "name": "always-false-2", + "jq_expr": "false" + } + ], + "policy": { + "mode": "always_approve", + "blocking_rulesets": [ + "always-false-2" + ] + }, + "want": { + "approve": true + }, + "bucket": "static-gate" + }, + { + "name": "static-gate-no-rulesets-configured-passes-trivially", + "rationale": "No blocking rulesets configured at all: the static stage should pass trivially rather than erroring or defensively blocking on an empty configuration.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": true + }, + "bucket": "static-gate" + }, + { + "name": "unmapped-author-default-level-passes-at-min-level-1", + "rationale": "The PR author has no user_identities mapping at all (never invited, or a brand-new contributor). Unmapped non-bot authors default to review level 1 -- this should be enough to pass the default min_author_level=1.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_unmapped": true, + "policy": { + "min_author_level": 1 + }, + "want": { + "approve": true + }, + "bucket": "eligibility" + }, + { + "name": "unmapped-author-default-level-blocked-at-min-level-2", + "rationale": "Same unmapped author (default level 1), but the policy requires level 2. The fail-closed default should not accidentally satisfy a higher bar.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_unmapped": true, + "policy": { + "min_author_level": 2 + }, + "want": { + "approve": false, + "blocked_at": "eligibility" + }, + "bucket": "eligibility" + }, + { + "name": "llm-confidence-exactly-at-required-passes", + "rationale": "Classification confidence is exactly equal to require_llm_confidence, not above it. The gate's check is confidence < required, so an exact match must pass, not fail.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 8 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "policy": { + "require_llm_confidence": 8 + }, + "want": { + "approve": true + }, + "bucket": "llm-gate" + }, + { + "name": "two-critical-security-findings-compounds", + "rationale": "Two critical security findings instead of one. Should block at least as decisively as the single-finding version -- more risk signal should never look safer.", + "blast_hunks": [], + "findings": [ + { + "severity": "critical", + "category": "security", + "count": 2 + } + ], + "llm_class": { + "class": "security-sensitive-but-confident", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": false, + "blocked_at": "risk" + }, + "bucket": "compounding" + }, + { + "name": "mixed-low-weight-category-info-findings-stays-safe", + "rationale": "Several different taxonomy categories each contribute one info-severity finding, all low-weight categories. Info-severity findings carry no severity weight of their own (only 'critical' and 'warning' do) -- variety across categories at info level should not be treated as risk. (An earlier version of this scenario used warning severity and was corrected after this test run showed 3 warnings' severity weight alone was enough to tip the score -- see two-warnings-no-criticals-stays-safe for where that line sits.)", + "blast_hunks": [], + "findings": [ + { + "severity": "info", + "category": "maintainability", + "count": 1 + }, + { + "severity": "info", + "category": "architecture", + "count": 1 + }, + { + "severity": "info", + "category": "cost", + "count": 1 + } + ], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "want": { + "approve": true + }, + "bucket": "taxonomy-warning" + }, + { + "name": "landing-delay-passes-through-on-approval", + "rationale": "A nonzero landing_delay_seconds is configured. The engine itself doesn't enforce the delay (the worker does, by queuing a follow-up job), but it must carry the configured value through on the result so the worker can act on it.", + "blast_hunks": [], + "findings": [], + "llm_class": { + "class": "refactor-only", + "confidence": 9 + }, + "diff": { + "file_count": 0, + "lines_added": 0 + }, + "author_level": 2, + "policy": {}, + "want": { + "approve": true + }, + "bucket": "caps-timing" + } +] diff --git a/internal/prompts/registry_stub.go b/internal/prompts/registry_stub.go index 6eb39c08b..d777c8dcd 100644 --- a/internal/prompts/registry_stub.go +++ b/internal/prompts/registry_stub.go @@ -13,6 +13,7 @@ func PlaintextTemplates() []PlaintextTemplate { CommentRequirements + "\n\n" + JSONStructureExample + "\n\n" + TaxonomyClassificationRules + "\n\n" + + DiffClassificationRules + "\n\n" + CommentClassification + "\n\n" + LineNumberInstructions + "\n\n" + "{{VAR:style_guide}}\n\n{{VAR:security_guidelines}}"}, diff --git a/internal/prompts/templates.go b/internal/prompts/templates.go index a9b62984c..b73f320e9 100644 --- a/internal/prompts/templates.go +++ b/internal/prompts/templates.go @@ -87,7 +87,11 @@ const ( "suggestions": ["Specific improvement suggestion 1", "Specific improvement suggestion 2"], "isInternal": false } - ] + ], + "diffClassification": { + "class": "one of the classes from DIFF CLASSIFICATION RULES", + "confidence": 8 + } } ` + "```" ) @@ -121,7 +125,27 @@ TAXONOMY (category -> allowed subcategories): ` + "```" ) -// Issue detection checklist +// Diff classification rules +const ( + // DiffClassificationRules is a closed, fixed taxonomy for the diff-level + // semantic classification used by the auto-approve policy's LLM gate + // (docs/risk-policy-engine.md §5). "class" MUST be exactly one of these + // values, verbatim; "confidence" is an integer 0-10 expressing how sure the + // model is. This rides inside the same review JSON (no second LLM call). + DiffClassificationRules = `DIFF CLASSIFICATION RULES: +- After reviewing the whole diff, classify it with exactly one "class" from the closed list below, verbatim (no other values): + "refactor-only" (behavior-preserving rename/extract/cleanup; no logic change) + "dead-code-removal" (removing unused code/imports with no behavior change) + "docs-only" (documentation/comments/changelog changes only) + "test-only" (test additions or changes only) + "config-change" (build/dependency/CI/feature-flag or other configuration change) + "dependency-change" (adding/removing/upgrading third-party dependencies) + "data-migration" (schema or data migration changes) + "logic-change" (changes to runtime behavior, control flow, or business logic) + "security-sensitive" (changes touching auth, permissions, secrets, cryptography, input validation, or data exposure) +- "confidence" is an integer 0-10: how confident you are in the chosen class. Use 0 when genuinely unsure, 10 when unambiguous. +- Pick the single most dominant class for the whole diff. If the diff spans multiple concerns, choose the highest-risk one that applies.` +) const ( // IssueDetectionChecklist enumerates the categories and specific issues the reviewer must watch for IssueDetectionChecklist = `ISSUE DETECTION FOCUS AREAS: diff --git a/internal/provider_input/azuredevops/azuredevops_pr_state_event.go b/internal/provider_input/azuredevops/azuredevops_pr_state_event.go new file mode 100644 index 000000000..abd9bb3ca --- /dev/null +++ b/internal/provider_input/azuredevops/azuredevops_pr_state_event.go @@ -0,0 +1,77 @@ +package azuredevops + +import ( + "encoding/json" + "fmt" + neturl "net/url" + "strings" + "time" + + "github.com/livereview/internal/prsync" +) + +// ConvertPullRequestStateEvent handles git.pullrequest.created/updated so new PRs get the review links. +func (p *AzureDevOpsV2Provider) ConvertPullRequestStateEvent(headers map[string]string, body []byte) (*prsync.PullRequestStateEvent, bool, error) { + var payload AzureWebhookPayload + if err := json.Unmarshal(body, &payload); err != nil { + return nil, false, nil + } + action := map[string]string{"git.pullrequest.created": "opened", "git.pullrequest.updated": "updated"}[payload.EventType] + if action == "" { + return nil, false, nil + } + + var pr AzurePullRequestResource + if err := json.Unmarshal(payload.Resource, &pr); err != nil { + return nil, true, fmt.Errorf("failed to parse Azure DevOps pull request resource: %w", err) + } + repoWebURL := azureRepoWebURL(pr.Repository.RemoteURL) + + return &prsync.PullRequestStateEvent{ + RepositoryProviderID: pr.Repository.ID, + RepositoryFullName: pr.Repository.Project.Name + "/" + pr.Repository.Name, + RepositoryWebURL: repoWebURL, + + Number: int(pr.PullRequestID), + ProviderPRID: fmt.Sprintf("%d", pr.PullRequestID), + Title: pr.Title, + Description: pr.Description, + State: normalizeAzureState(pr.Status), + AuthorID: pr.CreatedBy.ID, + AuthorUsername: pr.CreatedBy.UniqueName, + AuthorName: pr.CreatedBy.DisplayName, + AuthorAvatarURL: pr.CreatedBy.ImageURL, + SourceBranch: strings.TrimPrefix(pr.SourceRefName, "refs/heads/"), + TargetBranch: strings.TrimPrefix(pr.TargetRefName, "refs/heads/"), + WebURL: fmt.Sprintf("%s/pullrequest/%d", repoWebURL, pr.PullRequestID), + ProviderCreatedAt: parseAzureTime(pr.CreationDate), + ProviderUpdatedAt: parseAzureTime(payload.CreatedDate), + Metadata: map[string]interface{}{"action": action}, + }, true, nil +} + +// azureRepoWebURL drops the "user@" part Azure puts in remoteUrl, giving the repo's browser URL. +func azureRepoWebURL(remoteURL string) string { + u, err := neturl.Parse(remoteURL) + if err != nil { + return remoteURL + } + u.User = nil + return strings.TrimSuffix(u.String(), "/") +} + +func normalizeAzureState(status string) string { + switch status { + case "completed": + return "merged" + case "abandoned": + return "closed" + default: + return "open" + } +} + +func parseAzureTime(s string) time.Time { + t, _ := time.Parse(time.RFC3339Nano, s) + return t +} diff --git a/internal/provider_input/bitbucket/bitbucket_pr_state_event.go b/internal/provider_input/bitbucket/bitbucket_pr_state_event.go new file mode 100644 index 000000000..1b8f286d8 --- /dev/null +++ b/internal/provider_input/bitbucket/bitbucket_pr_state_event.go @@ -0,0 +1,75 @@ +package bitbucket + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/livereview/internal/prsync" + "github.com/livereview/internal/webhookutils" +) + +// bitbucketStateActions maps the PR lifecycle event keys this handles to the action name the sync worker expects. +// "pullrequest:updated" is left out on purpose: it drives the reviewer-assignment flow. +var bitbucketStateActions = map[string]string{ + "pullrequest:created": "opened", + "pullrequest:fulfilled": "merged", + "pullrequest:rejected": "declined", +} + +// ConvertPullRequestStateEvent handles Bitbucket PR created/merged/declined events so new PRs get the review links. +func (p *BitbucketV2Provider) ConvertPullRequestStateEvent(headers map[string]string, body []byte) (*prsync.PullRequestStateEvent, bool, error) { + eventKey, _ := webhookutils.GetHeaderCaseInsensitive(headers, "X-Event-Key") + action, ok := bitbucketStateActions[eventKey] + if !ok { + return nil, false, nil + } + + var payload BitbucketV2WebhookPayload + if err := json.Unmarshal(body, &payload); err != nil { + return nil, true, fmt.Errorf("failed to parse Bitbucket %s webhook: %w", eventKey, err) + } + pr, repo := payload.PullRequest, payload.Repository + + username := pr.Author.Username + if username == "" { + username = pr.Author.DisplayName + } + return &prsync.PullRequestStateEvent{ + RepositoryProviderID: repo.UUID, + RepositoryFullName: repo.FullName, + RepositoryWebURL: repo.Links.HTML.Href, + + Number: pr.ID, + ProviderPRID: fmt.Sprintf("%d", pr.ID), + Title: pr.Title, + Description: pr.Description, + State: normalizeBitbucketState(pr.State), + AuthorID: pr.Author.AccountID, + AuthorUsername: username, + AuthorName: pr.Author.DisplayName, + SourceBranch: pr.Source.Name, + TargetBranch: pr.Destination.Name, + WebURL: pr.Links.HTML.Href, + ProviderCreatedAt: parseBitbucketTime(pr.CreatedOn), + ProviderUpdatedAt: parseBitbucketTime(pr.UpdatedOn), + Metadata: map[string]interface{}{"action": action}, + }, true, nil +} + +func normalizeBitbucketState(state string) string { + switch strings.ToUpper(state) { + case "MERGED": + return "merged" + case "DECLINED", "SUPERSEDED": + return "closed" + default: + return "open" + } +} + +func parseBitbucketTime(s string) time.Time { + t, _ := time.Parse(time.RFC3339Nano, s) + return t +} diff --git a/internal/provider_input/gitea/gitea_pr_state_event.go b/internal/provider_input/gitea/gitea_pr_state_event.go new file mode 100644 index 000000000..33e235615 --- /dev/null +++ b/internal/provider_input/gitea/gitea_pr_state_event.go @@ -0,0 +1,60 @@ +package gitea + +import ( + "encoding/json" + "fmt" + + "github.com/livereview/internal/prsync" + "github.com/livereview/internal/webhookutils" +) + +// ConvertPullRequestStateEvent handles Gitea's "pull_request" event so new PRs get the review links. +// Review-request actions are left to the existing reviewer flow. +func (p *GiteaV2Provider) ConvertPullRequestStateEvent(headers map[string]string, body []byte) (*prsync.PullRequestStateEvent, bool, error) { + eventType, _ := webhookutils.GetHeaderCaseInsensitive(headers, "X-Gitea-Event") + if eventType != "pull_request" { + return nil, false, nil + } + + var payload GiteaV2WebhookPayload + if err := json.Unmarshal(body, &payload); err != nil { + return nil, true, fmt.Errorf("failed to parse Gitea pull_request webhook: %w", err) + } + switch payload.Action { + case "review_requested", "review_request_removed": + return nil, false, nil + } + pr, repo := payload.PullRequest, payload.Repository + if pr == nil || repo == nil { + return nil, true, fmt.Errorf("gitea pull_request webhook is missing pull_request or repository") + } + + event := &prsync.PullRequestStateEvent{ + RepositoryProviderID: fmt.Sprintf("%d", repo.ID), + RepositoryFullName: repo.FullName, + RepositoryWebURL: repo.HTMLURL, + + Number: int(pr.Number), + ProviderPRID: fmt.Sprintf("%d", pr.ID), + Title: pr.Title, + Description: pr.Body, + State: prsync.NormalizeGitHubState(pr.State, pr.Merged), + WebURL: pr.HTMLURL, + ProviderCreatedAt: pr.CreatedAt, + ProviderUpdatedAt: pr.UpdatedAt, + Metadata: map[string]interface{}{"action": payload.Action, "merged": pr.Merged}, + } + if pr.User != nil { + event.AuthorID = fmt.Sprintf("%d", pr.User.ID) + event.AuthorUsername = pr.User.Login + event.AuthorName = pr.User.FullName + event.AuthorAvatarURL = pr.User.AvatarURL + } + if pr.Head != nil { + event.SourceBranch = pr.Head.Ref + } + if pr.Base != nil { + event.TargetBranch = pr.Base.Ref + } + return event, true, nil +} diff --git a/internal/providers/azuredevops/pr_description.go b/internal/providers/azuredevops/pr_description.go new file mode 100644 index 000000000..26bdd7350 --- /dev/null +++ b/internal/providers/azuredevops/pr_description.go @@ -0,0 +1,111 @@ +package azuredevops + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + neturl "net/url" + "strings" +) + +// Helpers for the PR review links section and the auto-approve policy result. +// projectRepo is "project/repo", the repositories.full_name LiveReview stores for Azure DevOps. + +func (p *Provider) prAPIPath(projectRepo string, number int) (string, error) { + project, repo, ok := strings.Cut(projectRepo, "/") + if !ok { + return "", fmt.Errorf("invalid Azure DevOps repository name %q, expected project/repo", projectRepo) + } + return fmt.Sprintf("%s/%s/_apis/git/repositories/%s/pullRequests/%d", + p.baseURL, neturl.PathEscape(project), neturl.PathEscape(repo), number), nil +} + +// GetPRDescription returns the PR description. +func (p *Provider) GetPRDescription(ctx context.Context, projectRepo string, number int) (string, error) { + path, err := p.prAPIPath(projectRepo, number) + if err != nil { + return "", err + } + var pr struct { + Description string `json:"description"` + } + err = p.doJSON(ctx, http.MethodGet, path, nil, &pr) + return pr.Description, err +} + +// SetPRDescription replaces the PR description (Azure DevOps caps it at 4000 characters). +func (p *Provider) SetPRDescription(ctx context.Context, projectRepo string, number int, description string) error { + path, err := p.prAPIPath(projectRepo, number) + if err != nil { + return err + } + return p.doJSON(ctx, http.MethodPatch, path, map[string]string{"description": description}, nil) +} + +// PostPRThread posts a general (non-file) comment thread on the PR. +func (p *Provider) PostPRThread(ctx context.Context, projectRepo string, number int, body string) error { + path, err := p.prAPIPath(projectRepo, number) + if err != nil { + return err + } + payload := map[string]interface{}{ + "status": 1, + "comments": []map[string]interface{}{{"parentCommentId": 0, "content": body, "commentType": 1}}, + } + return p.doJSON(ctx, http.MethodPost, path+"/threads", payload, nil) +} + +// SetPRVote sets the bot's reviewer vote: approved (10), or "waiting for author" (-5) when approve is false. +func (p *Provider) SetPRVote(ctx context.Context, projectRepo string, number int, approve bool) error { + path, err := p.prAPIPath(projectRepo, number) + if err != nil { + return err + } + bot, err := p.GetBotIdentity(ctx) + if err != nil { + return fmt.Errorf("failed to resolve bot identity: %w", err) + } + vote := -5 + if approve { + vote = 10 + } + return p.doJSON(ctx, http.MethodPut, path+"/reviewers/"+neturl.PathEscape(bot.ID), map[string]int{"vote": vote}, nil) +} + +func (p *Provider) doJSON(ctx context.Context, method, apiURL string, payload, out interface{}) error { + var reqBody io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to encode request: %w", err) + } + reqBody = bytes.NewReader(data) + } + sep := "?" + if strings.Contains(apiURL, "?") { + sep = "&" + } + req, err := http.NewRequestWithContext(ctx, method, apiURL+sep+"api-version="+apiVersion, reqBody) + if err != nil { + return err + } + p.applyAuth(req) + req.Header.Set("Content-Type", "application/json") + + resp, err := p.do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf("azure devops %s failed with status %d: %s", method, resp.StatusCode, string(data)) + } + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} diff --git a/internal/providers/bitbucket/pr_description.go b/internal/providers/bitbucket/pr_description.go new file mode 100644 index 000000000..26597483f --- /dev/null +++ b/internal/providers/bitbucket/pr_description.go @@ -0,0 +1,86 @@ +package bitbucket + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Helpers for the PR review links section and the auto-approve policy result. +// Bitbucket Cloud API tokens use basic auth with the account email. + +const bitbucketAPIBase = "https://api.bitbucket.org/2.0" + +type bitbucketPR struct { + Title string `json:"title"` + Description string `json:"description"` +} + +// GetPRBody returns the PR description. +func GetPRBody(ctx context.Context, email, token, fullName string, number int) (string, error) { + var pr bitbucketPR + err := doBitbucketJSON(ctx, http.MethodGet, email, token, fmt.Sprintf("/repositories/%s/pullrequests/%d", fullName, number), nil, &pr) + return pr.Description, err +} + +// SetPRBody replaces the PR description. Bitbucket's update needs the title too, so it is re-read first. +func SetPRBody(ctx context.Context, email, token, fullName string, number int, description string) error { + path := fmt.Sprintf("/repositories/%s/pullrequests/%d", fullName, number) + var pr bitbucketPR + if err := doBitbucketJSON(ctx, http.MethodGet, email, token, path, nil, &pr); err != nil { + return err + } + return doBitbucketJSON(ctx, http.MethodPut, email, token, path, bitbucketPR{Title: pr.Title, Description: description}, nil) +} + +// PostPRComment posts a general comment on the PR. +func PostPRComment(ctx context.Context, email, token, fullName string, number int, body string) error { + payload := map[string]interface{}{"content": map[string]string{"raw": body}} + return doBitbucketJSON(ctx, http.MethodPost, email, token, fmt.Sprintf("/repositories/%s/pullrequests/%d/comments", fullName, number), payload, nil) +} + +// SetPRVerdict approves the PR, or marks it "changes requested" when approve is false. +func SetPRVerdict(ctx context.Context, email, token, fullName string, number int, approve bool) error { + action := "request-changes" + if approve { + action = "approve" + } + return doBitbucketJSON(ctx, http.MethodPost, email, token, fmt.Sprintf("/repositories/%s/pullrequests/%d/%s", fullName, number, action), nil, nil) +} + +func doBitbucketJSON(ctx context.Context, method, email, token, path string, payload, out interface{}) error { + var reqBody io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to encode request: %w", err) + } + reqBody = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, bitbucketAPIBase+path, reqBody) + if err != nil { + return err + } + req.SetBasicAuth(email, token) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "LiveReview-Bot") + + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf("bitbucket %s %s failed with status %d: %s", method, path, resp.StatusCode, string(data)) + } + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} diff --git a/internal/providers/gitea/pr_description.go b/internal/providers/gitea/pr_description.go new file mode 100644 index 000000000..0a44a1214 --- /dev/null +++ b/internal/providers/gitea/pr_description.go @@ -0,0 +1,70 @@ +package gitea + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Helpers for the PR review links section and the auto-approve policy result. +// baseURL is the connector's Gitea URL; rawToken is the stored (possibly packed) PAT. + +// GetPRBody returns the PR description. +func GetPRBody(ctx context.Context, baseURL, rawToken, ownerRepo string, number int) (string, error) { + var pr struct { + Body string `json:"body"` + } + err := doGiteaJSON(ctx, http.MethodGet, baseURL, rawToken, fmt.Sprintf("/repos/%s/pulls/%d", ownerRepo, number), nil, &pr) + return pr.Body, err +} + +// SetPRBody replaces the PR description. +func SetPRBody(ctx context.Context, baseURL, rawToken, ownerRepo string, number int, body string) error { + return doGiteaJSON(ctx, http.MethodPatch, baseURL, rawToken, fmt.Sprintf("/repos/%s/pulls/%d", ownerRepo, number), map[string]string{"body": body}, nil) +} + +// PostIssueComment posts a plain comment on the PR conversation. +func PostIssueComment(ctx context.Context, baseURL, rawToken, ownerRepo string, number int, body string) error { + return doGiteaJSON(ctx, http.MethodPost, baseURL, rawToken, fmt.Sprintf("/repos/%s/issues/%d/comments", ownerRepo, number), map[string]string{"body": body}, nil) +} + +// SubmitPullRequestReview submits a review; event is "APPROVED" or "REQUEST_CHANGES". +func SubmitPullRequestReview(ctx context.Context, baseURL, rawToken, ownerRepo string, number int, event, body string) error { + return doGiteaJSON(ctx, http.MethodPost, baseURL, rawToken, fmt.Sprintf("/repos/%s/pulls/%d/reviews", ownerRepo, number), map[string]string{"event": event, "body": body}, nil) +} + +func doGiteaJSON(ctx context.Context, method, baseURL, rawToken, path string, payload, out interface{}) error { + var reqBody io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to encode request: %w", err) + } + reqBody = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, NormalizeGiteaBaseURL(baseURL)+"/api/v1"+path, reqBody) + if err != nil { + return err + } + req.Header.Set("Authorization", "token "+UnpackGiteaPAT(rawToken)) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf("gitea %s %s failed with status %d: %s", method, path, resp.StatusCode, string(data)) + } + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} diff --git a/internal/providers/github/pr_comment.go b/internal/providers/github/pr_comment.go new file mode 100644 index 000000000..179561931 --- /dev/null +++ b/internal/providers/github/pr_comment.go @@ -0,0 +1,81 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// PostIssueComment posts a plain top-level comment on a PR (POST /repos/{owner}/{repo}/issues/{number}/comments). +// Unlike GitHubProvider.PostComment it honors GitHub Enterprise base URLs and posts body as-is. +func PostIssueComment(ctx context.Context, baseURL, pat, ownerRepo string, number int, body string) error { + path := fmt.Sprintf("/repos/%s/issues/%d/comments", ownerRepo, number) + return postGitHubJSON(ctx, baseURL, pat, path, map[string]string{"body": body}) +} + +// SubmitPullRequestReview submits a PR review with event "APPROVE" or "REQUEST_CHANGES" and body as its text. +// GitHub rejects both events when the token's user is the PR author. +func SubmitPullRequestReview(ctx context.Context, baseURL, pat, ownerRepo string, number int, event, body string) error { + path := fmt.Sprintf("/repos/%s/pulls/%d/reviews", ownerRepo, number) + return postGitHubJSON(ctx, baseURL, pat, path, map[string]string{"event": event, "body": body}) +} + +// GetPRBody returns the PR description. +func GetPRBody(ctx context.Context, baseURL, pat, ownerRepo string, number int) (string, error) { + var pr struct { + Body string `json:"body"` + } + err := doGitHubJSON(ctx, http.MethodGet, baseURL, pat, fmt.Sprintf("/repos/%s/pulls/%d", ownerRepo, number), nil, &pr) + return pr.Body, err +} + +// SetPRBody replaces the PR description. +func SetPRBody(ctx context.Context, baseURL, pat, ownerRepo string, number int, body string) error { + path := fmt.Sprintf("/repos/%s/pulls/%d", ownerRepo, number) + return doGitHubJSON(ctx, http.MethodPatch, baseURL, pat, path, map[string]string{"body": body}, nil) +} + +func postGitHubJSON(ctx context.Context, baseURL, pat, path string, payload interface{}) error { + return doGitHubJSON(ctx, http.MethodPost, baseURL, pat, path, payload, nil) +} + +// doGitHubJSON sends payload (if any) and decodes the response into out (if non-nil). +func doGitHubJSON(ctx context.Context, method, baseURL, pat, path string, payload, out interface{}) error { + var reqBody io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to encode request: %w", err) + } + reqBody = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, apiBaseURL(baseURL)+path, reqBody) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Add("Authorization", "token "+pat) + req.Header.Add("Accept", "application/vnd.github.v3+json") + req.Header.Add("Content-Type", "application/json") + req.Header.Add("User-Agent", "LiveReview/1.0") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("GitHub request %s failed with status %d: %s", path, resp.StatusCode, string(respBody)) + } + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} diff --git a/internal/providers/gitlab/gitlab_comment.go b/internal/providers/gitlab/gitlab_comment.go index 24c8ebbd6..9685900a5 100644 --- a/internal/providers/gitlab/gitlab_comment.go +++ b/internal/providers/gitlab/gitlab_comment.go @@ -1,6 +1,7 @@ package gitlab import ( + "bytes" "encoding/json" "fmt" "io" @@ -157,6 +158,28 @@ func (c *GitLabHTTPClient) CreateMRGeneralComment(projectID string, mrIID int, c return nil } +// ApproveMR approves a merge request as the token's user (POST /merge_requests/:iid/approve). +func (c *GitLabHTTPClient) ApproveMR(projectID string, mrIID int) error { + requestURL := fmt.Sprintf("%s/projects/%s/merge_requests/%d/approve", c.baseURL, url.PathEscape(projectID), mrIID) + req, err := http.NewRequest("POST", requestURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Add("PRIVATE-TOKEN", c.token) + + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("approve request failed with status %d: %s", resp.StatusCode, string(body)) + } + return nil +} + // Enhanced implementation of CreateMRLineComment that uses the GitLab API documentation approach // This overrides the implementation in http_client.go func (c *GitLabHTTPClient) CreateMRLineComment(projectID string, mrIID int, filePath string, lineNum int, comment string, isDeletedLine ...bool) error { @@ -486,3 +509,31 @@ func (c *GitLabHTTPClient) tryFormBasedLineComment(projectID string, mrIID int, return nil } + +// UpdateMRDescription replaces the MR description. +func (c *GitLabHTTPClient) UpdateMRDescription(projectID string, mrIID int, description string) error { + data, err := json.Marshal(map[string]string{"description": description}) + if err != nil { + return fmt.Errorf("failed to encode request: %w", err) + } + + requestURL := fmt.Sprintf("%s/projects/%s/merge_requests/%d", c.baseURL, url.PathEscape(projectID), mrIID) + req, err := http.NewRequest("PUT", requestURL, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Add("PRIVATE-TOKEN", c.token) + req.Header.Add("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("update MR description failed with status %d: %s", resp.StatusCode, string(body)) + } + return nil +} diff --git a/internal/review/helper_transform.go b/internal/review/helper_transform.go index 65081a7a4..8e603a730 100644 --- a/internal/review/helper_transform.go +++ b/internal/review/helper_transform.go @@ -202,6 +202,8 @@ func cloneReviewResult(result *models.ReviewResult) *models.ReviewResult { Comments: make([]*models.ReviewComment, 0, len(result.Comments)), InternalComments: make([]*models.ReviewComment, 0, len(result.InternalComments)), Quiz: append([]models.QuizQuestion(nil), result.Quiz...), + // Keep the leader's diff classification; the helper only rewrites comments. + DiffClassification: result.DiffClassification, } for _, comment := range result.Comments { diff --git a/internal/review/service.go b/internal/review/service.go index 627c51f04..030a5d750 100644 --- a/internal/review/service.go +++ b/internal/review/service.go @@ -99,6 +99,7 @@ type ReviewResult struct { CommentsCount int Comments []*models.ReviewComment // Added to track actual comment details Quiz []models.QuizQuestion // Optional comprehension quiz for this review, nil if none generated + DiffClassification *models.DiffClassification // Diff-level semantic classification (docs/risk-policy-engine.md §5) BillableLOC int64 Provider string Model string @@ -346,6 +347,7 @@ func (s *Service) ProcessReview(ctx context.Context, request ReviewRequest) *Rev result.CommentsCount = len(reviewData.Result.Comments) result.Comments = reviewData.Result.Comments // Include actual comment details result.Quiz = reviewData.Result.Quiz + result.DiffClassification = reviewData.Result.DiffClassification result.BillableLOC = reviewData.BillableLOC providerName := strings.TrimSpace(request.AI.Type) if configuredProvider, ok := request.AI.Config["provider_name"].(string); ok && strings.TrimSpace(configuredProvider) != "" { diff --git a/internal/review_processor/manual.go b/internal/review_processor/manual.go index d0c0edf75..8dc43ab48 100644 --- a/internal/review_processor/manual.go +++ b/internal/review_processor/manual.go @@ -79,6 +79,8 @@ func ProcessManualReview( "summary": result.Summary, "comments": result.Comments, "quiz": result.Quiz, + // Read by the auto-approve policy's LLM classification stage. + "diffClassification": result.DiffClassification, }, }); err != nil { log.Printf("[WARN] failed to persist review_result for review %d: %v", reviewID, err) diff --git a/internal/riskscore/calibration.go b/internal/riskscore/calibration.go new file mode 100644 index 000000000..a790a7dc4 --- /dev/null +++ b/internal/riskscore/calibration.go @@ -0,0 +1,90 @@ +package riskscore + +import ( + "context" + "database/sql" + "sort" +) + +// Percentile calibration turns a per-org risk_threshold_percentile into an +// actual score cutoff: the org's own recent score distribution defines "P20", +// not a global magic number. The curve is org-wide (one model per org), while +// the percentile read off it is repo-overridable. + +// defaultCutoff is the cold-start threshold (no scores yet): 0.5, i.e. block +// reviews whose risk score is at or above the neutral midpoint. With the seed +// prior (bias -0.5), a clean review scores ~0.38 (approves) and one critical +// finding scores ~0.6 (blocks). +const defaultCutoff = 0.5 + +// LoadRecentScores returns the org's most recent risk scores (newest first), +// from risk_feature_snapshots, limited to limit rows. +func LoadRecentScores(ctx context.Context, db *sql.DB, orgID int64, limit int) ([]float64, error) { + if limit <= 0 { + limit = 1000 + } + rows, err := db.QueryContext(ctx, + `SELECT score FROM risk_feature_snapshots WHERE org_id = $1 ORDER BY created_at DESC LIMIT $2`, orgID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + scores := make([]float64, 0, limit) + for rows.Next() { + var s float64 + if err := rows.Scan(&s); err != nil { + return nil, err + } + scores = append(scores, s) + } + return scores, rows.Err() +} + +// PercentileCutoff returns the score value at the p-th percentile of the +// distribution (nearest-rank). A review blocks when its score >= this value. +// Empty distribution returns defaultCutoff. +func PercentileCutoff(scores []float64, p int) float64 { + if len(scores) == 0 { + return defaultCutoff + } + if p <= 0 { + p = 1 + } + if p > 100 { + p = 100 + } + sorted := make([]float64, len(scores)) + copy(sorted, scores) + sort.Float64s(sorted) + + // Nearest-rank method: index = ceil(p/100 * n) - 1, clamped. + idx := (p*len(sorted)+99)/100 - 1 + if idx < 0 { + idx = 0 + } + if idx >= len(sorted) { + idx = len(sorted) - 1 + } + return sorted[idx] +} + +// PercentileOf returns the percentile rank (0-100) of score within the +// distribution (the percentage of scores <= score), for the UI's +// "risk score 34 (P45)" display. +func PercentileOf(score float64, scores []float64) int { + if len(scores) == 0 { + return 0 + } + sorted := make([]float64, len(scores)) + copy(sorted, scores) + sort.Float64s(sorted) + + le := 0 + for _, s := range sorted { + if s <= score { + le++ + } + } + return le * 100 / len(sorted) +} diff --git a/internal/riskscore/features.go b/internal/riskscore/features.go new file mode 100644 index 000000000..d8f49bf26 --- /dev/null +++ b/internal/riskscore/features.go @@ -0,0 +1,162 @@ +// Package riskscore implements the trained risk model behind the auto-approve +// policy engine: feature extraction, an online-learning logistic regression +// (goml's linear.Logistic for batch gradient ascent, plus a custom +// prior-anchored regularizer and category-level weight overrides), and the +// percentile calibration that turns a per-org risk_threshold_percentile into a +// concrete cutoff. See docs/risk-policy-engine.md §3 for the design. +package riskscore + +import ( + "context" + "database/sql" + "math" + "strings" + + "github.com/livereview/internal/canonicaldoc" + "github.com/livereview/pkg/models" +) + +// FeatureSpec describes one model feature. The slice is append-only: never +// reorder or remove entries, because old model versions keep scoring with the +// same indices (a feature a model predates simply reads as 0). +type FeatureSpec struct { + Name string + Category string // override category; "" means no category-level override applies +} + +// FeatureSpecs is the canonical feature vector. goml adds its own bias term, so +// the feature vector itself carries no intercept; weights are stored as +// [bias, w0, w1, ...] where w_i corresponds to FeatureSpecs[i]. +var FeatureSpecs = []FeatureSpec{ + {"br_sum", "blast_radius"}, + {"br_max", "blast_radius"}, + {"br_weighted_avg", "blast_radius"}, + {"br_high_count", "blast_radius"}, + {"critical_count", "critical_findings"}, + {"warning_count", ""}, + {"security_count", "security"}, + {"files_changed", "diff_size"}, + {"lines_added", "diff_size"}, + {"llm_class_risk", ""}, + // Per-taxonomy-category finding counts (internal/prompts/templates.go's + // TaxonomyClassificationRules is the canonical 10-category list; "security" + // above already covers that category, these add the other 9). Appended + // rather than inserted so old model_versions rows keep scoring correctly + // (Score/scoreRaw/OnlineStep all stop at len(w), reading a historical, + // shorter weight vector as if these simply weren't there yet). + {"reliability_count", "reliability"}, + {"correctness_count", "correctness"}, + {"performance_count", "performance"}, + {"scalability_count", "scalability"}, + {"compliance_governance_count", "compliance_governance"}, + {"maintainability_count", "maintainability"}, + {"architecture_count", "architecture"}, + {"developer_experience_count", "developer_experience"}, + {"cost_count", "cost"}, +} + +// OverrideCategories returns every distinct non-empty category string used by +// FeatureSpecs — i.e. every category a Settings UI slider can target — in +// FeatureSpecs order with duplicates removed. Single source of truth so the +// API/UI never drifts from what scoring actually applies overrides to. +func OverrideCategories() []string { + seen := make(map[string]bool, len(FeatureSpecs)) + out := make([]string, 0, len(FeatureSpecs)) + for _, f := range FeatureSpecs { + if f.Category == "" || seen[f.Category] { + continue + } + seen[f.Category] = true + out = append(out, f.Category) + } + return out +} + +// FeatureCount returns the number of model features (excluding goml's bias). +func FeatureCount() int { return len(FeatureSpecs) } + +// BlastStats is the diff-level blast-radius aggregate the feature extractor +// consumes, loaded from blast_radius_hunks. Zero values mean "no blast radius +// data" (CLI review, disabled cache, or the scoring job hasn't finished yet); +// the policy engine treats that as zeros rather than risk. +type BlastStats struct { + Sum float64 + Max float64 + WeightedAvg float64 + HighCount int + FileCount int + LinesAdded int +} + +// LoadBlastStats reads the blast-radius aggregates for a review from +// blast_radius_hunks, scoped to orgID. +func LoadBlastStats(ctx context.Context, db *sql.DB, orgID, reviewID int64) (BlastStats, error) { + var br BlastStats + err := db.QueryRowContext(ctx, ` + SELECT + COALESCE(SUM(combined), 0), + COALESCE(MAX(combined), 0), + COALESCE(SUM(combined * new_lines) / NULLIF(SUM(new_lines), 0), 0), + COALESCE(COUNT(*) FILTER (WHERE tier = 'blast-radius-high'), 0), + COALESCE(COUNT(DISTINCT file_path), 0), + COALESCE(SUM(new_lines), 0) + FROM blast_radius_hunks + WHERE review_id = $1 AND org_id = $2`, reviewID, orgID). + Scan(&br.Sum, &br.Max, &br.WeightedAvg, &br.HighCount, &br.FileCount, &br.LinesAdded) + if err != nil { + return br, err + } + return br, nil +} + +// ExtractFeatures builds the length-FeatureCount() feature vector from blast +// radius aggregates, finding-severity counts from the canonical review doc, and +// (when present) the diff-level semantic classification. Feature values are +// lightly bounded/compressed (log1p on counts) so no single feature dominates +// the dot product. +func ExtractFeatures(br BlastStats, doc *canonicaldoc.ReviewDoc) []float64 { + f := make([]float64, FeatureCount()) + f[0] = br.Sum + f[1] = br.Max + f[2] = br.WeightedAvg + f[3] = float64(br.HighCount) + if doc != nil { + f[4] = float64(doc.Counts.BySeverity["critical"]) + f[5] = float64(doc.Counts.BySeverity["warning"]) + f[6] = float64(doc.Counts.ByCategory["security"]) + } + f[7] = math.Log1p(float64(br.FileCount)) + f[8] = math.Log1p(float64(br.LinesAdded)) + if doc != nil { + f[9] = classRisk(doc.DiffClassification) + // doc.Counts.ByCategory keys are the taxonomy labels lower-cased as-is + // (canonicaldoc.Build), so "Compliance & Governance"/"Developer + // Experience" keep their space/"&" rather than becoming snake_case. + f[10] = float64(doc.Counts.ByCategory["reliability"]) + f[11] = float64(doc.Counts.ByCategory["correctness"]) + f[12] = float64(doc.Counts.ByCategory["performance"]) + f[13] = float64(doc.Counts.ByCategory["scalability"]) + f[14] = float64(doc.Counts.ByCategory["compliance & governance"]) + f[15] = float64(doc.Counts.ByCategory["maintainability"]) + f[16] = float64(doc.Counts.ByCategory["architecture"]) + f[17] = float64(doc.Counts.ByCategory["developer experience"]) + f[18] = float64(doc.Counts.ByCategory["cost"]) + } + return f +} + +// classRisk maps the diff-level semantic classification to a coarse risk +// ordinal (0 safe, 0.5 mixed, 1 risky). Absent/unknown classification reads 0. +func classRisk(dc *models.DiffClassification) float64 { + if dc == nil { + return 0 + } + switch strings.ToLower(strings.TrimSpace(dc.Class)) { + case "logic-change", "security-sensitive": + return 1 + case "config-change", "dependency-change", "data-migration": + return 0.5 + default: // refactor-only, dead-code-removal, docs-only, test-only, unknown + return 0 + } +} diff --git a/internal/riskscore/learning_cycle_test.go b/internal/riskscore/learning_cycle_test.go new file mode 100644 index 000000000..df6483f52 --- /dev/null +++ b/internal/riskscore/learning_cycle_test.go @@ -0,0 +1,221 @@ +package riskscore + +// TestLearningCycleProvesFeedbackLoopWorks is the single artifact that +// directly answers "does the ML-based feedback loop actually do anything": +// it runs a full synthetic production cycle -- cold start, a batch of +// historical outcomes with a known pattern, a refit, and a fresh real-time +// nudge -- and asserts the model's score for two held-out reviews moves in +// the direction the synthetic incidents taught it, not just that nothing +// crashed. Run with -v; the logged before/after numbers are exactly what +// docs/risk-policy-explainability.md quotes as its worked example. +// +// See docs/risk-policy-engine.md §3 for the three update paths this test +// exercises (batch refit + the real-time online nudge; the third, on-demand +// refit, is the same code path as the batch refit under a different +// trigger, so it isn't a fourth variant here). + +import ( + "context" + "encoding/json" + "math" + "testing" +) + +// weightDeltaRow is one weight's before/after, for the report's weight-delta +// table (see learningCycleDump, prefix "LCDATA:" -- regenerated by +// scripts/regen_risk_report.py, never hand-transcribed). +type weightDeltaRow struct { + Name string `json:"name"` + Before float64 `json:"before"` + After float64 `json:"after"` + Delta float64 `json:"delta"` +} + +// logWeightDeltas prints every weight that changed by a meaningful amount +// between two model versions, by feature name -- the literal "why" behind a +// score change, not just the before/after score itself -- and returns the +// same data as a structured list for the JSON dump. +func logWeightDeltas(t *testing.T, label string, before, after Weights) []weightDeltaRow { + t.Helper() + t.Logf("weight changes (%s):", label) + const noise = 1e-6 + var rows []weightDeltaRow + if math.Abs(after[0]-before[0]) > noise { + t.Logf(" bias: %.4f -> %.4f (delta %+.4f)", before[0], after[0], after[0]-before[0]) + rows = append(rows, weightDeltaRow{Name: "bias", Before: before[0], After: after[0], Delta: after[0] - before[0]}) + } + for i, spec := range FeatureSpecs { + if i+1 >= len(before) || i+1 >= len(after) { + break + } + delta := after[i+1] - before[i+1] + if math.Abs(delta) > noise { + t.Logf(" %s: %.4f -> %.4f (delta %+.4f)", spec.Name, before[i+1], after[i+1], delta) + rows = append(rows, weightDeltaRow{Name: spec.Name, Before: before[i+1], After: after[i+1], Delta: delta}) + } + } + if len(rows) == 0 { + t.Logf(" (no weight moved by more than %.0e)", noise) + } + return rows +} + +// learningCycleDump is the machine-readable record TestLearningCycleProvesFeedbackLoopWorks +// logs (prefix "LCDATA:") -- the report's learning-loop section is built +// directly from this, never hand-transcribed. +type learningCycleDump struct { + NHistorical int `json:"n_historical"` + ColdHigh float64 `json:"cold_high"` + ColdLow float64 `json:"cold_low"` + BatchHigh float64 `json:"batch_high"` + BatchLow float64 `json:"batch_low"` + HoldoutAUC float64 `json:"holdout_auc"` + OnlineHigh float64 `json:"online_high"` + ColdToBatchDeltas []weightDeltaRow `json:"cold_to_batch_deltas"` + BatchToOnlineDeltas []weightDeltaRow `json:"batch_to_online_deltas"` +} + +func TestLearningCycleProvesFeedbackLoopWorks(t *testing.T) { + db := testDB(t) + orgID := seedOrg(t, db, "learning-cycle") + store := NewStore(db) + scorer := NewScorer(db) + ctx := context.Background() + + // A held-out pair of reviews -- never inserted into training data, used + // only to probe the model before and after each learning step. Chosen in + // the model's non-saturated range (moderate blast radius, below the + // "high" tier) deliberately, so the before/after movement this test + // prints is a real, legible delta rather than a ceiling effect at ~1.0. + highRisk := make([]float64, FeatureCount()) + highRisk[0], highRisk[1], highRisk[2] = 15, 15, 15 // br_sum / br_max / br_weighted_avg + lowRisk := make([]float64, FeatureCount()) + lowRisk[0], lowRisk[1], lowRisk[2] = 2, 2, 2 + + // --- Step 0: cold start. Every org begins at the shared common-sense + // seed, with zero outcomes recorded. --- + if err := store.EnsureSeedModel(ctx); err != nil { + t.Fatalf("EnsureSeedModel: %v", err) + } + _, coldWeights, _, err := store.ActiveWeights(ctx, orgID) + if err != nil { + t.Fatalf("ActiveWeights (cold): %v", err) + } + coldHigh, err := scorer.ScoreFor(ctx, orgID, highRisk) + if err != nil { + t.Fatalf("ScoreFor (cold, high): %v", err) + } + coldLow, err := scorer.ScoreFor(ctx, orgID, lowRisk) + if err != nil { + t.Fatalf("ScoreFor (cold, low): %v", err) + } + t.Logf("step 0 (cold start, seed model, 0 outcomes): high-blast-radius score=%.4f low-blast-radius score=%.4f", coldHigh, coldLow) + + // --- Step 1: a batch of synthetic production history lands, with a + // deliberate pattern: reviews whose blast radius was high got reverted; + // reviews whose blast radius was low were later confirmed safe. This is + // exactly what review_outcomes accumulating from real webhook/manual + // reports over weeks would look like, compressed into one seeding pass. --- + const nHistorical = 40 + for i := 0; i < nHistorical; i++ { + features := make([]float64, FeatureCount()) + outcome := "confirmed_safe" + if i%2 == 1 { + // Moderate blast radius, varied a little so it's not two point + // masses, and overlapping the held-out highRisk probe's range. + v := 15.0 + float64(i%20) + features[0], features[1], features[2] = v, v, v + outcome = "revert" + } else { + v := float64(i % 10) + features[0], features[1], features[2] = v, v, v + } + seedLabeledExampleFromSource(t, db, orgID, features, outcome, "ci") + } + + refitRes, err := store.Refit(ctx, orgID) + if err != nil { + t.Fatalf("Refit: %v", err) + } + if !refitRes.Promoted { + t.Fatalf("expected the batch refit to promote on %d cleanly separable historical outcomes, got reason: %s", nHistorical, refitRes.Reason) + } + if refitRes.HoldoutAUC < 0.75 { + t.Fatalf("holdout AUC %.3f is too close to random for this cleanly separable synthetic history -- the mechanism doesn't appear to be learning", refitRes.HoldoutAUC) + } + Invalidate(orgID) // same invalidation SaveVersion already does; explicit here for clarity + _, batchWeights, _, err := store.ActiveWeights(ctx, orgID) + if err != nil { + t.Fatalf("ActiveWeights (post-batch-refit): %v", err) + } + batchHigh, err := scorer.ScoreFor(ctx, orgID, highRisk) + if err != nil { + t.Fatalf("ScoreFor (post-batch-refit, high): %v", err) + } + batchLow, err := scorer.ScoreFor(ctx, orgID, lowRisk) + if err != nil { + t.Fatalf("ScoreFor (post-batch-refit, low): %v", err) + } + t.Logf("step 1 (after batch refit on %d historical outcomes, holdout AUC=%.3f): high-blast-radius score=%.4f (was %.4f, delta %+.4f) low-blast-radius score=%.4f (was %.4f, delta %+.4f)", + nHistorical, refitRes.HoldoutAUC, batchHigh, coldHigh, batchHigh-coldHigh, batchLow, coldLow, batchLow-coldLow) + + // WHY the score moved: log which specific weights changed and by how + // much. This is the step the old report skipped -- it showed the score + // jump but never showed the cause. The training data's pattern was + // "elevated br_sum/br_max/br_weighted_avg correlates with reverts," so + // those three weights, specifically, are what should move. + coldToBatchDeltas := logWeightDeltas(t, "cold start -> after batch refit", coldWeights, batchWeights) + + if batchHigh <= coldHigh { + t.Fatalf("after learning from reverted high-blast-radius history, the high-blast-radius score should have increased from the cold-start seed: %.4f -> %.4f", coldHigh, batchHigh) + } + if batchHigh <= batchLow { + t.Fatalf("after the batch refit, the model must rank the high-blast-radius review above the low-blast-radius one: high=%.4f low=%.4f", batchHigh, batchLow) + } + + // --- Step 2: a single new, real-time incident report (the "mark as + // reverted" button / webhook path), on top of the just-refit model. + // No batch job involved -- one gradient step, applied immediately. --- + freshReviewID := seedReview(t, db, orgID) + freshFeatures := make([]float64, FeatureCount()) + freshFeatures[0], freshFeatures[1], freshFeatures[2] = 15, 15, 15 + raw := mustMarshal(t, freshFeatures) + if _, err := db.Exec(` + INSERT INTO risk_feature_snapshots (review_id, org_id, model_version, features, score) + VALUES ($1, $2, 1, $3, 0.5)`, freshReviewID, orgID, raw); err != nil { + t.Fatalf("insert snapshot: %v", err) + } + if err := store.ApplyOutcome(ctx, orgID, freshReviewID, "revert"); err != nil { + t.Fatalf("ApplyOutcome: %v", err) + } + Invalidate(orgID) + _, onlineWeights, _, err := store.ActiveWeights(ctx, orgID) + if err != nil { + t.Fatalf("ActiveWeights (post-online-nudge): %v", err) + } + onlineHigh, err := scorer.ScoreFor(ctx, orgID, highRisk) + if err != nil { + t.Fatalf("ScoreFor (post-online-nudge, high): %v", err) + } + t.Logf("step 2 (after one additional real-time 'revert' report, no batch job): high-blast-radius score=%.4f (was %.4f after the refit, delta %+.4f)", + onlineHigh, batchHigh, onlineHigh-batchHigh) + batchToOnlineDeltas := logWeightDeltas(t, "after batch refit -> after one more real-time report", batchWeights, onlineWeights) + + if onlineHigh < batchHigh { + t.Fatalf("a single additional revert report on a high-blast-radius review should not decrease the high-blast-radius score: %.4f -> %.4f", batchHigh, onlineHigh) + } + + t.Logf("SUMMARY: cold-start high=%.4f -> after %d historical outcomes + refit high=%.4f -> after one more real-time report high=%.4f (total movement %+.4f, entirely from reported outcomes, zero manual weight edits)", + coldHigh, nHistorical, batchHigh, onlineHigh, onlineHigh-coldHigh) + + dump := learningCycleDump{ + NHistorical: nHistorical, ColdHigh: coldHigh, ColdLow: coldLow, + BatchHigh: batchHigh, BatchLow: batchLow, HoldoutAUC: refitRes.HoldoutAUC, + OnlineHigh: onlineHigh, ColdToBatchDeltas: coldToBatchDeltas, BatchToOnlineDeltas: batchToOnlineDeltas, + } + dumpJSON, err := json.Marshal(dump) + if err != nil { + t.Fatalf("marshal learning cycle dump: %v", err) + } + t.Logf("LCDATA:%s", dumpJSON) +} diff --git a/internal/riskscore/model.go b/internal/riskscore/model.go new file mode 100644 index 000000000..7975fe226 --- /dev/null +++ b/internal/riskscore/model.go @@ -0,0 +1,212 @@ +package riskscore + +import ( + "encoding/json" + "io" + "math" + + "github.com/cdipaolo/goml/base" + "github.com/cdipaolo/goml/linear" +) + +// Weights is one model's weight vector, laid out exactly as goml's +// Logistic.Parameters: index 0 is the bias, index i+1 is the weight for +// FeatureSpecs[i]. A row in risk_model_versions.weights_json is a []float64 of +// length FeatureCount()+1. +type Weights []float64 + +// AnchorLambda is the prior-anchor strength constant. The effective anchor on a +// batch refit is AnchorStrength(n) = lambda / (lambda + n), so with no labeled +// outcomes the fitted model stays at the common-sense prior, and by a few +// hundred outcomes the prior's pull is negligible. +const AnchorLambda = 1.0 + +// Prior returns the hand-set common-sense seed weights (docs/risk-policy-engine.md +// §3 "Cold start"). Blast-radius magnitude and critical-finding count dominate, +// security findings are weighted up, info-level findings and small diffs are +// weighted near zero. This is the starting point every org's model begins from +// and is anchored back to. +func Prior() Weights { + w := make(Weights, FeatureCount()+1) + w[0] = -0.5 // bias: slight default toward "safe" + w[1] = 0.02 // br_sum (0-100) + w[2] = 0.01 // br_max + w[3] = 0.01 // br_weighted_avg + w[4] = 0.20 // br_high_count + w[5] = 0.90 // critical_count (dominates) + w[6] = 0.18 // warning_count + w[7] = 0.55 // security_count (weighted up) + w[8] = 0.10 // files_changed (log1p) + w[9] = 0.05 // lines_added (log1p) + w[10] = 0.30 // llm_class_risk + // Per-taxonomy-category counts: correctness/reliability findings matter + // almost as much as security, performance next, the rest start modest — + // the same ladder the UI's default category multipliers (below) express. + w[11] = 0.30 // reliability_count + w[12] = 0.30 // correctness_count + w[13] = 0.20 // performance_count + w[14] = 0.12 // scalability_count + w[15] = 0.12 // compliance_governance_count + w[16] = 0.10 // maintainability_count + w[17] = 0.10 // architecture_count + w[18] = 0.10 // developer_experience_count + w[19] = 0.10 // cost_count + return w +} + +// AnchorStrength returns the blend factor toward the prior for n labeled +// outcomes: 1.0 with no data (fully prior), decaying as n grows. +func AnchorStrength(n int) float64 { + return AnchorLambda / (AnchorLambda + float64(n)) +} + +func sigmoid(z float64) float64 { + if z >= 0 { + zExp := math.Exp(-z) + return 1 / (1 + zExp) + } + zExp := math.Exp(z) + return zExp / (1 + zExp) +} + +// DefaultCategoryMultiplier is the starting weight-multiplier for a finding +// category before an org has set its own override: blast radius is weighted +// highest of all, since how far a change's effects reach is the strongest +// single risk signal the model has — it applies regardless of finding +// category and often catches what per-category counts miss. Security is +// weighted up next since teams tend to take it most seriously, correctness +// and reliability next, performance next, and everything else neutral (1x). +// An explicit risk_factor_overrides row always takes priority over this. +func DefaultCategoryMultiplier(category string) float64 { + switch category { + case "blast_radius": + return 2.0 + case "security": + return 1.5 + case "correctness", "reliability": + return 1.25 + case "performance": + return 1.1 + default: + return 1 + } +} + +// categoryOverride returns the multiplier for a weight index (0 = bias, no +// category), falling back to DefaultCategoryMultiplier when no explicit +// override is set. +func categoryOverride(overrides map[string]float64, weightIndex int) float64 { + if weightIndex == 0 { + return 1 + } + spec := FeatureSpecs[weightIndex-1] + if spec.Category == "" { + return 1 + } + if overrides != nil { + if m, ok := overrides[spec.Category]; ok && m > 0 { + return m + } + } + return DefaultCategoryMultiplier(spec.Category) +} + +// Score computes sigmoid(bias + Σ effective_w_i * x_i), where +// effective_w_i = w_i * override_multiplier[category(i)]. A single dot product. +func Score(w Weights, x []float64, overrides map[string]float64) float64 { + var z float64 + if len(w) > 0 { + z = w[0] // bias has no category override + } + for i := 0; i < len(x) && i+1 < len(w); i++ { + z += w[i+1] * categoryOverride(overrides, i+1) * x[i] + } + return sigmoid(z) +} + +// scoreRaw is the raw dot product (no overrides), used during training and AUC. +func scoreRaw(w Weights, x []float64) float64 { + var z float64 + if len(w) > 0 { + z = w[0] + } + for i := 0; i < len(x) && i+1 < len(w); i++ { + z += w[i+1] * x[i] + } + return sigmoid(z) +} + +// BatchFit fits a fresh logistic model over (X, Y) using goml's batch gradient +// ascent (regularization disabled inside goml; the prior anchor is applied as a +// post-fit blend toward the common-sense prior). With no training data it +// returns the prior unchanged. +func BatchFit(X [][]float64, Y []float64, prior Weights, sampleCount int, maxIter int) Weights { + if len(X) == 0 || len(X) != len(Y) { + return cloneWeights(prior) + } + if maxIter <= 0 { + maxIter = 200 + } + + model := linear.NewLogistic(base.BatchGA, 1e-3, 0, maxIter, X, Y, FeatureCount()) + model.Output = io.Discard + if err := model.Learn(); err != nil { + // goml failed (diverged / bad input): keep the prior rather than a broken model. + return cloneWeights(prior) + } + + fitted := Weights(model.Parameters) + a := AnchorStrength(sampleCount) + out := make(Weights, len(fitted)) + for i := range fitted { + out[i] = (1-a)*fitted[i] + a*prior[i] + } + return out +} + +// OnlineStep applies one stochastic gradient ascent step for a single labeled +// example (the real-time nudge fired when a review_outcomes row lands), +// shrinking toward the prior so a single noisy example can't swing the weights +// away from common sense. Returns a new weight vector (the old one is not +// mutated). +func OnlineStep(w Weights, x []float64, y float64, prior Weights, sampleCount int, alpha float64) Weights { + if alpha <= 0 { + alpha = 0.01 + } + + p := scoreRaw(w, x) + err := y - p + + // Per-step anchor: strong when data is scarce, negligible once calibrated. + shrink := AnchorLambda / (1 + float64(sampleCount)) + + out := cloneWeights(w) + // bias term (x_0 = 1) + if len(out) > 0 { + out[0] += alpha * (err*1 - shrink*(out[0]-prior[0])) + } + for i := 0; i < len(x) && i+1 < len(out); i++ { + out[i+1] += alpha * (err*x[i] - shrink*(out[i+1]-prior[i+1])) + } + return out +} + +func cloneWeights(w Weights) Weights { + out := make(Weights, len(w)) + copy(out, w) + return out +} + +// MarshalWeights encodes a weight vector for risk_model_versions.weights_json. +func MarshalWeights(w Weights) ([]byte, error) { + return json.Marshal([]float64(w)) +} + +// UnmarshalWeights decodes a weights_json value. +func UnmarshalWeights(raw []byte) (Weights, error) { + var w Weights + if err := json.Unmarshal(raw, &w); err != nil { + return nil, err + } + return w, nil +} diff --git a/internal/riskscore/referencemix/referencemix.go b/internal/riskscore/referencemix/referencemix.go new file mode 100644 index 000000000..ba5d5aebc --- /dev/null +++ b/internal/riskscore/referencemix/referencemix.go @@ -0,0 +1,83 @@ +// Package referencemix is test-support code only: it builds one fixed, +// reproducible set of 60 synthetic reviews, shaped like a believable repo's +// recent history (mostly small/clean, a smaller medium-risk group, a few +// clearly risky ones). It exists so more than one test package can score +// the exact same reference distribution and report consistent percentile +// numbers -- internal/riskscore's threshold experiment and +// internal/policyengine's scenario corpus both import this, instead of each +// keeping a private copy that could silently drift apart. +// +// Nothing in internal/riskscore or internal/policyengine's production code +// imports this package. It is only ever used from _test.go files. +package referencemix + +import ( + "math" + "math/rand" +) + +// Example is one synthetic review: an optional human-readable label (empty +// for filler rows) and its full feature vector. +type Example struct { + Label string + Features []float64 +} + +// Build returns the fixed 60-review mix, given featureCount (the caller's +// riskscore.FeatureCount(), passed in rather than imported, so this package +// has no dependency on riskscore and can be imported from any package that +// scores features the same way). +func Build(featureCount int) []Example { + rng := rand.New(rand.NewSource(7)) + var out []Example + + f := func(brSum, brMax, brWeighted, brHigh, critical, warning, security, files, lines, llmRisk float64) []float64 { + v := make([]float64, featureCount) + v[0] = brSum + v[1] = brMax + v[2] = brWeighted + v[3] = brHigh + v[4] = critical + v[5] = warning + v[6] = security + v[7] = math.Log1p(files) + v[8] = math.Log1p(lines) + v[9] = llmRisk + return v + } + + // Named, representative examples (the ones the report quotes by name). + out = append(out, + Example{"docs-only edit", f(0, 0, 0, 0, 0, 0, 0, 1, 10, 0)}, + Example{"typical two-file bugfix", f(0, 0, 0, 0, 0, 1, 0, 2, 25, 0)}, + Example{"small refactor, one warning", f(5, 5, 5, 0, 0, 1, 0, 4, 60, 0)}, + Example{"medium feature, no findings", f(15, 15, 15, 0, 0, 0, 0, 8, 150, 0)}, + Example{"medium feature, one security warning", f(10, 10, 10, 0, 0, 0, 1, 6, 120, 0)}, + Example{"large refactor, high blast radius", f(70, 70, 70, 1, 0, 0, 0, 20, 500, 0)}, + Example{"one critical security finding", f(0, 0, 0, 0, 1, 0, 1, 2, 30, 1)}, + ) + + // Filler: a realistic long tail around the named examples, fixed seed so + // this is reproducible. ~70% small/clean, ~22% medium, ~8% risky. + for range 53 { + r := rng.Float64() + switch { + case r < 0.70: // small/clean + out = append(out, Example{"", f( + rng.Float64()*5, rng.Float64()*5, rng.Float64()*5, 0, + 0, float64(rng.Intn(2)), 0, + float64(1+rng.Intn(5)), float64(5+rng.Intn(80)), 0)}) + case r < 0.92: // medium + out = append(out, Example{"", f( + 10+rng.Float64()*25, 10+rng.Float64()*25, 10+rng.Float64()*25, 0, + 0, float64(rng.Intn(3)), float64(rng.Intn(2)), + float64(3+rng.Intn(10)), float64(50+rng.Intn(200)), 0)}) + default: // risky + out = append(out, Example{"", f( + 50+rng.Float64()*40, 50+rng.Float64()*40, 50+rng.Float64()*40, 1, + float64(rng.Intn(2)), float64(rng.Intn(3)), 1, + float64(10+rng.Intn(20)), float64(200+rng.Intn(400)), float64(rng.Intn(2)))}) + } + } + return out +} diff --git a/internal/riskscore/refit.go b/internal/riskscore/refit.go new file mode 100644 index 000000000..a905a45c7 --- /dev/null +++ b/internal/riskscore/refit.go @@ -0,0 +1,213 @@ +package riskscore + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" +) + +// TrainingExample is one labeled (features, risky) pair for model fitting, +// built from risk_feature_snapshots ⋈ review_outcomes. +type TrainingExample struct { + ReviewID int64 + Features []float64 + Risky float64 // 1.0 risky (revert/incident), 0.0 safe (confirmed_safe) +} + +// LoadTrainingSet returns the org's labeled training examples, excluding +// 'test'-source outcomes so the setup-panel round-trip never pollutes the model. +func (s *Store) LoadTrainingSet(ctx context.Context, orgID int64) ([]TrainingExample, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT s.review_id, s.features, o.outcome_type + FROM risk_feature_snapshots s + JOIN review_outcomes o ON o.review_id = s.review_id AND o.org_id = s.org_id + WHERE s.org_id = $1 AND o.source <> 'test' + ORDER BY s.created_at`, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []TrainingExample + for rows.Next() { + var reviewID int64 + var raw []byte + var outcomeType string + if err := rows.Scan(&reviewID, &raw, &outcomeType); err != nil { + return nil, err + } + var features []float64 + if err := json.Unmarshal(raw, &features); err != nil { + continue + } + out = append(out, TrainingExample{ + ReviewID: reviewID, + Features: features, + Risky: outcomeLabel(outcomeType), + }) + } + return out, rows.Err() +} + +// outcomeLabel maps an outcome_type to a training label. +func outcomeLabel(outcomeType string) float64 { + switch outcomeType { + case "revert", "incident": + return 1 + default: // confirmed_safe + return 0 + } +} + +// ApplyOutcome is the real-time online nudge (path 1): when a review_outcomes +// row lands, load that review's snapshot as the labeled example and take one +// gradient step on the org's current weights, persisting a new active version. +// source 'test' outcomes are skipped by the caller. +func (s *Store) ApplyOutcome(ctx context.Context, orgID, reviewID int64, outcomeType string) error { + var raw []byte + err := s.db.QueryRowContext(ctx, + `SELECT features FROM risk_feature_snapshots WHERE review_id = $1 AND org_id = $2`, + reviewID, orgID).Scan(&raw) + if err == sql.ErrNoRows { + return nil // no snapshot (e.g. policy never scored this review); nothing to learn + } + if err != nil { + return err + } + var features []float64 + if err := json.Unmarshal(raw, &features); err != nil { + return err + } + if len(features) != FeatureCount() { + return fmt.Errorf("feature vector length %d != %d; model drifted", len(features), FeatureCount()) + } + + _, weights, sampleCount, err := s.ActiveWeights(ctx, orgID) + if err != nil { + return err + } + + next := OnlineStep(weights, features, outcomeLabel(outcomeType), Prior(), sampleCount, 0.01) + _, err = s.SaveVersion(ctx, orgID, next, sampleCount+1, nil, "online_update") + return err +} + +// RefitResult summarizes a batch refit. +type RefitResult struct { + OrgID int64 `json:"org_id"` + TrainingCount int `json:"training_count"` + HoldoutAUC float64 `json:"holdout_auc"` + Promoted bool `json:"promoted"` + Version int `json:"version,omitempty"` + Reason string `json:"reason"` +} + +// Refit is the daily/on-demand batch refit (paths 2/3): fit a fresh logistic +// model over all labeled snapshots, evaluate AUC, and promote only if it is at +// least as good as the current active version (or the current version is the +// seed). Corrects drift a long sequence of noisy online steps can accumulate. +func (s *Store) Refit(ctx context.Context, orgID int64) (*RefitResult, error) { + examples, err := s.LoadTrainingSet(ctx, orgID) + if err != nil { + return nil, err + } + res := &RefitResult{OrgID: orgID, TrainingCount: len(examples), Promoted: false} + if len(examples) == 0 { + res.Reason = "no labeled outcomes yet" + return res, nil + } + + X := make([][]float64, 0, len(examples)) + Y := make([]float64, 0, len(examples)) + for _, ex := range examples { + X = append(X, padFeatures(ex.Features)) + Y = append(Y, ex.Risky) + } + + trainX, trainY, evalX, evalY := splitHoldout(X, Y) + fitted := BatchFit(trainX, trainY, Prior(), len(trainX), 200) + auc := rankAUC(scoreAll(evalX, fitted), evalY) + res.HoldoutAUC = auc + + _, currentWeights, currentSamples, err := s.ActiveWeights(ctx, orgID) + if err != nil { + return nil, err + } + currentAUC := rankAUC(scoreAll(evalX, currentWeights), evalY) + + if currentSamples == 0 || auc >= currentAUC { + version, err := s.NextVersion(ctx, orgID) + if err != nil { + return nil, err + } + if _, err := s.SaveVersion(ctx, orgID, fitted, len(examples), &auc, "batch_refit"); err != nil { + return nil, err + } + res.Promoted = true + res.Version = version + res.Reason = "promoted (AUC improved or replaced seed)" + } else { + res.Reason = fmt.Sprintf("not promoted (AUC %.3f < current %.3f)", auc, currentAUC) + } + return res, nil +} + +// scoreAll scores each row with the given (raw) weights. +func scoreAll(X [][]float64, w Weights) []float64 { + out := make([]float64, len(X)) + for i := range X { + out[i] = scoreRaw(w, X[i]) + } + return out +} + +// padFeatures returns a copy of f padded/truncated to FeatureCount(). +func padFeatures(f []float64) []float64 { + out := make([]float64, FeatureCount()) + for i := range out { + if i < len(f) { + out[i] = f[i] + } + } + return out +} + +// splitHoldout splits X/Y into 80% train / 20% eval when >= 20 samples, else +// returns all data as both train and eval (in-sample AUC). +func splitHoldout(X [][]float64, Y []float64) ([][]float64, []float64, [][]float64, []float64) { + if len(X) < 20 { + return X, Y, X, Y + } + n := len(X) + trainN := n * 4 / 5 + return X[:trainN], Y[:trainN], X[trainN:], Y[trainN:] +} + +// rankAUC ranks scores against binary labels (pairwise). Returns 0.5 when +// there are no positive or no negative examples. +func rankAUC(scores, labels []float64) float64 { + nPos, nNeg := 0, 0 + rank := 0.0 + for i, si := range scores { + if labels[i] == 1 { + nPos++ + for j, sj := range scores { + if labels[j] == 0 { + switch { + case si > sj: + rank++ + case si == sj: + rank += 0.5 + } + } + } + } else { + nNeg++ + } + } + if nPos == 0 || nNeg == 0 { + return 0.5 + } + return rank / float64(nPos*nNeg) +} diff --git a/internal/riskscore/refit_test.go b/internal/riskscore/refit_test.go new file mode 100644 index 000000000..eeb13fc60 --- /dev/null +++ b/internal/riskscore/refit_test.go @@ -0,0 +1,302 @@ +package riskscore + +// DB-backed tests for the real-time online nudge (ApplyOutcome) and the +// batch refit's promotion gate (Refit) -- the two paths riskscore_test.go's +// pure-math tests can't reach, and the ones that actually answer "does the +// ML feedback loop do anything real." See docs/risk-policy-explainability.md. + +import ( + "context" + "database/sql" + "os" + "testing" + + _ "github.com/lib/pq" +) + +func testDB(t *testing.T) *sql.DB { + if testing.Short() { + t.Skip("skipping database integration test") + } + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + dsn = "postgres://livereview:livereview_password_123@localhost:5432/livereview?sslmode=disable" + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Ping(); err != nil { + t.Skipf("db not reachable: %v", err) + } + return db +} + +func seedOrg(t *testing.T, db *sql.DB, name string) int64 { + t.Helper() + var orgID int64 + if err := db.QueryRow(`INSERT INTO orgs (name) VALUES ($1) RETURNING id`, name).Scan(&orgID); err != nil { + t.Fatalf("insert org: %v", err) + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM dashboard_cache WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM risk_factor_overrides WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM risk_model_versions WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM review_outcomes WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM risk_feature_snapshots WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM reviews WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM orgs WHERE id = $1`, orgID) + }) + return orgID +} + +func seedReview(t *testing.T, db *sql.DB, orgID int64) int64 { + t.Helper() + var id int64 + err := db.QueryRow(` + INSERT INTO reviews (repository, status, org_id) VALUES ('riskscore/test-repo', 'completed', $1) RETURNING id`, + orgID).Scan(&id) + if err != nil { + t.Fatalf("insert review: %v", err) + } + // Cascades risk_feature_snapshots/review_outcomes cleanup isn't automatic + // (no ON DELETE CASCADE on those FKs), so seedOrg's cleanup above deletes + // them by org_id before deleting reviews; no separate cleanup needed here. + return id +} + +// seedLabeledExample writes one risk_feature_snapshots + review_outcomes pair +// -- the join LoadTrainingSet reads. +func seedLabeledExample(t *testing.T, db *sql.DB, orgID int64, features []float64, outcomeType string) { + t.Helper() + reviewID := seedReview(t, db, orgID) + raw := mustMarshal(t, features) + if _, err := db.Exec(` + INSERT INTO risk_feature_snapshots (review_id, org_id, model_version, features, score) + VALUES ($1, $2, 1, $3, 0.5)`, reviewID, orgID, raw); err != nil { + t.Fatalf("insert snapshot: %v", err) + } + if _, err := db.Exec(` + INSERT INTO review_outcomes (review_id, org_id, outcome_type, source, reason) + VALUES ($1, $2, $3, 'test', 'seeded for refit test')`, reviewID, orgID, outcomeType); err != nil { + t.Fatalf("insert outcome: %v", err) + } +} + +func mustMarshal(t *testing.T, v []float64) []byte { + t.Helper() + raw, err := MarshalWeights(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return raw +} + +// --- Refit --- + +func TestRefitNoOutcomesYetReturnsReason(t *testing.T) { + db := testDB(t) + orgID := seedOrg(t, db, "refit-no-outcomes") + store := NewStore(db) + + res, err := store.Refit(context.Background(), orgID) + if err != nil { + t.Fatalf("Refit: %v", err) + } + if res.Promoted { + t.Fatalf("Refit with zero outcomes should never promote") + } + if res.TrainingCount != 0 { + t.Fatalf("TrainingCount = %d, want 0", res.TrainingCount) + } + if res.Reason == "" { + t.Fatalf("expected a non-empty reason explaining why nothing was promoted") + } +} + +// TestRefitLearnsAndPromotesOnSeparableData is the Refit-level proof that +// training actually moves the model: seed outcomes with a clear, deliberate +// pattern (high critical_count -> revert, low critical_count -> confirmed +// safe), refit, and confirm (a) it promotes, (b) the holdout AUC clears a real +// discrimination bar (not 0.5, i.e. not random), and (c) the *persisted, +// active* model -- reloaded from the DB exactly as production scoring would +// -- actually ranks a fresh high-critical review above a fresh low-critical +// one it was never trained on. LoadTrainingSet excludes outcomes with +// source='test', so these are seeded with source='ci' (a real, non-test +// source) to participate in training. +func TestRefitLearnsAndPromotesOnSeparableData(t *testing.T) { + db := testDB(t) + orgID := seedOrg(t, db, "refit-separable") + store := NewStore(db) + + seedSeparableOutcomes(t, db, orgID, 30) + + res, err := store.Refit(context.Background(), orgID) + if err != nil { + t.Fatalf("Refit: %v", err) + } + if !res.Promoted { + t.Fatalf("expected promotion on cleanly separable training data, got reason: %s", res.Reason) + } + if res.HoldoutAUC < 0.75 { + t.Fatalf("holdout AUC %.3f is too close to random (0.5) for cleanly separable data -- the fit doesn't appear to be learning anything", res.HoldoutAUC) + } + if res.TrainingCount != 30 { + t.Fatalf("TrainingCount = %d, want 30", res.TrainingCount) + } + + // Reload exactly as production scoring does (ActiveWeights, not the + // in-memory `res`) and confirm the persisted model itself discriminates a + // fresh pair of reviews it was never trained on. + _, weights, sampleCount, err := store.ActiveWeights(context.Background(), orgID) + if err != nil { + t.Fatalf("ActiveWeights: %v", err) + } + if sampleCount != 30 { + t.Fatalf("persisted training_sample_count = %d, want 30", sampleCount) + } + lowRisk := make([]float64, FeatureCount()) + highRisk := make([]float64, FeatureCount()) + highRisk[4] = 5 // critical_count -- the signal the seeded outcomes were keyed on + lowScore := Score(weights, lowRisk, nil) + highScore := Score(weights, highRisk, nil) + if highScore <= lowScore { + t.Fatalf("persisted model doesn't rank a high-critical review above a low-critical one (low=%.3f, high=%.3f) -- the learned pattern wasn't actually saved/activated", lowScore, highScore) + } +} + +// seedSeparableOutcomes writes n labeled examples with a deliberate pattern: +// even indices get a low critical_count and a confirmed_safe outcome, odd +// indices get a high critical_count and a revert outcome. +func seedSeparableOutcomes(t *testing.T, db *sql.DB, orgID int64, n int) { + t.Helper() + for i := 0; i < n; i++ { + features := make([]float64, FeatureCount()) + outcome := "confirmed_safe" + if i%2 == 1 { + features[4] = float64(3 + i%3) // critical_count 3-5 + outcome = "revert" + } + seedLabeledExampleFromSource(t, db, orgID, features, outcome, "ci") + } +} + +func seedLabeledExampleFromSource(t *testing.T, db *sql.DB, orgID int64, features []float64, outcomeType, source string) { + t.Helper() + reviewID := seedReview(t, db, orgID) + raw := mustMarshal(t, features) + if _, err := db.Exec(` + INSERT INTO risk_feature_snapshots (review_id, org_id, model_version, features, score) + VALUES ($1, $2, 1, $3, 0.5)`, reviewID, orgID, raw); err != nil { + t.Fatalf("insert snapshot: %v", err) + } + if _, err := db.Exec(` + INSERT INTO review_outcomes (review_id, org_id, outcome_type, source, reason) + VALUES ($1, $2, $3, $4, 'seeded for refit test')`, reviewID, orgID, outcomeType, source); err != nil { + t.Fatalf("insert outcome: %v", err) + } +} + +// TestRefitExcludesTestSourceOutcomes confirms the setup panel's "send test +// event" round trip (source='test') can never influence training, per +// docs/risk-policy-engine.md §10. +func TestRefitExcludesTestSourceOutcomes(t *testing.T) { + db := testDB(t) + orgID := seedOrg(t, db, "refit-excludes-test-source") + store := NewStore(db) + + seedLabeledExample(t, db, orgID, make([]float64, FeatureCount()), "revert") // source='test' + + examples, err := store.LoadTrainingSet(context.Background(), orgID) + if err != nil { + t.Fatalf("LoadTrainingSet: %v", err) + } + if len(examples) != 0 { + t.Fatalf("LoadTrainingSet returned %d examples; source='test' rows must never be included", len(examples)) + } +} + +// --- ApplyOutcome --- + +// TestApplyOutcomeNudgesWeightsTowardTheReportedLabel confirms the real-time +// path: one reported outcome against a review's snapshot should move the +// corresponding feature's weight in the direction that outcome implies +// (revert -> nudge the risky features up), and persist+activate a new +// version immediately -- no batch job involved. +func TestApplyOutcomeNudgesWeightsTowardTheReportedLabel(t *testing.T) { + db := testDB(t) + orgID := seedOrg(t, db, "apply-outcome-nudge") + store := NewStore(db) + ctx := context.Background() + + if err := store.EnsureSeedModel(ctx); err != nil { + t.Fatalf("EnsureSeedModel: %v", err) + } + _, before, _, err := store.ActiveWeights(ctx, orgID) + if err != nil { + t.Fatalf("ActiveWeights (before): %v", err) + } + + reviewID := seedReview(t, db, orgID) + features := make([]float64, FeatureCount()) + features[4] = 5 // critical_count: a strong, specific signal to nudge on + raw := mustMarshal(t, features) + if _, err := db.Exec(` + INSERT INTO risk_feature_snapshots (review_id, org_id, model_version, features, score) + VALUES ($1, $2, 1, $3, 0.5)`, reviewID, orgID, raw); err != nil { + t.Fatalf("insert snapshot: %v", err) + } + + if err := store.ApplyOutcome(ctx, orgID, reviewID, "revert"); err != nil { + t.Fatalf("ApplyOutcome: %v", err) + } + + version, after, sampleCount, err := store.ActiveWeights(ctx, orgID) + if err != nil { + t.Fatalf("ActiveWeights (after): %v", err) + } + if sampleCount != 1 { + t.Fatalf("training_sample_count after one outcome = %d, want 1", sampleCount) + } + if version == 0 { + t.Fatalf("expected a new active version, got version 0") + } + // A positive (risky) label should push the critical_count weight up, not + // down -- the single-step gradient direction the online nudge takes. + if after[5] <= before[5] { + t.Fatalf("one 'revert' outcome on a high-critical_count review should increase its weight: before=%.6f after=%.6f", before[5], after[5]) + } + + var updateKind string + if err := db.QueryRow(`SELECT update_kind FROM risk_model_versions WHERE org_id = $1 AND is_active`, orgID).Scan(&updateKind); err != nil { + t.Fatalf("query active version: %v", err) + } + if updateKind != "online_update" { + t.Fatalf("active version's update_kind = %q, want %q", updateKind, "online_update") + } +} + +// TestApplyOutcomeWithNoSnapshotIsANoop confirms a review that was never +// scored (no risk_feature_snapshots row -- e.g. the policy was disabled when +// it ran) is silently skipped rather than erroring the caller's outcome +// insert. +func TestApplyOutcomeWithNoSnapshotIsANoop(t *testing.T) { + db := testDB(t) + orgID := seedOrg(t, db, "apply-outcome-no-snapshot") + store := NewStore(db) + ctx := context.Background() + + reviewID := seedReview(t, db, orgID) + if err := store.ApplyOutcome(ctx, orgID, reviewID, "revert"); err != nil { + t.Fatalf("ApplyOutcome with no snapshot should be a no-op, got error: %v", err) + } + + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM risk_model_versions WHERE org_id = $1`, orgID).Scan(&count); err != nil { + t.Fatalf("count versions: %v", err) + } + if count != 0 { + t.Fatalf("expected no model version to be written when there's no snapshot to learn from, got %d", count) + } +} diff --git a/internal/riskscore/riskscore_test.go b/internal/riskscore/riskscore_test.go new file mode 100644 index 000000000..aca2b45f9 --- /dev/null +++ b/internal/riskscore/riskscore_test.go @@ -0,0 +1,238 @@ +package riskscore + +import ( + "math" + "math/rand" + "testing" +) + +func TestPriorLength(t *testing.T) { + if got := len(Prior()); got != FeatureCount()+1 { + t.Fatalf("Prior length = %d, want %d", got, FeatureCount()+1) + } +} + +func TestScoreInRange(t *testing.T) { + prior := Prior() + x := make([]float64, FeatureCount()) + for i := range x { + x[i] = 1 + } + for _, overrides := range []map[string]float64{nil, {"blast_radius": 3, "security_findings": 0.5}} { + s := Score(prior, x, overrides) + if s < 0 || s > 1 { + t.Fatalf("score %v out of [0,1]", s) + } + } +} + +func TestScoreOverrideEffect(t *testing.T) { + prior := Prior() + x := make([]float64, FeatureCount()) + x[0] = 50 // br_sum + x[4] = 1 // critical_count + + base := Score(prior, x, nil) + boosted := Score(prior, x, map[string]float64{"blast_radius": 10}) + if boosted <= base { + t.Fatalf("boosting blast_radius should raise risk score: base %v, boosted %v", base, boosted) + } +} + +func TestBatchFitEmptyReturnsPrior(t *testing.T) { + prior := Prior() + got := BatchFit(nil, nil, prior, 0, 100) + if len(got) != len(prior) { + t.Fatalf("length mismatch") + } + for i := range got { + if math.Abs(got[i]-prior[i]) > 1e-9 { + t.Fatalf("BatchFit(empty) drifted from prior at %d: %v vs %v", i, got[i], prior[i]) + } + } +} + +func TestBatchFitWithData(t *testing.T) { + prior := Prior() + // A dataset strongly predictive of risk: critical_count high -> risky. + X := [][]float64{} + Y := []float64{} + for i := 0; i < 40; i++ { + x := make([]float64, FeatureCount()) + x[4] = float64(i % 5) // critical_count varies 0..4 + X = append(X, x) + Y = append(Y, []float64{0, 0, 1, 1, 1}[i%5]) + } + fitted := BatchFit(X, Y, prior, len(X), 200) + if len(fitted) != len(prior) { + t.Fatalf("length mismatch") + } + // A high-critical example should score above a low-critical one. + low := make([]float64, FeatureCount()) + high := make([]float64, FeatureCount()) + high[4] = 4 + if Score(fitted, low, nil) >= Score(fitted, high, nil) { + t.Fatalf("high-critical example should score higher") + } +} + +func TestOnlineStepShrinksTowardPrior(t *testing.T) { + prior := Prior() + w := cloneWeights(prior) + w[5] = prior[5] + 5 // perturb critical_count weight far from prior + x := make([]float64, FeatureCount()) + next := OnlineStep(w, x, 0, prior, 0, 0.1) + if math.Abs(next[5]-prior[5]) >= math.Abs(w[5]-prior[5]) { + t.Fatalf("online step should shrink toward prior: before %v -> %v, prior %v", w[5], next[5], prior[5]) + } +} + +func TestAnchorStrength(t *testing.T) { + if AnchorStrength(0) != 1.0 { + t.Fatalf("AnchorStrength(0) = %v, want 1.0", AnchorStrength(0)) + } + if AnchorStrength(1000) >= 0.01 { + t.Fatalf("AnchorStrength(1000) should be negligible, got %v", AnchorStrength(1000)) + } +} + +func TestPercentileCutoff(t *testing.T) { + if got := PercentileCutoff(nil, 20); got != defaultCutoff { + t.Fatalf("empty distribution cutoff = %v, want %v", got, defaultCutoff) + } + scores := []float64{0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0} + if got := PercentileCutoff(scores, 20); math.Abs(got-0.2) > 1e-9 { + t.Fatalf("P20 of 0.1..1.0 = %v, want 0.2", got) + } + if got := PercentileCutoff(scores, 50); math.Abs(got-0.5) > 1e-9 { + t.Fatalf("P50 = %v, want 0.5", got) + } +} + +func TestPercentileOf(t *testing.T) { + scores := []float64{0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0} + if got := PercentileOf(0.5, scores); got != 50 { + t.Fatalf("PercentileOf(0.5) = %d, want 50", got) + } + if got := PercentileOf(1.0, scores); got != 100 { + t.Fatalf("PercentileOf(1.0) = %d, want 100", got) + } +} + +// --- categoryOverride: explicit override vs default, and the two indices +// (bias, empty-category) that must never be scaled by either. --- + +func TestCategoryOverrideExplicitBeatsDefault(t *testing.T) { + // weightIndex 1 -> FeatureSpecs[0] = br_sum, category "blast_radius". + if got := categoryOverride(map[string]float64{"blast_radius": 5}, 1); got != 5 { + t.Fatalf("explicit override should win over the default multiplier: got %v, want 5", got) + } +} + +func TestCategoryOverrideFallsBackToDefaultWhenUnset(t *testing.T) { + // weightIndex 7 -> FeatureSpecs[6] = security_count, category "security". + want := DefaultCategoryMultiplier("security") + if got := categoryOverride(nil, 7); got != want { + t.Fatalf("got %v, want default multiplier %v", got, want) + } + if got := categoryOverride(map[string]float64{}, 7); got != want { + t.Fatalf("empty (not nil) overrides map should also fall back to default: got %v, want %v", got, want) + } +} + +func TestCategoryOverrideBiasNeverScaled(t *testing.T) { + if got := categoryOverride(map[string]float64{"blast_radius": 99}, 0); got != 1 { + t.Fatalf("the bias term (weightIndex 0) must never be scaled by any override, got %v", got) + } +} + +func TestCategoryOverrideEmptyCategoryNeverScaled(t *testing.T) { + // weightIndex 6 -> FeatureSpecs[5] = warning_count, category "" (no override applies). + if got := categoryOverride(map[string]float64{"security": 99}, 6); got != 1 { + t.Fatalf("a feature with no category must never be scaled, got %v", got) + } +} + +// --- Formal-ish property tests over the seed prior: every weight the prior +// ships with is non-negative, so the common-sense model must never consider +// "more of any single risk signal" to be safer. A random, fixed-seed sweep +// catches a future hand-edit to Prior() that introduces a wrong-signed +// weight, which a handful of hand-picked example points would not. --- + +func TestPriorAllFeatureWeightsNonNegative(t *testing.T) { + prior := Prior() + for i := 1; i < len(prior); i++ { + if prior[i] < 0 { + t.Fatalf("prior weight %d (%s) is negative (%v): this would make the seed model non-monotonic in that feature", + i, FeatureSpecs[i-1].Name, prior[i]) + } + } +} + +func TestPriorScoreMonotonicInEachFeature(t *testing.T) { + prior := Prior() + rng := rand.New(rand.NewSource(42)) + for trial := 0; trial < 20; trial++ { + base := make([]float64, FeatureCount()) + for i := range base { + base[i] = rng.Float64() * 10 + } + baseScore := Score(prior, base, nil) + for i := range base { + bumped := append([]float64{}, base...) + bumped[i] += rng.Float64()*10 + 0.1 // strictly positive delta + bumpedScore := Score(prior, bumped, nil) + if bumpedScore < baseScore-1e-12 { + t.Fatalf("trial %d, feature %d (%s): increasing this feature (holding others fixed) decreased the seed model's score (%v -> %v)", + trial, i, FeatureSpecs[i].Name, baseScore, bumpedScore) + } + } + } +} + +func TestAnchorStrengthMonotonicallyDecreasing(t *testing.T) { + prev := AnchorStrength(0) + if prev != 1.0 { + t.Fatalf("AnchorStrength(0) = %v, want 1.0", prev) + } + for _, n := range []int{1, 5, 10, 50, 100, 500, 1000, 5000} { + cur := AnchorStrength(n) + if cur >= prev { + t.Fatalf("AnchorStrength(%d) = %v should be strictly less than the previous sample count's %v", n, cur, prev) + } + prev = cur + } +} + +// TestOnlineStepRegularizationWeakensAsSampleCountGrows isolates the +// prior-anchor term from the gradient term (by choosing y so err=0: y is set +// to exactly what the model already predicts, so the only thing OnlineStep +// does is pull w[5] back toward the prior) and sweeps sampleCount, confirming +// that pull shrinks as an org accumulates more labeled outcomes -- ties +// model.go's documented shrink = AnchorLambda/(1+sampleCount) formula to an +// actual behavioral assertion, not just a comment. +func TestOnlineStepRegularizationWeakensAsSampleCountGrows(t *testing.T) { + prior := Prior() + w := cloneWeights(prior) + w[5] = prior[5] + 5 // perturb one weight far from the prior + x := make([]float64, FeatureCount()) + y := sigmoid(w[0]) // makes err = y - scoreRaw(w, x) == 0 + + prevMove := math.Inf(1) + for _, n := range []int{0, 10, 100, 1000} { + next := OnlineStep(w, x, y, prior, n, 0.1) + move := math.Abs(next[5] - w[5]) + if move >= prevMove { + t.Fatalf("sampleCount %d: regularization pull %v should be smaller than at a lower sample count (%v)", n, move, prevMove) + } + prevMove = move + } +} + +func TestRankAUC(t *testing.T) { + scores := []float64{0.9, 0.8, 0.7, 0.3, 0.2, 0.1} + labels := []float64{1, 1, 1, 0, 0, 0} + if got := rankAUC(scores, labels); math.Abs(got-1.0) > 1e-9 { + t.Fatalf("perfect separation AUC = %v, want 1.0", got) + } +} diff --git a/internal/riskscore/scorer.go b/internal/riskscore/scorer.go new file mode 100644 index 000000000..cafb4d69d --- /dev/null +++ b/internal/riskscore/scorer.go @@ -0,0 +1,96 @@ +package riskscore + +import ( + "context" + "database/sql" + "sync" +) + +// The scorer keeps each org's active weights (and category overrides) cached in +// memory; a scoring call is a single dot product with no DB round-trip. The +// cache is invalidated whenever a new version activates (online nudge, batch +// refit) or an override is saved, via Invalidate. + +type cachedOrg struct { + version int + sampleCount int + weights Weights + overrides map[string]float64 +} + +var ( + cacheMu sync.Mutex + cache = map[int64]cachedOrg{} +) + +// Invalidate drops the cached model for orgID so the next ScoreFor reloads it. +func Invalidate(orgID int64) { + cacheMu.Lock() + delete(cache, orgID) + cacheMu.Unlock() +} + +// Scorer scores feature vectors against the org's active model. A thin +// interface wrapper around ScoreFor keeps the door open for an ONNX-backed +// implementation later (see docs/risk-policy-engine.md §3 ONNX note). +type Scorer struct { + db *sql.DB + store *Store +} + +// NewScorer returns a Scorer backed by db. +func NewScorer(db *sql.DB) *Scorer { return &Scorer{db: db, store: NewStore(db)} } + +// ModelInfo is the "model confidence" signal surfaced in the UI: which version +// is active and how many labeled outcomes trained it (0 = common-sense seed). +type ModelInfo struct { + Version int `json:"version"` + TrainingSamples int `json:"training_samples"` + IsSeed bool `json:"is_seed"` +} + +// Info returns the active model's version and training-sample count without +// scoring, for the settings UI's model-confidence indicator. +func (s *Scorer) Info(ctx context.Context, orgID int64) (ModelInfo, error) { + cacheMu.Lock() + c, ok := cache[orgID] + cacheMu.Unlock() + if ok { + return ModelInfo{Version: c.version, TrainingSamples: c.sampleCount, IsSeed: c.sampleCount == 0}, nil + } + // ActiveWeights lazily creates the shared seed model if it doesn't exist yet. + version, _, sampleCount, err := s.store.ActiveWeights(ctx, orgID) + if err != nil { + return ModelInfo{}, err + } + return ModelInfo{Version: version, TrainingSamples: sampleCount, IsSeed: sampleCount == 0}, nil +} + +// ScoreFor computes sigmoid(Σ effective_w · x) against the org's active model, +// loading and caching it on first use. +func (s *Scorer) ScoreFor(ctx context.Context, orgID int64, x []float64) (float64, error) { + cacheMu.Lock() + c, ok := cache[orgID] + cacheMu.Unlock() + if ok { + return Score(c.weights, x, c.overrides), nil + } + + if err := s.store.EnsureSeedModel(ctx); err != nil { + return 0, err + } + version, weights, sampleCount, err := s.store.ActiveWeights(ctx, orgID) + if err != nil { + return 0, err + } + overrides, err := s.store.LoadOverrides(ctx, orgID) + if err != nil { + return 0, err + } + + cacheMu.Lock() + cache[orgID] = cachedOrg{version: version, sampleCount: sampleCount, weights: weights, overrides: overrides} + cacheMu.Unlock() + + return Score(weights, x, overrides), nil +} diff --git a/internal/riskscore/store.go b/internal/riskscore/store.go new file mode 100644 index 000000000..e8e188436 --- /dev/null +++ b/internal/riskscore/store.go @@ -0,0 +1,181 @@ +package riskscore + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" +) + +// Store bundles the DB access for the trained risk model: seed/version +// management, feature snapshots, and category overrides. Mutating functions +// invalidate the scorer's in-memory cache for the affected org. +type Store struct { + db *sql.DB +} + +// NewStore returns a Store backed by db. +func NewStore(db *sql.DB) *Store { return &Store{db: db} } + +// EnsureSeedModel inserts the single shared seed model (org_id NULL, +// update_kind 'seed') if it doesn't already exist. Idempotent. +func (s *Store) EnsureSeedModel(ctx context.Context) error { + var exists bool + err := s.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM risk_model_versions WHERE org_id IS NULL AND update_kind = 'seed')`).Scan(&exists) + if err != nil { + return fmt.Errorf("checking seed model: %w", err) + } + if exists { + return nil + } + weights, err := MarshalWeights(Prior()) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO risk_model_versions (org_id, version, weights_json, training_sample_count, update_kind, is_active) + VALUES (NULL, 1, $1, 0, 'seed', false)`, weights) + if err != nil { + return fmt.Errorf("inserting seed model: %w", err) + } + return nil +} + +// ActiveWeights returns the org's active weights, falling back to the shared +// seed when the org has no active version yet. Lazily creates the seed model +// if it doesn't exist yet (e.g. no review has ever been scored in this +// deployment), so callers never see a bare "no rows" error. +func (s *Store) ActiveWeights(ctx context.Context, orgID int64) (version int, weights Weights, sampleCount int, err error) { + var raw []byte + err = s.db.QueryRowContext(ctx, ` + SELECT version, weights_json, training_sample_count + FROM risk_model_versions + WHERE org_id = $1 AND is_active + ORDER BY id DESC LIMIT 1`, orgID).Scan(&version, &raw, &sampleCount) + if err == nil { + weights, err = UnmarshalWeights(raw) + return version, weights, sampleCount, err + } + if err != sql.ErrNoRows { + return 0, nil, 0, err + } + + if err := s.EnsureSeedModel(ctx); err != nil { + return 0, nil, 0, err + } + + // Fall back to the shared seed. + err = s.db.QueryRowContext(ctx, ` + SELECT version, weights_json, training_sample_count + FROM risk_model_versions + WHERE org_id IS NULL AND update_kind = 'seed' + ORDER BY id DESC LIMIT 1`).Scan(&version, &raw, &sampleCount) + if err != nil { + return 0, nil, 0, err + } + weights, err = UnmarshalWeights(raw) + return version, weights, sampleCount, err +} + +// NextVersion returns the next per-org version number (1 if none yet). +func (s *Store) NextVersion(ctx context.Context, orgID int64) (int, error) { + var v int + err := s.db.QueryRowContext(ctx, + `SELECT COALESCE(MAX(version), 0) + 1 FROM risk_model_versions WHERE org_id = $1`, orgID).Scan(&v) + return v, err +} + +// SaveVersion appends a new active version (deactivating the previous one) and +// returns the new row id. Append-only: any version can be reactivated later. +func (s *Store) SaveVersion(ctx context.Context, orgID int64, weights Weights, sampleCount int, auc *float64, kind string) (int64, error) { + raw, err := MarshalWeights(weights) + if err != nil { + return 0, err + } + version, err := s.NextVersion(ctx, orgID) + if err != nil { + return 0, err + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, `UPDATE risk_model_versions SET is_active = false WHERE org_id = $1 AND is_active`, orgID); err != nil { + return 0, err + } + var id int64 + err = tx.QueryRowContext(ctx, ` + INSERT INTO risk_model_versions (org_id, version, weights_json, training_sample_count, holdout_auc, update_kind, is_active) + VALUES ($1, $2, $3, $4, $5, $6, true) RETURNING id`, + orgID, version, raw, sampleCount, auc, kind).Scan(&id) + if err != nil { + return 0, err + } + if err := tx.Commit(); err != nil { + return 0, err + } + + Invalidate(orgID) + return id, nil +} + +// SaveFeatureSnapshot records the feature vector and score at evaluation time +// (the training set once review_outcomes attach to the same review_id). One +// snapshot per review: a re-evaluation replaces the prior row. +func (s *Store) SaveFeatureSnapshot(ctx context.Context, reviewID, orgID int64, modelVersion int, features []float64, score float64) error { + raw, err := json.Marshal(features) + if err != nil { + return err + } + if _, err := s.db.ExecContext(ctx, + `DELETE FROM risk_feature_snapshots WHERE review_id = $1 AND org_id = $2`, reviewID, orgID); err != nil { + return err + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO risk_feature_snapshots (review_id, org_id, model_version, features, score) + VALUES ($1, $2, $3, $4, $5)`, reviewID, orgID, modelVersion, raw, score) + return err +} + +// LoadOverrides returns the org's category multipliers, defaulting missing +// categories to 1.0. +func (s *Store) LoadOverrides(ctx context.Context, orgID int64) (map[string]float64, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT feature_category, multiplier FROM risk_factor_overrides WHERE org_id = $1`, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := map[string]float64{} + for rows.Next() { + var cat string + var m float64 + if err := rows.Scan(&cat, &m); err != nil { + return nil, err + } + out[cat] = m + } + return out, rows.Err() +} + +// UpsertOverride sets a category multiplier. Takes effect immediately (the +// scorer cache is invalidated); the next batch refit treats it as an anchor. +func (s *Store) UpsertOverride(ctx context.Context, orgID int64, category string, multiplier float64) error { + if multiplier <= 0 { + return fmt.Errorf("multiplier must be > 0") + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO risk_factor_overrides (org_id, feature_category, multiplier) + VALUES ($1, $2, $3) + ON CONFLICT (org_id, feature_category) DO UPDATE SET multiplier = EXCLUDED.multiplier, updated_at = now()`, + orgID, category, multiplier) + if err == nil { + Invalidate(orgID) + } + return err +} diff --git a/internal/riskscore/threshold_experiment_test.go b/internal/riskscore/threshold_experiment_test.go new file mode 100644 index 000000000..d6b1183e2 --- /dev/null +++ b/internal/riskscore/threshold_experiment_test.go @@ -0,0 +1,201 @@ +package riskscore + +// TestThresholdExperiment answers the questions a reader of the +// verification report keeps asking: "what does the threshold setting +// actually do to the approval rate," "is the threshold a fixed number," and +// "what happens at the extreme ends (P0, P100)." Every question gets a +// real, computed answer here instead of a guess. No database is needed: +// PercentileCutoff and Score are pure functions over plain feature vectors, +// so this scores a fixed, reproducible mix of reviews +// (internal/riskscore/referencemix) with the real seed model. +// +// See docs/risk-policy-explainability.md's "Threshold" section for the +// numbers this test produces, quoted directly. internal/policyengine's +// scenario corpus test imports the same referencemix package, so every +// scenario's reported percentile rank is measured against this identical +// distribution. + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/livereview/internal/riskscore/referencemix" +) + +// tierDump and namedExampleDump are the machine-readable record this test +// logs (prefix "TDDATA:") -- the verification report's Thresholds tab is +// regenerated directly from this JSON by scripts/regen_risk_report.py, never +// hand-transcribed. If you change referencemix, Prior(), or this test's tier +// list, re-run `make regen-risk-report` (or the script directly) to keep the +// report in sync -- it will NOT update itself. +type tierDump struct { + Percentile int `json:"percentile"` + Tag string `json:"tag"` + Cutoff float64 `json:"cutoff"` + Approved int `json:"approved"` + Total int `json:"total"` +} + +type namedExampleDump struct { + Label string `json:"label"` + Score float64 `json:"score"` + Decision map[string]bool `json:"decision"` // percentile (as string) -> approved +} + +type thresholdExperimentDump struct { + Tiers []tierDump `json:"tiers"` + NamedExamples []namedExampleDump `json:"named_examples"` +} + +// TestThresholdExperimentApprovalRatesByTier is the "what does P10/P20/P50/ +// P80 actually do" answer: compute the cutoff and resulting approval rate +// for each tier against the same realistic mix, and show which named +// examples flip from approved to blocked at the stricter tiers. +func TestThresholdExperimentApprovalRatesByTier(t *testing.T) { + prior := Prior() + mix := referencemix.Build(FeatureCount()) + + var scores []float64 + named := map[string]float64{} + var namedOrder []string // insertion order from referencemix.Build, not map iteration (which Go randomizes) -- keeps TDDATA reproducible byte-for-byte across runs. + for _, ex := range mix { + s := Score(prior, ex.Features, nil) + scores = append(scores, s) + if ex.Label != "" { + named[ex.Label] = s + namedOrder = append(namedOrder, ex.Label) + } + } + + tiers := []struct { + percentile int + tag string + }{ + {0, "the strictest possible setting"}, + {10, "strict"}, + {20, "balanced (today's default)"}, + {50, "relaxed"}, + {80, "very relaxed"}, + {100, "the most relaxed possible setting"}, + } + + dump := thresholdExperimentDump{} + for _, label := range namedOrder { + dump.NamedExamples = append(dump.NamedExamples, namedExampleDump{Label: label, Score: named[label], Decision: map[string]bool{}}) + } + + t.Logf("synthetic mix: %d reviews, mostly small/clean (matches a typical repo's recent history)", len(scores)) + for _, tier := range tiers { + cutoff := PercentileCutoff(scores, tier.percentile) + approved := 0 + for _, s := range scores { + if s < cutoff { + approved++ + } + } + rate := float64(approved) * 100 / float64(len(scores)) + t.Logf("P%-3d %-32s cutoff=%.3f approved=%d/%d (%.0f%%)", tier.percentile, tier.tag, cutoff, approved, len(scores), rate) + dump.Tiers = append(dump.Tiers, tierDump{Percentile: tier.percentile, Tag: tier.tag, Cutoff: cutoff, Approved: approved, Total: len(scores)}) + for i, label := range namedOrder { + score := named[label] + decision := "approved" + approvedBool := score < cutoff + if !approvedBool { + decision = "BLOCKED" + } + t.Logf(" at P%d: %-38s score=%.3f -> %s", tier.percentile, label, score, decision) + dump.NamedExamples[i].Decision[fmt.Sprintf("%d", tier.percentile)] = approvedBool + } + } + + dumpJSON, err := json.Marshal(dump) + if err != nil { + t.Fatalf("marshal threshold dump: %v", err) + } + t.Logf("TDDATA:%s", dumpJSON) + + // Sanity: a stricter percentile (lower P) must never approve more of the + // mix than a more relaxed one (higher P) -- the dial must point the + // right way, end to end. + prevApproved := -1 + for _, tier := range tiers { + cutoff := PercentileCutoff(scores, tier.percentile) + approved := 0 + for _, s := range scores { + if s < cutoff { + approved++ + } + } + if approved < prevApproved { + t.Fatalf("P%d approved fewer reviews (%d) than a stricter, lower percentile already approved (%d) -- the dial points the wrong way", tier.percentile, approved, prevApproved) + } + prevApproved = approved + } + + // The two edges, stated precisely: P0 (and P1, since PercentileCutoff + // clamps p<=0 to 1) must approve nothing -- its cutoff is the mix's own + // lowest score, and nothing scores strictly below the lowest score. + // P100 must approve everything except the single riskiest review in the + // mix -- its cutoff is the mix's own highest score, which is the one + // review that is never strictly below its own cutoff. + p0Cutoff := PercentileCutoff(scores, 0) + p0Approved := 0 + for _, s := range scores { + if s < p0Cutoff { + p0Approved++ + } + } + if p0Approved != 0 { + t.Fatalf("P0 should approve nothing (strictest possible setting), approved %d/%d", p0Approved, len(scores)) + } + + p100Cutoff := PercentileCutoff(scores, 100) + p100Approved := 0 + for _, s := range scores { + if s < p100Cutoff { + p100Approved++ + } + } + if p100Approved != len(scores)-1 { + t.Fatalf("P100 should approve every review except the single riskiest one (%d/%d), got %d/%d", len(scores)-1, len(scores), p100Approved, len(scores)) + } +} + +// TestThresholdExperimentSamePercentileDifferentCutoff is the literal answer +// to "is the threshold a fixed number": the same P20 *setting*, run against +// two different organizations' recent score histories, produces two +// different cutoff *numbers*. The setting (percentile) is static until an +// admin changes it; the number it resolves to moves with the org's own +// history automatically. +func TestThresholdExperimentSamePercentileDifferentCutoff(t *testing.T) { + quietOrgHistory := make([]float64, 0, 40) + for i := 0; i < 40; i++ { + quietOrgHistory = append(quietOrgHistory, 0.2+float64(i%10)*0.02) // 0.2-0.38, a calm repo + } + riskyOrgHistory := make([]float64, 0, 40) + for i := 0; i < 40; i++ { + riskyOrgHistory = append(riskyOrgHistory, 0.5+float64(i%10)*0.04) // 0.5-0.86, a repo mid-incident-spike + } + + quietCutoff := PercentileCutoff(quietOrgHistory, 20) + riskyCutoff := PercentileCutoff(riskyOrgHistory, 20) + + t.Logf("same policy setting (P20) on two different orgs' recent history:") + t.Logf(" quiet org (scores mostly 0.20-0.38): P20 cutoff = %.3f", quietCutoff) + t.Logf(" busy org (scores mostly 0.50-0.86): P20 cutoff = %.3f", riskyCutoff) + + same := map[string]float64{"quiet_cutoff": quietCutoff, "busy_cutoff": riskyCutoff} + sameJSON, err := json.Marshal(same) + if err != nil { + t.Fatalf("marshal same-percentile dump: %v", err) + } + t.Logf("TDSAME:%s", sameJSON) + + if quietCutoff == riskyCutoff { + t.Fatalf("expected the two orgs' cutoffs to differ even at the same P20 setting; got the same value %.3f for both", quietCutoff) + } + if riskyCutoff <= quietCutoff { + t.Fatalf("the org with a riskier recent history should calibrate to a higher cutoff: quiet=%.3f risky=%.3f", quietCutoff, riskyCutoff) + } +} diff --git a/pkg/models/models.go b/pkg/models/models.go index df35411f0..bb2366a07 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -55,6 +55,9 @@ type User struct { CreatedByUserID *int64 `json:"created_by_user_id,omitempty" db:"created_by_user_id"` PasswordResetRequired bool `json:"password_reset_required" db:"password_reset_required"` DefaultOrgID *int64 `json:"default_org_id,omitempty" db:"default_org_id"` + // ReviewLevel is the seniority dial gating auto-approve eligibility + // (docs/risk-policy-engine.md §1). Default 1; owners bump it per user. + ReviewLevel int `json:"review_level" db:"review_level"` } // FullName returns the user's display name (first + last name), falling back to @@ -164,6 +167,18 @@ type ReviewResult struct { Comments []*ReviewComment // External comments to be posted to the platform InternalComments []*ReviewComment // Internal comments used for synthesis only Quiz []QuizQuestion // Optional: 5-question comprehension quiz for this PR, nil if none was generated + // DiffClassification is the diff-level semantic classification the review + // LLM emits alongside findings (docs/risk-policy-engine.md §5). Nil for + // CLI/legacy reviews and for degraded responses where the field was absent. + DiffClassification *DiffClassification +} + +// DiffClassification is the closed-taxonomy diff-level classification used by +// the auto-approve policy's LLM gate: Class is one of the values enumerated in +// prompts.DiffClassificationTaxonomy, Confidence is 0-10. +type DiffClassification struct { + Class string `json:"class"` + Confidence int `json:"confidence"` } // QuizQuestion is one multiple-choice question in a review's comprehension diff --git a/scripts/docindex/docs_sources.env b/scripts/docindex/docs_sources.env new file mode 100644 index 000000000..c48cee544 --- /dev/null +++ b/scripts/docindex/docs_sources.env @@ -0,0 +1,29 @@ +# Pinned commit SHAs for the chatbot's RAG corpus (internal/docindex/docs/), +# for git-lrc, git-lrc's wiki, LiveReview's wiki, and the public docs site +# (hexmoshomepage's pages/livereview/docs/ - what's published at +# hexmos.com/livereview/docs). LiveReview's own top-level docs/ (internal +# engineering plans, release notes, etc.) is deliberately NOT part of this +# corpus at all - see scripts/docindex/sync_docs_sources.sh. +# +# GitHub/GitLab, not any developer's machine, is the source of truth: these +# SHAs are what actually gets embedded into the binary (dev builds, +# `make build`, and Docker images alike - see +# scripts/docindex/sync_docs_sources.sh). This is normally kept current +# automatically (see check_docs_sources.py --auto, wired into every sync); +# a value here changing by hand is possible but not required. +# +# HEXMOSHOMEPAGE_DOCS_COMMIT points at a PRIVATE repo +# (git@git.apps.hexmos.com:hexmos/frontend/hexmoshomepage.git), unlike the +# 3 public GitHub sources below - any machine that builds LiveReview +# (including CI/Docker build hosts) needs SSH access to git.apps.hexmos.com +# configured for this source to sync; a missing/failing SSH key just means +# that one source is skipped (see sync_docs_sources.sh), not a build +# failure, but the content will then be stale/missing there. +# +# Checking for drift / bumping: see docs/docs-sources-pinning-plan.md, or +# run `make check-docs-sources` / `make update-docs-sources` / +# `make update-docs-sources-yes`. +GIT_LRC_COMMIT=14584106823b1597e2097fca9ff42d2d18de5417 +GIT_LRC_WIKI_COMMIT=a1bb17c392a46c1f3bf6cfad6396813ccf80a1a9 +LIVEREVIEW_WIKI_COMMIT=c86fff200182e3f2828759d631259e5d2869ba9a +HEXMOSHOMEPAGE_DOCS_COMMIT=4192cce20f254bdcad6146ba7a6ce98db4f8db2b diff --git a/scripts/regen_risk_report.py b/scripts/regen_risk_report.py new file mode 100755 index 000000000..87357db13 --- /dev/null +++ b/scripts/regen_risk_report.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Regenerate docs/risk-policy-verification-report.html from real test output. + +The report's prose is hand-written and stable. Every *number* in it -- +scenario scores, percentile ranks, threshold tiers, approval rates, learning- +cycle scores, weight deltas -- comes from one of three Go tests and is +embedded in the page as a JS data variable. This script is the only thing +that should ever update those variables: it never invents a number, it only +runs the tests and copies out what they actually printed. + +Run this after changing: + - internal/riskscore/model.go (weights, DefaultCategoryMultiplier) + - internal/riskscore/features.go (FeatureSpecs) + - internal/riskscore/referencemix (the reference distribution) + - internal/policyengine/testdata/scenarios.json (the scenario corpus) + - internal/riskscore/threshold_experiment_test.go + - internal/riskscore/learning_cycle_test.go + +Usage: + DATABASE_URL=postgres://... python3 scripts/regen_risk_report.py + (DATABASE_URL defaults to the same local-dev DSN internal/*_test.go's + testDB(t) helpers fall back to.) +""" +import json +import re +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +REPORT_PATH = REPO_ROOT / "docs" / "risk-policy-verification-report.html" + + +def run_go_test(pattern: str, pkg: str) -> str: + cmd = ["go", "test", pkg, "-run", pattern, "-v", "-count=1"] + print(f"$ {' '.join(cmd)}", file=sys.stderr) + result = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True) + print(result.stdout, file=sys.stderr) + if result.returncode != 0: + print(result.stderr, file=sys.stderr) + raise SystemExit( + f"go test {pattern} failed (exit {result.returncode}) -- " + "fix the failing test before regenerating the report; " + "the report must never embed a lie." + ) + return result.stdout + + +def extract_prefixed_json(output: str, prefix: str) -> list: + """Every line like 'prefix{...json...}' in `go test -v` output, parsed.""" + out = [] + for line in output.splitlines(): + idx = line.find(prefix) + if idx == -1: + continue + raw = line[idx + len(prefix):] + out.append(json.loads(raw)) + return out + + +def extract_one_prefixed_json(output: str, prefix: str) -> dict: + rows = extract_prefixed_json(output, prefix) + if len(rows) != 1: + raise SystemExit(f"expected exactly one {prefix!r} line, found {len(rows)}") + return rows[0] + + +def replace_var(html: str, var_name: str, value) -> str: + """Replace `var NAME = ;` with `var NAME = ;`. + + Matches non-greedily up to the first `;` that is immediately followed by + whitespace-then-`var ` or end of script -- good enough because these + variables are always simple JSON literals with no embedded `;`. + """ + pattern = re.compile(r"var " + re.escape(var_name) + r" = .*?;\n", re.S) + new_assignment = f"var {var_name} = {json.dumps(value, separators=(',', ':'))};\n" + new_html, n = pattern.subn(new_assignment, html, count=1) + if n != 1: + raise SystemExit(f"could not find `var {var_name} = ...;` in the report to replace") + return new_html + + +def main() -> None: + scenario_output = run_go_test("TestScenarios", "./internal/policyengine/...") + scenarios = extract_prefixed_json(scenario_output, "SCDATA:") + if len(scenarios) < 1: + raise SystemExit("no SCDATA: lines found -- did TestScenarios actually run?") + + threshold_output = run_go_test( + "TestThresholdExperimentApprovalRatesByTier|TestThresholdExperimentSamePercentileDifferentCutoff", + "./internal/riskscore/...", + ) + threshold_data = extract_one_prefixed_json(threshold_output, "TDDATA:") + same_percentile_data = extract_one_prefixed_json(threshold_output, "TDSAME:") + + learning_output = run_go_test("TestLearningCycleProvesFeedbackLoopWorks", "./internal/riskscore/...") + learning_data = extract_one_prefixed_json(learning_output, "LCDATA:") + + html = REPORT_PATH.read_text() + html = replace_var(html, "SCENARIOS", scenarios) + html = replace_var(html, "THRESHOLD_DATA", threshold_data) + html = replace_var(html, "SAME_PERCENTILE_DATA", same_percentile_data) + html = replace_var(html, "LEARNING_DATA", learning_data) + REPORT_PATH.write_text(html) + + print(f"updated {REPORT_PATH.relative_to(REPO_ROOT)} from {len(scenarios)} scenarios, " + f"{len(threshold_data['tiers'])} threshold tiers, and the learning-cycle test.", + file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 3bb59175a..506c94bc2 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -22,6 +22,7 @@ const AIProviders = React.lazy(() => import('./pages/AIProviders/AIProviders')); const Settings = React.lazy(() => import('./pages/Settings/Settings')); const ReviewsRoutes = React.lazy(() => import('./pages/Reviews/ReviewsRoutes')); const ExploreRoutes = React.lazy(() => import('./pages/Explore/ExploreRoutes')); +const PullRequestReviewTrigger = React.lazy(() => import('./pages/Reviews/PullRequestReviewTrigger')); const Login = React.lazy(() => import('./pages/Auth/Login')); const SelfHosted = React.lazy(() => import('./pages/Auth/SelfHosted')); const Setup = React.lazy(() => import('./pages/Setup/Setup')); @@ -37,6 +38,7 @@ const UserForm = React.lazy(() => import('./components/UserManagement/UserForm') const BillingPortfolio = React.lazy(() => import('./pages/Admin/BillingPortfolio')); const TaxonomyReports = React.lazy(() => import('./pages/Reports/TaxonomyReports')); const OnboardingReport = React.lazy(() => import('./pages/Reports/OnboardingReport')); +const AutoApproveReport = React.lazy(() => import('./pages/Reports/AutoApproveReport')); const ChatbotRoutes = React.lazy(() => import('./pages/Chatbot/ChatbotRoutes')); const ChatDebugRoutes = React.lazy(() => import('./pages/Chatbot/ChatDebugRoutes')); const CiRulesetsRoutes = React.lazy(() => import('./pages/CiRulesets/CiRulesetsRoutes')); @@ -314,6 +316,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> @@ -330,6 +333,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/pullRequests.ts b/ui/src/api/pullRequests.ts index aed9675e5..023f11700 100644 --- a/ui/src/api/pullRequests.ts +++ b/ui/src/api/pullRequests.ts @@ -58,6 +58,25 @@ export const triggerReviewForPullRequest = async (pullRequestId: number): Promis return apiClient.post('/api/v1/reviews', { pull_request_id: pullRequestId }); }; +/** + * Resolve which org a PR belongs to (404 if missing or the user isn't a member). + * Used by the PR comment's "Start a review" link before the org is known. + */ +export const getPullRequestOrg = async (pullRequestId: number): Promise<{ org_id: number }> => { + return apiClient.get<{ org_id: number }>(`/api/v1/pull-requests/${pullRequestId}/org`); +}; + +/** + * Start a review from the PR comment link: reuses one still running from the last + * 10 minutes, and runs the approval policy check when it finishes. + */ +export const startOrReuseReviewForPullRequest = async (pullRequestId: number): Promise => { + return apiClient.post('/api/v1/reviews', { + pull_request_id: pullRequestId, + from_pr_link: true, + }); +}; + /** * Human-friendly label for a PR/MR state. */ diff --git a/ui/src/api/reviews.ts b/ui/src/api/reviews.ts index 8a997726c..885c0f7ee 100644 --- a/ui/src/api/reviews.ts +++ b/ui/src/api/reviews.ts @@ -54,6 +54,7 @@ export const getReviews = async (filters: ReviewsFilters = {}): Promise; +} + +export interface PolicyLogResponse { + stages: PolicyLogStage[]; + blocked: boolean; + force_approve_min_level: number; + features: number[]; + feature_names: { Name: string; Category: string }[]; + model_version: number; + score: number; +} + +export interface RiskModelInfo { + version: number; + training_samples: number; + is_seed: boolean; +} + +export interface RiskModelResponse { + model: RiskModelInfo; + overrides: Record; + scores: number[]; +} + +export interface OutcomeRow { + review_id: number; + outcome_type: string; + source: string; + reason: string; + created_at: string; +} + +export interface UserIdentity { + id: number; + org_id: number; + user_id: number; + kind: 'email' | 'username'; + value: string; +} + +export interface SimulateRow { + review_id: number; + would_approve: boolean; + blocked_at?: string; + score?: number; +} + +export interface ScoreHistogramBucket { + range_start: number; + range_end: number; + count: number; +} + +export interface ScoreDistribution { + buckets: ScoreHistogramBucket[]; + cutoff: number; + percentile: number; + total_scored: number; +} + +export interface DecisionTrendPoint { + day: string; + approved: number; + blocked: number; +} + +export interface DecisionsSummary { + approved: number; + blocked_at_eligibility: number; + blocked_at_static: number; + blocked_at_risk: number; + blocked_at_llm: number; + overridden: number; + total_decided: number; + override_rate: number; + trend: DecisionTrendPoint[]; +} + +export interface CalibrationCell { + decision: 'approved' | 'blocked'; + outcome_type: 'revert' | 'incident' | 'confirmed_safe'; + count: number; +} + +export interface Calibration { + cells: CalibrationCell[]; + total_outcomes: number; + false_approve_rate?: number; + low_volume_warning: boolean; +} + +export interface AutoApproveInsights { + window_days: number; + score_distribution: ScoreDistribution; + decisions: DecisionsSummary; + calibration: Calibration; +} + +// ---- auto-approve policy CRUD ---------------------------------------------- + +export const listAutoApprovePolicies = (): Promise<{ rows: AutoApprovePolicy[] }> => + apiClient.get('/auto-approve-policies'); + +export const createAutoApprovePolicy = (body: AutoApprovePolicyUpsert): Promise<{ policy: AutoApprovePolicy }> => + apiClient.post('/auto-approve-policies', body); + +export const updateAutoApprovePolicy = (id: number, body: AutoApprovePolicyUpsert): Promise<{ policy: AutoApprovePolicy }> => + apiClient.put(`/auto-approve-policies/${id}`, body); + +export const deleteAutoApprovePolicy = (id: number): Promise<{ status: string }> => + apiClient.delete(`/auto-approve-policies/${id}`); + +export const getResolvedPolicy = (repositoryId?: number): Promise<{ policy: AutoApprovePolicy | null; configured: boolean }> => + apiClient.get(`/auto-approve-policies/resolved${repositoryId ? `?repository_id=${repositoryId}` : ''}`); + +export const simulatePolicy = (body: { + repository_id?: number; + policy: AutoApprovePolicyUpsert; + review_ids?: number[]; + limit?: number; +}): Promise<{ rows: SimulateRow[] }> => apiClient.post('/auto-approve-policies/simulate', body); + +// ---- review policy actions ------------------------------------------------- + +export const getReviewPolicyLog = (reviewId: number): Promise => + apiClient.get(`/reviews/${reviewId}/policy-log`); + +export const forceApproveReview = (reviewId: number, reason: string): Promise<{ status: string }> => + apiClient.post(`/reviews/${reviewId}/force-approve`, { reason }); + +export const markReviewOutcome = (reviewId: number, outcomeType: string, reason: string): Promise<{ status: string }> => + apiClient.post(`/reviews/${reviewId}/outcomes`, { outcome_type: outcomeType, reason }); + +// ---- risk model ------------------------------------------------------------ + +export const getRiskModel = (): Promise => apiClient.get('/risk-model'); + +export const putRiskModelOverrides = (overrides: Record): Promise<{ overrides: Record }> => + apiClient.put('/risk-model/overrides', { overrides }); + +export const retrainRiskModel = (): Promise<{ result: { promoted: boolean; training_count: number; holdout_auc: number; reason: string } }> => + apiClient.post('/risk-model/retrain', {}); + +// ---- auto-approve insights (Reports -> Auto Approve) ----------------------- + +export const getAutoApproveInsights = (windowDays: 7 | 30 | 90 = 30): Promise<{ insights: AutoApproveInsights }> => + apiClient.get(`/auto-approve-insights?window=${windowDays}`); + +// ---- outcomes -------------------------------------------------------------- + +export const listReviewOutcomes = (orgId: number): Promise<{ rows: OutcomeRow[]; total: number; manual_count: number; webhook_count: number }> => + apiClient.get(`/orgs/${orgId}/review-outcomes`); + +// ---- identities ------------------------------------------------------------ + +export const listUserIdentities = (orgId: number, userId: number): Promise<{ rows: UserIdentity[] }> => + apiClient.get(`/orgs/${orgId}/users/${userId}/identities`); + +export const addUserIdentity = (orgId: number, userId: number, kind: string, value: string): Promise => + apiClient.post(`/orgs/${orgId}/users/${userId}/identities`, { kind, value }); + +export const removeUserIdentity = (orgId: number, userId: number, identityId: number): Promise<{ status: string }> => + apiClient.delete(`/orgs/${orgId}/users/${userId}/identities/${identityId}`); + +export const listOrgIdentities = (orgId: number): Promise<{ rows: UserIdentity[] }> => + apiClient.get(`/orgs/${orgId}/identities`); diff --git a/ui/src/api/users.ts b/ui/src/api/users.ts index 11149f059..f70f13461 100644 --- a/ui/src/api/users.ts +++ b/ui/src/api/users.ts @@ -81,6 +81,7 @@ export interface Member { role_id: number; org_id: number; onboarding_api_key?: string; + review_level?: number; } export interface FetchUsersResponse { diff --git a/ui/src/components/AutoApprove/InfoHint.tsx b/ui/src/components/AutoApprove/InfoHint.tsx new file mode 100644 index 000000000..d60e543c3 --- /dev/null +++ b/ui/src/components/AutoApprove/InfoHint.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Tooltip, Icons } from '../UIPrimitives'; + +// A small "i" icon with a hover tooltip. Used next to every auto-approve / +// risk-policy control, since these settings are new and not self-explanatory +// to most users. Tooltip text must be written in Simplified Technical +// English (ASD-STE100 style): short sentences, one idea per sentence, plain +// words, active voice, and a concrete example wherever possible. +export const InfoHint: React.FC<{ text: React.ReactNode; className?: string }> = ({ text, className }) => ( + {text} + } + position="auto" + > + + + + +); + +// A label plus its info-icon hint, for use as the `label` prop on Input / +// Select (both accept ReactNode), or as plain text next to a Toggle/heading. +export const FieldLabel: React.FC<{ label: React.ReactNode; hint: React.ReactNode }> = ({ label, hint }) => ( + + {label} + + +); diff --git a/ui/src/components/AutoApprove/PolicyFieldsEditor.tsx b/ui/src/components/AutoApprove/PolicyFieldsEditor.tsx new file mode 100644 index 000000000..d4db6bcc9 --- /dev/null +++ b/ui/src/components/AutoApprove/PolicyFieldsEditor.tsx @@ -0,0 +1,186 @@ +import React from 'react'; +import { Input, Toggle } from '../UIPrimitives'; +import { AutoApprovePolicyUpsert } from '../../api/riskPolicy'; +import { CIRuleset } from '../../pages/CiRulesets/shared'; +import { FieldLabel, InfoHint } from './InfoHint'; +import { HELP } from './helpText'; + +// Shared field groups for the auto-approve policy editor, used both by the +// org-wide Settings tab (AutoApprovePolicyTab.tsx) and a repository's +// "Customize" override (RepoAutoApproveSection.tsx), so the two never drift +// out of sync — only the save target (org default vs. repository_id) differs. + +// Named presets for the risk threshold; the stored value stays a plain percentile. +export const THRESHOLD_PRESETS = [ + { value: 20, name: 'Conservative' }, + { value: 50, name: 'Balanced' }, + { value: 80, name: 'Aggressive' }, +]; + +export const thresholdLabel = (v: number) => + `${THRESHOLD_PRESETS.find((p) => p.value === v)?.name ?? 'Custom'} (safest ${v}%)`; + +const ThresholdPicker: React.FC<{ value: number; onChange: (v: number) => void; readOnly: boolean }> = ({ value, onChange, readOnly }) => { + const [custom, setCustom] = React.useState(!THRESHOLD_PRESETS.some((p) => p.value === value)); + const btn = (active: boolean) => + `px-3 py-1.5 rounded-md text-sm border ${active ? 'bg-blue-600 border-blue-500 text-white' : 'border-slate-600 text-slate-300 hover:bg-slate-700'} disabled:opacity-50`; + return ( +
+
+ {THRESHOLD_PRESETS.map((p) => ( + + ))} + +
+

+ Approves the safest {value} out of 100 PRs. +

+ {custom && ( +
+
+ onChange(parseInt(e.target.value, 10))} + disabled={readOnly} + /> + + {THRESHOLD_PRESETS.map((p) => + {/* Preset names under the slider, placed at their percent positions. */} +
+ {THRESHOLD_PRESETS.map((p) => ( + + ))} +
+
+ P{value} +
+ )} +
+ ); +}; + +export interface PolicyFieldsProps { + draft: AutoApprovePolicyUpsert; + patch: (partial: Partial) => void; + readOnly: boolean; +} + +export const StaticRulesFields: React.FC = ({ draft, patch, readOnly, rulesets }) => ( +
+

+ + Blocking rulesets always run (even under "always approve"). Manage them under{' '} + CI Rulesets. + + +

+ {rulesets.length === 0 &&

No rulesets defined yet.

} + {rulesets.map((r) => { + const on = (draft.blocking_ruleset_ids || []).includes(r.id); + return ( + + ); + })} +
+); + +export const EligibilityFields: React.FC = ({ draft, patch, readOnly }) => ( +
+ } + value={String(draft.min_author_level ?? 1)} + onChange={(e) => patch({ min_author_level: parseInt(e.target.value, 10) || 0 })} + disabled={readOnly} + helperText="Unmapped authors default to level 1; bots to level 0." + /> + } + value={String(draft.force_approve_min_level ?? 3)} + onChange={(e) => patch({ force_approve_min_level: parseInt(e.target.value, 10) || 0 })} + disabled={readOnly} + helperText="Users at/above this level (or owners/admins) may force-approve a blocked review." + /> +
+); + +export const GeneralFields: React.FC = ({ draft, patch, readOnly }) => ( +
+
+ + patch({ risk_threshold_percentile: v })} readOnly={readOnly} /> +
+ } + value={String(draft.require_llm_confidence ?? 8)} + onChange={(e) => patch({ require_llm_confidence: parseInt(e.target.value, 10) || 0 })} + disabled={readOnly} + /> + } + value={String(draft.landing_delay_seconds ?? 0)} + onChange={(e) => patch({ landing_delay_seconds: parseInt(e.target.value, 10) || 0 })} + disabled={readOnly} + helperText="Human-rejection window before the approval is posted." + /> + } + value={draft.daily_approve_cap == null ? '' : String(draft.daily_approve_cap)} + onChange={(e) => { + const v = e.target.value === '' ? undefined : parseInt(e.target.value, 10); + patch({ daily_approve_cap: v }); + }} + disabled={readOnly} + /> + +
+); diff --git a/ui/src/components/AutoApprove/helpText.ts b/ui/src/components/AutoApprove/helpText.ts new file mode 100644 index 000000000..4086a4713 --- /dev/null +++ b/ui/src/components/AutoApprove/helpText.ts @@ -0,0 +1,160 @@ +// Help text for every auto-approve / risk-policy control. Written in +// Simplified Technical English (ASD-STE100 style): short sentences, one idea +// per sentence, plain common words, active voice, and a concrete example +// wherever it helps. These settings are new, so most users will not guess +// their meaning from the label alone. +// +// Each entry uses "\n\n" to break distinct ideas into separate paragraphs — +// InfoHint renders hint text with `whitespace-pre-line`, so these breaks +// become real line breaks in the popover instead of one dense run-on block. + +export const HELP = { + // ---- Static Rules ---- + staticRules: + 'A static rule is a fixed check. It runs the same way on every pull request.\n\n' + + 'Example: block the merge when a critical security finding is present.\n\n' + + "Static rules always run, even in 'Always approve' mode. This stops a low-risk " + + 'repository setting from hiding a serious problem.', + + // ---- Eligibility ---- + minAuthorLevel: + 'Each user has a review level. Level 1 is the lowest.\n\n' + + 'Set the lowest level a pull request author must have to get an auto-approval.\n\n' + + 'Example: set this to 3. Only authors at level 3 or higher can skip human review.\n\n' + + 'An author below this level always needs a human reviewer, unless you add them to the whitelist.', + + forceApproveMinLevel: + 'This sets the lowest review level a user must have to force-approve a blocked review.\n\n' + + "A force-approve overrides the policy's block. The user must give a reason.\n\n" + + 'The system saves the reason in the audit log.\n\n' + + 'Example: set this to 5. Only senior reviewers can then override a block.', + + // ---- General ---- + riskThresholdPercentile: + 'This sets how strict the risk check is.\n\n' + + 'The system scores every review from 0 (safe) to 100 (risky). ' + + "It compares each new score to your team's recent scores.\n\n" + + 'Conservative approves only the safest 20 out of 100 reviews, Balanced 50, Aggressive 80.\n\n' + + 'Pick Custom to set any other number. A lower number is stricter.', + + requireLLMConfidence: + 'The AI reviewer also labels the type of change, for example "refactor-only" or "security-sensitive".\n\n' + + 'It gives a confidence score from 0 to 10 for this label.\n\n' + + 'Set the lowest confidence score the policy will accept.\n\n' + + 'Example: set this to 8. The system blocks the auto-approval when the AI confidence is below 8.', + + landingDelaySeconds: + 'This is a wait time after an auto-approval, before the system posts it.\n\n' + + 'During this time, a human can still step in and stop the merge.\n\n' + + 'Example: set this to 600 for a 10-minute wait.\n\n' + + 'Set it to 0 to post the approval right away.', + + dailyApproveCap: + 'This sets the highest number of auto-approvals allowed per day.\n\n' + + 'Example: set this to 20. The system stops auto-approving after 20 reviews that day.\n\n' + + 'Leave this field blank for no limit.', + + restrictOutcomeReporting: + 'This switch controls who can report a revert or an incident from the review page.\n\n' + + 'When it is on, only owners and admins can report one. ' + + 'When it is off, any team member can report one.\n\n' + + 'The webhook always needs its own secret key. This switch does not change that.', + + // ---- Risk Model tab ---- + activeModel: + 'This is the risk model that scores reviews right now.\n\n' + + 'A "seed" model uses general, common-sense rules only. It has not learned from your data yet.\n\n' + + 'A "trained" model has learned from the outcomes your team reported.', + + categoryOverrideIntro: + 'Each slider changes how strongly one type of finding affects the risk score.\n\n' + + 'A value of 1x keeps the starting weight. A value of 2x doubles its effect.\n\n' + + 'A change here takes effect right away. It does not need a retrain.', + + categoryBlastRadius: + 'Blast radius measures how much other code a change can affect.\n\n' + + 'A large blast radius means higher risk.\n\n' + + 'Raise this slider to make the risk score react more strongly to large, far-reaching changes.', + + categorySecurityFindings: + 'This counts findings tagged as security issues.\n\n' + + 'Raise this slider to make the risk score react more strongly to security findings.', + + categoryCriticalFindings: + 'This counts every finding marked "critical", in any category.\n\n' + + 'Raise this slider to make the risk score react more strongly whenever a critical finding appears, ' + + 'on top of that finding\'s own category weight.', + + categoryDiffSize: + 'This measures how many files and lines changed.\n\n' + + 'Raise this slider to make the risk score react more strongly to large changes.', + + retrainNow: + "This retrains the risk model right now, using every outcome your team has reported so far.\n\n" + + 'The system also retrains once a day on its own.\n\n' + + 'Use this button after you report new outcomes, or change a weight, and want the model to update sooner.', + + simulate: + "This tests the current draft policy against your team's recent completed reviews.\n\n" + + 'It shows what the policy would have done for each review.\n\n' + + 'No approval is sent. No setting is saved.\n\n' + + 'Use this to check a policy before you save it.', + + // ---- Production Feedback tab ---- + outcomeWebhook: + 'Your CI/CD system or incident tool can send a message to this address.\n\n' + + 'It can report a revert, an incident, or confirm that a change was safe.\n\n' + + 'Example: a pipeline step runs "git revert", then posts to this address with outcome_type set to "revert".', + + outcomeCounter: + 'This shows how many outcomes your team reported in the last 30 days.\n\n' + + 'More reports help the risk model learn faster.\n\n' + + 'Even a few reports each month are enough to start.', + + // ---- Repository override ---- + repoMode: + 'Choose how this repository uses the policy.\n\n' + + "'Inherit' uses the organization's default policy.\n\n" + + "'Custom' lets you set different rules for only this repository.\n\n" + + "'Always approve' skips the risk check, but static rules still run.\n\n" + + "'Never approve' blocks every pull request from auto-approval.", + + repoSharedModel: + 'The trained risk model is shared by the whole organization.\n\n' + + 'Only the cutoff score, and the other settings below, can be different for this repository.', + + // ---- Review detail funnel (Auto Approve tab) ---- + stageEligibility: + "This checks if the pull request author's review level is high enough to skip human review, " + + 'or if the author is on the whitelist.', + + stageStatic: + "This runs your team's fixed checks, for example a check for leaked secrets in the diff.\n\n" + + 'A static rule can block the approval even when every other check passes.', + + stageRisk: + "This is the trained model's risk score for this change.\n\n" + + "The system compares it to your team's threshold.\n\n" + + 'A higher score means higher risk.', + + stageLLM: + 'This is the AI\'s label for the type of change, for example "logic-change" or "security-sensitive", ' + + 'with a confidence score.', + + stageFinal: + "This is the policy's final answer: approve or block.\n\n" + + 'It is the result of every stage above, combined.', + + forceApproveSection: + "Force approve overrides a blocked review's decision.\n\n" + + 'Only a user at or above the required review level, or an owner/admin, can do this.\n\n' + + 'You must give a reason.\n\n' + + 'The system saves this action in the audit log, and posts a warning comment on the pull request.', + + markOutcomeSection: + 'Use this to tell the risk model what happened after this change went live.\n\n' + + '"Revert" means the team rolled the change back.\n\n' + + '"Incident" means the change caused a production problem.\n\n' + + '"Confirmed safe" means the change caused no problems.\n\n' + + 'Your report helps the model learn.', +}; diff --git a/ui/src/components/AutoApprove/taxonomyHelp.ts b/ui/src/components/AutoApprove/taxonomyHelp.ts new file mode 100644 index 000000000..0f9e57974 --- /dev/null +++ b/ui/src/components/AutoApprove/taxonomyHelp.ts @@ -0,0 +1,114 @@ +// Mirrors internal/prompts/templates.go's TaxonomyClassificationRules — the +// one closed set of 10 finding categories the review LLM must classify every +// comment into. Keys here match internal/riskscore's FeatureSpecs category +// tags (see internal/riskscore/features.go), so they line up 1:1 with the +// risk-model override sliders in AutoApprovePolicyTab.tsx. + +export interface TaxonomyCategory { + key: string; + label: string; + subcategories: string[]; +} + +export const TAXONOMY_CATEGORIES: TaxonomyCategory[] = [ + { + key: 'security', + label: 'Security', + subcategories: [ + 'Authentication', 'Authorization', 'Secrets Management', 'Input Validation', + 'Injection Vulnerabilities', 'Cryptography', 'Dependency Vulnerabilities', + 'Data Exposure', 'Session Management', 'Security Logging & Auditing', + ], + }, + { + key: 'reliability', + label: 'Reliability', + subcategories: [ + 'Error Handling', 'Fault Tolerance', 'Retry Logic', 'Timeout Management', + 'Resilience Patterns', 'Availability Risks', 'Data Integrity', 'Race Conditions', + 'Resource Cleanup', 'Failure Recovery', + ], + }, + { + key: 'correctness', + label: 'Correctness', + subcategories: [ + 'Logic Errors', 'Edge Cases', 'Data Validation', 'State Management', + 'Concurrency Bugs', 'Business Rule Violations', 'Numerical Accuracy', + 'Null Handling', 'Type Safety', 'API Contract Violations', + ], + }, + { + key: 'performance', + label: 'Performance', + subcategories: [ + 'Database Efficiency', 'Algorithmic Complexity', 'Memory Usage', 'CPU Utilization', + 'Network Efficiency', 'Caching', 'Concurrency', 'Resource Contention', + 'Rendering Performance', 'Startup Performance', + ], + }, + { + key: 'scalability', + label: 'Scalability', + subcategories: [ + 'Horizontal Scaling', 'Vertical Scaling', 'Distributed Systems', 'Load Balancing', + 'Capacity Planning', 'Bottleneck Risks', 'Concurrency Limits', + 'Service Growth Constraints', 'Database Scaling', 'Queue Backpressure', + ], + }, + { + key: 'compliance_governance', + label: 'Compliance & Governance', + subcategories: [ + 'Privacy', 'Regulatory Compliance', 'Auditability', 'Data Retention', + 'Data Residency', 'Licensing', 'Policy Enforcement', 'Access Controls', + 'Change Management', 'Governance Standards', + ], + }, + { + key: 'maintainability', + label: 'Maintainability', + subcategories: [ + 'Code Complexity', 'Readability', 'Documentation', 'Code Duplication', 'Dead Code', + 'Naming Quality', 'Testability', 'Technical Debt', 'Refactoring Opportunities', + 'Configuration Management', 'UI/UX', 'Accessibility', + ], + }, + { + key: 'architecture', + label: 'Architecture', + subcategories: [ + 'Separation of Concerns', 'Modularity', 'Coupling', 'Cohesion', + 'Layering Violations', 'Dependency Management', 'Service Boundaries', + 'Domain Modeling', 'API Design', 'Extensibility', + ], + }, + { + key: 'developer_experience', + label: 'Developer Experience', + subcategories: [ + 'Testing', 'CI/CD', 'Build System', 'Local Development', 'Debuggability', + 'Observability', 'Deployment Process', 'Automation', 'Developer Tooling', + 'Documentation Quality', 'UI/UX', 'Accessibility', + ], + }, + { + key: 'cost', + label: 'Cost', + subcategories: [ + 'Cloud Resource Waste', 'Infrastructure Overprovisioning', 'Storage Optimization', + 'Database Cost Optimization', 'Excessive API Usage', 'Third-Party Service Costs', + 'Redundant Computation', 'LLM Token Consumption', 'Caching Opportunities', + 'Data Transfer Costs', + ], + }, +]; + +// A short, Simplified-Technical-English hint for a category slider's hover +// help: what it does, then its sub-issues as a bulleted list (one per line, +// via InfoHint's whitespace-pre-line rendering) rather than one long +// comma-separated sentence that's hard to scan. +export const categoryHint = (cat: TaxonomyCategory): string => + `This slider sets the weight of "${cat.label}" findings in the risk score.\n\n` + + `A higher value makes the engine treat these findings as riskier.\n\n` + + `It covers:\n${cat.subcategories.map((s) => `• ${s}`).join('\n')}`; diff --git a/ui/src/components/Connector/ManualBitbucketConnector.tsx b/ui/src/components/Connector/ManualBitbucketConnector.tsx index 55081a00d..9a12f6e0e 100644 --- a/ui/src/components/Connector/ManualBitbucketConnector.tsx +++ b/ui/src/components/Connector/ManualBitbucketConnector.tsx @@ -121,7 +121,9 @@ const ManualBitbucketConnector: React.FC = () => {

  • Generate at: id.atlassian.com
  • -
  • Grant repo read + pull request read permissions
  • +
  • + Grant scopes: read:user, read:workspace, read:repository, read:pullrequest, write:pullrequest, read:webhook, write:webhook, delete:webhook +
  • Use a dedicated service user (recommended)
diff --git a/ui/src/components/DataTable/ClientTable.tsx b/ui/src/components/DataTable/ClientTable.tsx index d9403fc7e..ee4d43472 100644 --- a/ui/src/components/DataTable/ClientTable.tsx +++ b/ui/src/components/DataTable/ClientTable.tsx @@ -63,7 +63,8 @@ export function ClientTable({ onRowClick, manualTotal, }: ClientTableProps) { - if (loading && isEmpty) { + // Show loading until the first rows arrive, even when filters keep isEmpty false. + if (loading && (isEmpty || table.getRowModel().rows.length === 0)) { return (
diff --git a/ui/src/components/Explore/RepoAutoApproveSection.tsx b/ui/src/components/Explore/RepoAutoApproveSection.tsx new file mode 100644 index 000000000..56c4c6386 --- /dev/null +++ b/ui/src/components/Explore/RepoAutoApproveSection.tsx @@ -0,0 +1,169 @@ +import React, { useEffect, useState } from 'react'; +import { Alert, Button, Select, Spinner, Tabs } from '../UIPrimitives'; +import { useOrgContext } from '../../hooks/useOrgContext'; +import { + AutoApprovePolicy, + AutoApprovePolicyUpsert, + listAutoApprovePolicies, + createAutoApprovePolicy, + updateAutoApprovePolicy, +} from '../../api/riskPolicy'; +import { CIRuleset } from '../../pages/CiRulesets/shared'; +import apiClient from '../../api/apiClient'; +import { StaticRulesFields, EligibilityFields, GeneralFields } from '../AutoApprove/PolicyFieldsEditor'; +import { FieldLabel, InfoHint } from '../AutoApprove/InfoHint'; +import { HELP } from '../AutoApprove/helpText'; + +// Draft fields a repo can customize — everything on auto_approve_policies +// except the trained risk model itself (weights/category overrides stay +// org-wide only, see docs/risk-policy-engine.md §3/§9). +const draftFromPolicy = (p: AutoApprovePolicy | null): AutoApprovePolicyUpsert => ({ + min_author_level: p?.min_author_level ?? 1, + eligibility_whitelist_user_ids: p?.eligibility_whitelist_user_ids ?? [], + risk_threshold_percentile: p?.risk_threshold_percentile ?? 20, + require_llm_confidence: p?.require_llm_confidence ?? 8, + blocking_ruleset_ids: p?.blocking_ruleset_ids ?? [], + landing_delay_seconds: p?.landing_delay_seconds ?? 0, + daily_approve_cap: p?.daily_approve_cap, + restrict_outcome_reporting_to_admins: p?.restrict_outcome_reporting_to_admins ?? false, + force_approve_min_level: p?.force_approve_min_level ?? 3, +}); + +const RepoAutoApproveSection: React.FC<{ repositoryId: number }> = ({ repositoryId }) => { + const { currentOrg, canManageCurrentOrg } = useOrgContext(); + const readOnly = !canManageCurrentOrg && currentOrg?.role !== 'owner'; + + const [orgDefault, setOrgDefault] = useState(null); + const [repoPolicy, setRepoPolicy] = useState(null); + const [rulesets, setRulesets] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(''); + + const [mode, setMode] = useState<'inherit' | 'custom' | 'always_approve' | 'never_approve'>('inherit'); + const [draft, setDraft] = useState(draftFromPolicy(null)); + const [subtab, setSubtab] = useState('static-rules'); + + useEffect(() => { + Promise.all([listAutoApprovePolicies(), apiClient.get<{ rows: CIRuleset[] }>('/ci-rulesets')]) + .then(([polRes, rsRes]) => { + const org = polRes.rows.find((p) => p.repository_id == null) || null; + const repo = polRes.rows.find((p) => p.repository_id === repositoryId) || null; + setOrgDefault(org); + setRepoPolicy(repo); + setRulesets(rsRes.rows || []); + setMode(repo?.mode ?? 'inherit'); + // A repo's "Customize" starts from its own saved values if it has any, + // else from the org default — never from bare schema defaults — so + // picking Customize can't silently loosen an org-wide setting. + setDraft(draftFromPolicy(repo ?? org)); + }) + .catch((e) => setError(String(e?.message || e))) + .finally(() => setLoading(false)); + }, [repositoryId]); + + const patch = (partial: Partial) => setDraft((d) => ({ ...d, ...partial })); + + const onModeChange = (next: typeof mode) => { + // Switching into custom for the first time: seed from the org default so + // the repo starts aligned with it rather than at hardcoded defaults. + if (next === 'custom' && !repoPolicy) { + setDraft(draftFromPolicy(orgDefault)); + } + setMode(next); + }; + + const save = async () => { + setSaving(true); + setError(''); + try { + const body: AutoApprovePolicyUpsert = { mode, ...(mode === 'custom' ? draft : { risk_threshold_percentile: draft.risk_threshold_percentile }) }; + if (repoPolicy) { + const res = await updateAutoApprovePolicy(repoPolicy.id, body); + setRepoPolicy(res.policy); + } else { + const res = await createAutoApprovePolicy({ repository_id: repositoryId, ...body }); + setRepoPolicy(res.policy); + } + setSaved(true); + setTimeout(() => setSaved(false), 2500); + } catch (e: any) { + setError(String(e?.message || e)); + } finally { + setSaving(false); + } + }; + + if (loading) { + return
; + } + + const effective = repoPolicy && repoPolicy.mode !== 'inherit' ? repoPolicy : orgDefault; + + return ( +
+
+

Auto Approve

+

Override this repository's risk-based approval policy.

+
+ + {error && {error}} + {readOnly && You need the owner role to edit this repository's policy.} + +
+ Effective: {effective ? effective.mode : 'off'} + {!effective && ' (no organization policy configured — visit Settings → Auto Approve)'} +
+

+ + 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. + + +

+ + + )} + {(!isEditMode && !existsGlobally) || (isEditMode && currentUserRole === 'super_admin') ? ( @@ -1421,6 +1440,10 @@ const UserForm: React.FC = () => {
)} + + {isEditMode && user && currentOrgId && ( + + )}
diff --git a/ui/src/components/UserManagement/UserIdentitiesEditor.tsx b/ui/src/components/UserManagement/UserIdentitiesEditor.tsx new file mode 100644 index 000000000..a1a3b31c0 --- /dev/null +++ b/ui/src/components/UserManagement/UserIdentitiesEditor.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Card, Input, Select, Spinner } from '../UIPrimitives'; +import { notify } from '../../utils/notify'; +import { + listUserIdentities, + addUserIdentity, + removeUserIdentity, + UserIdentity, +} from '../../api/riskPolicy'; + +const UserIdentitiesEditor: React.FC<{ orgId: number; userId: number }> = ({ orgId, userId }) => { + const [identities, setIdentities] = useState([]); + const [loading, setLoading] = useState(true); + const [kind, setKind] = useState<'email' | 'username'>('username'); + const [value, setValue] = useState(''); + const [busy, setBusy] = useState(false); + + const load = () => { + listUserIdentities(orgId, userId) + .then((res) => setIdentities(res.rows || [])) + .catch((e) => notify.error(String(e?.message || e))) + .finally(() => setLoading(false)); + }; + + useEffect(load, [orgId, userId]); + + const add = async () => { + if (!value.trim()) return; + setBusy(true); + try { + await addUserIdentity(orgId, userId, kind, value.trim()); + setValue(''); + await listUserIdentities(orgId, userId).then((res) => setIdentities(res.rows || [])); + notify.success('Identity added.'); + } catch (e: any) { + notify.error(String(e?.message || e)); + } finally { + setBusy(false); + } + }; + + const remove = async (id: number) => { + setBusy(true); + try { + await removeUserIdentity(orgId, userId, id); + setIdentities((prev) => prev.filter((i) => i.id !== id)); + notify.success('Identity removed.'); + } catch (e: any) { + notify.error(String(e?.message || e)); + } finally { + setBusy(false); + } + }; + + return ( + +

Alternative identities

+

+ Match this user's Git provider handles/emails to their account for auto-approve eligibility. +

+ + {loading ? ( + + ) : ( +
+ {identities.length === 0 &&

No identities yet.

} + {identities.map((i) => ( +
+ + {i.kind}: {i.value} + + +
+ ))} +
+ )} + +
+ setValue(e.target.value)} placeholder="e.g. octocat or user@example.com" /> + +
+
+ ); +}; + +export default UserIdentitiesEditor; diff --git a/ui/src/components/reviews/AutoApprovePanel.tsx b/ui/src/components/reviews/AutoApprovePanel.tsx new file mode 100644 index 000000000..5d58e0a66 --- /dev/null +++ b/ui/src/components/reviews/AutoApprovePanel.tsx @@ -0,0 +1,228 @@ +import React, { useEffect, useState } from 'react'; +import { Alert, Button, Card, Spinner } from '../UIPrimitives'; +import { getReviewPolicyLog, forceApproveReview, markReviewOutcome, PolicyLogResponse } from '../../api/riskPolicy'; +import { InfoHint } from '../AutoApprove/InfoHint'; +import { HELP } from '../AutoApprove/helpText'; + +const STAGE_LABEL: Record = { + eligibility: 'Eligibility', + static: 'Static rules', + risk: 'Risk score', + llm: 'LLM classification', + final: 'Final decision', +}; + +const STAGE_HINT: Record = { + eligibility: HELP.stageEligibility, + static: HELP.stageStatic, + risk: HELP.stageRisk, + llm: HELP.stageLLM, + final: HELP.stageFinal, +}; + +const verdictIcon = (v: string) => + v === 'pass' || v === 'approved' ? '✅' : v === 'fail' || v === 'blocked' ? '❌' : '⏭️'; + +// Every stage's detail blob carries more than the one-line reason already +// shown inline (risk's full feature-by-feature breakdown, llm's class + +// confidence, static's matched ruleset, etc.) — this renders whatever else +// is in it so "why" is fully inspectable, not just the summary line. +const StageDetail: React.FC<{ detail: Record }> = ({ detail }) => { + const features = Array.isArray(detail.features) ? (detail.features as { feature: string; value: unknown }[]) : null; + const entries = Object.entries(detail).filter(([k]) => k !== 'reason' && k !== 'features'); + if (!features && entries.length === 0) return null; + return ( +
+ {entries.map(([k, v]) => ( +
+ {k.replace(/_/g, ' ')}:{' '} + {typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v)} +
+ ))} + {features && features.length > 0 && ( + + + + + + + + + {features.map((f) => ( + + + + + ))} + +
FeatureValue
{f.feature}{typeof f.value === 'number' ? f.value.toFixed(3) : String(f.value)}
+ )} +
+ ); +}; + +const hasExpandableDetail = (detail: Record) => Object.keys(detail).some((k) => k !== 'reason'); + +const AutoApprovePanel: React.FC<{ reviewId: number }> = ({ reviewId }) => { + const [log, setLog] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [expanded, setExpanded] = useState>(new Set()); + + const [forceReason, setForceReason] = useState(''); + const [forceBusy, setForceBusy] = useState(false); + const [forceDone, setForceDone] = useState(false); + + const [outcomeType, setOutcomeType] = useState<'revert' | 'incident' | 'confirmed_safe'>('revert'); + const [outcomeReason, setOutcomeReason] = useState(''); + const [outcomeBusy, setOutcomeBusy] = useState(false); + const [outcomeDone, setOutcomeDone] = useState(false); + + const load = () => { + setLoading(true); + setError(''); + getReviewPolicyLog(reviewId) + .then(setLog) + .catch((e) => setError(String(e?.message || e))) + .finally(() => setLoading(false)); + }; + + useEffect(load, [reviewId]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return {error}; + } + + if (!log || !log.stages.length) { + return ( +
+

No policy evaluation for this review.

+
+ ); + } + + const forceApprove = async () => { + setForceBusy(true); + setError(''); + try { + await forceApproveReview(reviewId, forceReason || 'Manual override'); + setForceDone(true); + } catch (e: any) { + setError(String(e?.message || e)); + } finally { + setForceBusy(false); + } + }; + + const mark = async () => { + setOutcomeBusy(true); + setError(''); + try { + await markReviewOutcome(reviewId, outcomeType, outcomeReason); + setOutcomeDone(true); + } catch (e: any) { + setError(String(e?.message || e)); + } finally { + setOutcomeBusy(false); + } + }; + + return ( +
+ {error && {error}} + + +
+ {log.stages.map((st) => { + const canExpand = hasExpandableDetail(st.detail || {}); + const isOpen = expanded.has(st.stage); + return ( +
+
{ + if (!canExpand) return; + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(st.stage)) next.delete(st.stage); + else next.add(st.stage); + return next; + }); + }} + > + {verdictIcon(st.verdict)} + + {STAGE_LABEL[st.stage] || st.stage} + {STAGE_HINT[st.stage] && } + + + {String((st.detail as any)?.reason ?? '')} + + {canExpand && {isOpen ? '▾ hide details' : '▸ show details'}} +
+ {isOpen && } +
+ ); + })} +
+
+ + {log.blocked && ( + +

+ Force approve + +

+

+ Requires review level ≥ {log.force_approve_min_level} or owner/admin. Recorded in the audit log. +

+