diff --git a/.git-hooks-binary-allowlist b/.git-hooks-binary-allowlist index 82b1aab9c..de0c5be2b 100644 --- a/.git-hooks-binary-allowlist +++ b/.git-hooks-binary-allowlist @@ -71,3 +71,5 @@ assets/screenshots/2026-10-29/02-settings-sso-saml.png assets/screenshots/2026-10-29/03-login-sso-button.png assets/screenshots/2026-10-29/04-settings-sso-scim-baseline.png assets/screenshots/2026-10-29/05-settings-sso-reconciliation-scim-token.png + +ui/public/notification-icon.png diff --git a/Makefile b/Makefile index 036875566..9e11028a6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-prod run-review run-review-verbose test clean develop develop-reflex river-deps river-install river-migrate river-setup river-ui-install river-ui install-vl-convert db-flip version version-bump version-patch version-minor version-major version-bump-dirty version-patch-dirty version-minor-dirty version-major-dirty version-bump-dry version-patch-dry version-minor-dry version-major-dry build-versioned check-docker-deps update-docker-deps update-docker-deps-yes verify-docker-deps docker-build docker-build-push docker-build-dry docker-interactive docker-interactive-push docker-interactive-dry docker-build docker-build-push docker-build-versioned docker-build-push-versioned docker-build-dry docker-build-push-dry docker-context-setup ghcr-login docker-multiarch docker-multiarch-push docker-multiarch-dry docker-interactive-multiarch docker-interactive-multiarch-push cplrops vendor-prompts-encrypt vendor-prompts-build vendor-prompts-rebuild vendor-docker-build vendor-docker-build-dry vendor-docker-build-push vendor-docker-multiarch-dry vendor-docker-multiarch-push run-debug run-fast logrun api-with-migrations build-with-ui security-sbom security-sbom-cyclonedx security-sbom-spdx security-sbom-validate release-notes-init release-notes-check release-preflight release-gh niceurl niceurl2 run-api run-worker prep-dbctx +.PHONY: build build-prod run-review run-review-verbose test clean develop develop-reflex river-deps river-install river-migrate river-setup river-ui-install river-ui install-vl-convert db-flip version version-bump version-patch version-minor version-major version-bump-dirty version-patch-dirty version-minor-dirty version-major-dirty version-bump-dry version-patch-dry version-minor-dry version-major-dry build-versioned check-docker-deps update-docker-deps update-docker-deps-yes verify-docker-deps docker-build docker-build-push docker-build-dry docker-interactive docker-interactive-push docker-interactive-dry docker-build docker-build-push docker-build-versioned docker-build-push-versioned docker-build-dry docker-build-push-dry docker-context-setup ghcr-login docker-multiarch docker-multiarch-push docker-multiarch-dry docker-interactive-multiarch docker-interactive-multiarch-push cplrops vendor-prompts-encrypt vendor-prompts-build vendor-prompts-rebuild vendor-docker-build vendor-docker-build-dry vendor-docker-build-push vendor-docker-multiarch-dry vendor-docker-multiarch-push run-debug run-fast logrun api-with-migrations build-with-ui security-sbom security-sbom-cyclonedx security-sbom-spdx security-sbom-validate release-notes-init release-notes-check release-preflight release-gh niceurl niceurl2 run-api run-worker prep-dbctx db-dump .PHONY: upload-secrets download-secrets list-secrets-files legacy-secrets-clear generate-openapi sync-docs-sources check-docs-sources .PHONY: razorpay-webhook-ensure razorpay-webhook-ensure-dry razorpay-verify-plans razorpay-verify-plans-low-pricing .PHONY: raw-deploy raw-deploy-low-pricing raw-deploy-backend raw-deploy-backend-low-pricing build-staging-with-ui raw-deploy-staging stop-staging ensure-seed-demo-env @@ -631,6 +631,13 @@ db-flip: @echo "New DATABASE_URL in .env:" @grep "DATABASE_URL=" .env +# Regenerate db/schema.sql via dbmate dump, shimming pg_dump to run inside +# the livereview_pg Docker container so its version always matches the +# server (the host's own pg_dump package commonly lags behind) - see +# scripts/dbmate-dump.sh. +db-dump: + @./scripts/dbmate-dump.sh + # Build dbctx index from local .env DATABASE_URL and import terminology prep-dbctx: export PATH := $(HOME)/go/bin:$(PATH) prep-dbctx: diff --git a/config/osv-scanner.toml b/config/osv-scanner.toml index 9c9ea582c..efb80a5c0 100644 --- a/config/osv-scanner.toml +++ b/config/osv-scanner.toml @@ -17,3 +17,13 @@ reason = "Not applicable - this is about echo's middleware/proxy.go (Proxy()/Pro id = "GHSA-gcqx-w48m-w2h4" ignoreUntil = 2027-01-28 reason = "No patched release exists. deepmerge 4.3.1 is only pulled in by dev tooling (jest, fork-ts-checker-webpack-plugin); `npm ls deepmerge --omit=dev` is empty, so it never ships and never merges untrusted input. Re-check when a fixed version is published." + +[[ignoredVulns]] +id = "GHSA-3g3m-2fjr-6pw4" +ignoreUntil = 2027-01-31 +reason = "DoS-only (aborted glob walks keep doing filesystem I/O in the background); only fixed in glob 14.0.2+, no backport to the 9.x-13.x line this repo's overrides/transitive resolutions use. Every occurrence (ui/package-lock.json, extension/livereview/package-lock.json) is dev-only build/test tooling (beasties-webpack-plugin, jest, mocha, @vscode/test-cli) - `npm ls glob --omit=dev` is empty in both, so it never ships. Bumping to glob 14 means bumping every dependent (webpack plugins, jest, mocha) across a major version, which is a separate, larger change than this fix warrants. Re-check when re-evaluating the dev toolchain." + +[[ignoredVulns]] +id = "GHSA-g3hv-75wg-m249" +ignoreUntil = 2027-01-31 +reason = "DoS-only (globparts amplification); only fixed in minimatch 11.0.1+, no backport to the 3.x/9.x/10.x lines this repo's overrides pin (minimatch@3, @9, @10 in ui/package.json). Every occurrence (ui/package-lock.json, extension/livereview/package-lock.json) is dev-only build/test tooling (eslint, jest, webpack-obfuscator, beasties-webpack-plugin, @vscode/test-cli, npm-run-all) - `npm ls minimatch --omit=dev` is empty in both, so it never ships. Bumping to minimatch 11 means bumping every dependent across a major version, which is a separate, larger change than this fix warrants. Re-check when re-evaluating the dev toolchain." diff --git a/db/migrations/20261030134721_create_push_notifications.sql b/db/migrations/20261030134721_create_push_notifications.sql new file mode 100644 index 000000000..033238ba7 --- /dev/null +++ b/db/migrations/20261030134721_create_push_notifications.sql @@ -0,0 +1,35 @@ +-- migrate:up + +-- One row per browser/device a user has granted push permission on (a user +-- has no single device, so this is 1:many, not a column on users). +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth_key TEXT NOT NULL, + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id); + +-- A missing row means "both notification types enabled" (the agreed default), +-- so there is no backfill to do for existing users. +CREATE TABLE IF NOT EXISTS user_notification_preferences ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + notify_first_finding BOOLEAN NOT NULL DEFAULT TRUE, + notify_completion BOOLEAN NOT NULL DEFAULT TRUE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Claim-once marker so concurrent batch-completion goroutines (see +-- internal/batch/task_queue.go's worker pool) can't double-send the +-- "first finding available" push for the same review. +ALTER TABLE reviews ADD COLUMN IF NOT EXISTS first_finding_notified_at TIMESTAMPTZ; + +-- migrate:down +ALTER TABLE reviews DROP COLUMN IF EXISTS first_finding_notified_at; +DROP TABLE IF EXISTS user_notification_preferences; +DROP TABLE IF EXISTS push_subscriptions; diff --git a/db/migrations/20261031070300_add_ai_review_performance_prefs.sql b/db/migrations/20261031070300_add_ai_review_performance_prefs.sql new file mode 100644 index 000000000..625b79724 --- /dev/null +++ b/db/migrations/20261031070300_add_ai_review_performance_prefs.sql @@ -0,0 +1,26 @@ +-- migrate:up + +-- Optional per-org soft preferences for review concurrency/timeout. NULL +-- means "use the system default" (resolved at review-trigger time, see +-- internal/aiselection's review-performance resolver) - these are not hard +-- caps the org can set arbitrarily high; the resolved value is always +-- clamped to the system-wide ceiling in system_settings (row +-- 'ai_review_performance'), set by a super admin / self-hosted owner. +ALTER TABLE org_review_ai_settings + ADD COLUMN IF NOT EXISTS preferred_max_concurrent_batches INTEGER, + ADD COLUMN IF NOT EXISTS preferred_review_timeout_seconds INTEGER; + +ALTER TABLE org_review_ai_settings + ADD CONSTRAINT org_review_ai_settings_preferred_max_concurrent_batches_check + CHECK (preferred_max_concurrent_batches IS NULL OR preferred_max_concurrent_batches > 0), + ADD CONSTRAINT org_review_ai_settings_preferred_review_timeout_seconds_check + CHECK (preferred_review_timeout_seconds IS NULL OR preferred_review_timeout_seconds > 0); + +-- migrate:down +ALTER TABLE org_review_ai_settings + DROP CONSTRAINT IF EXISTS org_review_ai_settings_preferred_max_concurrent_batches_check, + DROP CONSTRAINT IF EXISTS org_review_ai_settings_preferred_review_timeout_seconds_check; + +ALTER TABLE org_review_ai_settings + DROP COLUMN IF EXISTS preferred_max_concurrent_batches, + DROP COLUMN IF EXISTS preferred_review_timeout_seconds; diff --git a/db/migrations/20261031120000_add_review_commits_message.sql b/db/migrations/20261031120000_add_review_commits_message.sql new file mode 100644 index 000000000..b11dde0c8 --- /dev/null +++ b/db/migrations/20261031120000_add_review_commits_message.sql @@ -0,0 +1,10 @@ +-- migrate:up + +-- Commit message, captured best-effort from whichever provider API call already +-- fetched the commit SHA (GitHub/GitLab PR-commits endpoints both return it). Null +-- for older rows and for sources that never had one available (e.g. local staged +-- commits recorded before a message was ever sent). +ALTER TABLE review_commits ADD COLUMN IF NOT EXISTS message TEXT; + +-- migrate:down +ALTER TABLE review_commits DROP COLUMN IF EXISTS message; diff --git a/db/migrations/20261031140000_reset_risk_model_seed_for_feature_rescale.sql b/db/migrations/20261031140000_reset_risk_model_seed_for_feature_rescale.sql new file mode 100644 index 000000000..6ea7a4154 --- /dev/null +++ b/db/migrations/20261031140000_reset_risk_model_seed_for_feature_rescale.sql @@ -0,0 +1,33 @@ +-- migrate:up + +-- riskscore.Prior() changed: every structurally-unbounded count/sum feature +-- (br_sum, br_high_count, critical_count, warning_count, security_count, and +-- the 9 taxonomy counts) is now log1p-compressed before scoring, with weights +-- retuned to match. The old br_sum weight assumed a 0-100 range, but br_sum is +-- actually a raw, unbounded SUM(combined) across every blast-radius hunk in a +-- diff (real production values ran 1700-4840), which saturated the sigmoid +-- past the point float64 can represent 1+exp(-z) as anything but exactly 1.0 +-- - confirmed in production, where an org's entire risk-score history was +-- pinned to exactly 1.0 for every review. See internal/riskscore/model.go's +-- Prior() doc comment and internal/riskscore/saturation_test.go for the full +-- investigation and regression coverage. +-- +-- EnsureSeedModel (internal/riskscore/store.go) only inserts the shared seed +-- row (org_id IS NULL, update_kind = 'seed') if one doesn't already exist - it +-- never updates one in place. At the time this migration was written, that +-- table held exactly one row in production, and no org had ever trained a +-- custom model (training_sample_count was 0 everywhere) - so there is no +-- fitted-model data to preserve or migrate. Deleting the stale row lets +-- EnsureSeedModel's existing idempotent bootstrap logic reinsert it from the +-- corrected Prior() the next time any review is scored, with no new code path +-- needed. +DELETE FROM risk_model_versions WHERE org_id IS NULL AND update_kind = 'seed'; + +-- migrate:down + +-- Data-only reset; the prior seed weights are not reconstructed here (see git +-- history for the exact values this migration replaced). EnsureSeedModel will +-- reinsert a seed row from whatever Prior() the running code defines, same as +-- after any fresh deploy - there is no way to "undo" this short of also +-- reverting the Prior()/ExtractFeatures code changes. +DELETE FROM risk_model_versions WHERE org_id IS NULL AND update_kind = 'seed'; diff --git a/db/schema.sql b/db/schema.sql index 294a1b7d7..c21b66fe6 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,7 +1,7 @@ \restrict dbmate -- Dumped from database version 18.6 (Debian 18.6-1.pgdg13+2) --- Dumped by pg_dump version 18.6 (Debian 18.6-1.pgdg13+2) +-- Dumped by pg_dump version 18.6 (Ubuntu 18.6-1.pgdg24.04+2) SET statement_timeout = 0; SET lock_timeout = 0; @@ -2066,7 +2066,11 @@ CREATE TABLE public.org_review_ai_settings ( helper_mode character varying(32) DEFAULT 'concise_then_expand'::character varying NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT org_review_ai_settings_helper_mode_check CHECK (((helper_mode)::text = ANY (ARRAY[('concise_then_expand'::character varying)::text, ('polish_only'::character varying)::text]))) + preferred_max_concurrent_batches integer, + preferred_review_timeout_seconds integer, + CONSTRAINT org_review_ai_settings_helper_mode_check CHECK (((helper_mode)::text = ANY (ARRAY[('concise_then_expand'::character varying)::text, ('polish_only'::character varying)::text]))), + CONSTRAINT org_review_ai_settings_preferred_max_concurrent_batches_check CHECK (((preferred_max_concurrent_batches IS NULL) OR (preferred_max_concurrent_batches > 0))), + CONSTRAINT org_review_ai_settings_preferred_review_timeout_seconds_check CHECK (((preferred_review_timeout_seconds IS NULL) OR (preferred_review_timeout_seconds > 0))) ); @@ -2467,6 +2471,41 @@ CREATE SEQUENCE public.pull_requests_id_seq ALTER SEQUENCE public.pull_requests_id_seq OWNED BY public.pull_requests.id; +-- +-- Name: push_subscriptions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.push_subscriptions ( + id bigint NOT NULL, + user_id bigint NOT NULL, + endpoint text NOT NULL, + p256dh text NOT NULL, + auth_key text NOT NULL, + user_agent text, + created_at timestamp with time zone DEFAULT now() NOT NULL, + last_used_at timestamp with time zone +); + + +-- +-- Name: push_subscriptions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.push_subscriptions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: push_subscriptions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.push_subscriptions_id_seq OWNED BY public.push_subscriptions.id; + + -- -- Name: quota_batch_settlements; Type: TABLE; Schema: public; Owner: - -- @@ -2772,6 +2811,7 @@ CREATE TABLE public.review_commits ( ref character varying(160) NOT NULL, ref_type text DEFAULT 'commit'::text NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, + message text, CONSTRAINT review_commits_ref_type_check CHECK ((ref_type = ANY (ARRAY['commit'::text, 'range'::text]))) ); @@ -3032,6 +3072,7 @@ CREATE TABLE public.reviews ( author_username text, friendly_name text, pull_request_id bigint, + first_finding_notified_at timestamp with time zone, CONSTRAINT reviews_status_check CHECK (((status)::text = ANY (ARRAY[('created'::character varying)::text, ('in_progress'::character varying)::text, ('completed'::character varying)::text, ('failed'::character varying)::text]))) ); @@ -4023,6 +4064,18 @@ CREATE SEQUENCE public.user_management_audit_id_seq ALTER SEQUENCE public.user_management_audit_id_seq OWNED BY public.user_management_audit.id; +-- +-- Name: user_notification_preferences; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_notification_preferences ( + user_id bigint NOT NULL, + notify_first_finding boolean DEFAULT true NOT NULL, + notify_completion boolean DEFAULT true NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + -- -- Name: user_role_history; Type: TABLE; Schema: public; Owner: - -- @@ -4095,7 +4148,8 @@ CREATE TABLE public.users ( onboarding_api_key text, last_cli_used_at timestamp with time zone, default_org_id bigint, - review_level integer DEFAULT 1 NOT NULL + review_level integer DEFAULT 1 NOT NULL, + time_zone text ); @@ -4484,6 +4538,13 @@ ALTER TABLE ONLY public.prompt_chunks ALTER COLUMN id SET DEFAULT nextval('publi ALTER TABLE ONLY public.pull_requests ALTER COLUMN id SET DEFAULT nextval('public.pull_requests_id_seq'::regclass); +-- +-- Name: push_subscriptions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.push_subscriptions ALTER COLUMN id SET DEFAULT nextval('public.push_subscriptions_id_seq'::regclass); + + -- -- Name: quota_batch_settlements id; Type: DEFAULT; Schema: public; Owner: - -- @@ -5220,6 +5281,22 @@ ALTER TABLE ONLY public.pull_requests ADD CONSTRAINT pull_requests_pkey PRIMARY KEY (id); +-- +-- Name: push_subscriptions push_subscriptions_endpoint_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.push_subscriptions + ADD CONSTRAINT push_subscriptions_endpoint_key UNIQUE (endpoint); + + +-- +-- Name: push_subscriptions push_subscriptions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.push_subscriptions + ADD CONSTRAINT push_subscriptions_pkey PRIMARY KEY (id); + + -- -- Name: quota_batch_settlements quota_batch_settlements_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -5692,6 +5769,14 @@ ALTER TABLE ONLY public.user_management_audit ADD CONSTRAINT user_management_audit_pkey PRIMARY KEY (id); +-- +-- Name: user_notification_preferences user_notification_preferences_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_notification_preferences + ADD CONSTRAINT user_notification_preferences_pkey PRIMARY KEY (user_id); + + -- -- Name: user_role_history user_role_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -7412,6 +7497,13 @@ CREATE INDEX idx_pull_requests_org_state ON public.pull_requests USING btree (or CREATE INDEX idx_pull_requests_repository_state ON public.pull_requests USING btree (repository_id, state); +-- +-- Name: idx_push_subscriptions_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_push_subscriptions_user_id ON public.push_subscriptions USING btree (user_id); + + -- -- Name: idx_quota_batch_settlements_org_idempotency; Type: INDEX; Schema: public; Owner: - -- @@ -9768,6 +9860,14 @@ ALTER TABLE ONLY public.pull_requests ADD CONSTRAINT pull_requests_repository_id_fkey FOREIGN KEY (repository_id) REFERENCES public.repositories(id) ON DELETE CASCADE; +-- +-- Name: push_subscriptions push_subscriptions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.push_subscriptions + ADD CONSTRAINT push_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + -- -- Name: quota_batch_settlements quota_batch_settlements_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -10256,6 +10356,14 @@ ALTER TABLE ONLY public.user_management_audit ADD CONSTRAINT user_management_audit_target_user_id_fkey FOREIGN KEY (target_user_id) REFERENCES public.users(id) ON DELETE CASCADE; +-- +-- Name: user_notification_preferences user_notification_preferences_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_notification_preferences + ADD CONSTRAINT user_notification_preferences_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + -- -- Name: user_role_history user_role_history_changed_by_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -10490,7 +10598,12 @@ INSERT INTO public.schema_migrations (version) VALUES ('20261025120100'), ('20261025130000'), ('20261029104047'), + ('20261029120000'), ('20261029134500'), ('20261029150000'), ('20261029150100'), - ('20261029150200'); + ('20261029150200'), + ('20261030134721'), + ('20261031070300'), + ('20261031120000'), + ('20261031140000'); diff --git a/docs/audit-events.md b/docs/audit-events.md index 0d02adbbd..55b2bf9e3 100644 --- a/docs/audit-events.md +++ b/docs/audit-events.md @@ -135,7 +135,10 @@ Generated from `internal/auditlog/catalog.go`. | `user.create` | `user` | user | warning | User created | A user was added to the organization. | | `user.deactivate` | `user` | user | warning | User deactivated | A user was deactivated. | | `user.invite` | `user` | user | info | User invited | A user was invited. | +| `user.notification_preferences.update` | `user` | user | info | Notification preferences updated | A user changed their review push-notification preferences. | | `user.password.reset.force` | `user` | user | warning | Forced password reset | An owner required a user to reset their password. | +| `user.push_subscription.create` | `user` | push_subscription | info | Push subscription created | A user registered a browser for push notifications. | +| `user.push_subscription.delete` | `user` | push_subscription | info | Push subscription removed | A user removed a browser's push notification registration. | | `user.reactivate` | `user` | user | warning | User reactivated | A user was reactivated. | | `user.role.changed` | `user` | user | warning | Role changed | A user's organization role was changed. | | `user.transfer` | `user` | user | critical | User transferred | A user was moved to another organization. | diff --git a/go.mod b/go.mod index cdf4b81be..2619cd34f 100644 --- a/go.mod +++ b/go.mod @@ -70,6 +70,7 @@ require ( ) require ( + github.com/SherClockHolmes/webpush-go v1.4.0 github.com/cdipaolo/goml v0.0.0-20220715001353-00e0c845ae1c github.com/coreos/go-oidc/v3 v3.21.0 github.com/crewjam/saml v0.5.1 diff --git a/go.sum b/go.sum index d1382e672..a93e88177 100644 --- a/go.sum +++ b/go.sum @@ -89,6 +89,8 @@ github.com/RijulTP/echo-mcp v0.0.0-20260521161450-0737ad06cc38 h1:sLTIMZZTxKOD8/ github.com/RijulTP/echo-mcp v0.0.0-20260521161450-0737ad06cc38/go.mod h1:H0pmdFxDONfEUdoq7BjzeprwlyzT0kSSE+XThculHE8= github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= @@ -278,6 +280,7 @@ github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncV github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -304,6 +307,7 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -672,6 +676,10 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -705,6 +713,10 @@ golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -722,7 +734,12 @@ golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -739,6 +756,11 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -761,12 +783,22 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -774,6 +806,11 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -805,6 +842,9 @@ golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/ai/gemini/batch.go b/internal/ai/gemini/batch.go index da94b74a0..c680b7fa6 100644 --- a/internal/ai/gemini/batch.go +++ b/internal/ai/gemini/batch.go @@ -3,10 +3,10 @@ package gemini import ( "context" "fmt" - "runtime" "strings" "github.com/livereview/internal/batch" + "github.com/livereview/internal/llm/adaptivelimiter" "github.com/livereview/internal/prompts" "github.com/livereview/pkg/models" ) @@ -42,7 +42,7 @@ func (p *GeminiProvider) ReviewCodeWithBatching( batchInput := batchProcessor.BatchInputs(input) // Step 4: Process batches with LLM using task queue - taskQueue := batch.NewTaskQueue(runtime.NumCPU()) + taskQueue := batch.NewTaskQueue(batch.DefaultMaxConcurrentBatches) // Use the batch processor's configuration for the task queue if available if batchProcessor.TaskQueueConfig.MaxWorkers > 0 { @@ -52,6 +52,14 @@ func (p *GeminiProvider) ReviewCodeWithBatching( batchProcessor.TaskQueueConfig.MaxRetries) } + // Tier 2: gate every batch's LLM call through the process-wide, self-tuning + // limiter for this connector (see internal/llm/adaptivelimiter) - shared + // across every concurrently-running review using this same Gemini connector. + connectorLimiter := adaptivelimiter.ForConnector( + "gemini", + adaptivelimiter.Config{Floor: 2, Ceiling: 64, Initial: batch.DefaultMaxConcurrentBatches}, + ) + // Create tasks for each batch for i, batchDiffs := range batchInput.Batches { batchID := fmt.Sprintf("batch-%d", i+1) @@ -65,6 +73,8 @@ func (p *GeminiProvider) ReviewCodeWithBatching( task := batch.NewBatchTask(batchID, batchDiffs, processor) task.SetBatchNumber(i + 1) task.SetLogger(batchProcessor.Logger) + task.SetLimiter(connectorLimiter) + task.SetProviderType("gemini") taskQueue.AddTask(task) // Display batch details @@ -80,8 +90,14 @@ func (p *GeminiProvider) ReviewCodeWithBatching( results := taskQueue.ProcessAll(ctx) fmt.Printf("===== āœ… BATCH PROCESSING COMPLETE =====\n\n") - // Collect batch results - batchResults := make([]*batch.BatchResult, len(batchInput.Batches)) + // Collect batch results - TOLERATE PARTIAL FAILURES. One bad batch (a + // transient LLM error, a parse failure, ...) must not discard every other + // batch's already-good comments and fail the whole review; only abort if + // every batch failed. Mirrors internal/ai/langchain/provider.go's + // ReviewCodeWithBatching, which already does this correctly. + var batchResults []*batch.BatchResult + var failedBatches []string + var partialFailure bool totalComments := 0 fmt.Printf("===== šŸ“‹ BATCH RESULTS SUMMARY =====\n") @@ -90,15 +106,22 @@ func (p *GeminiProvider) ReviewCodeWithBatching( taskResult, ok := results[batchID] if !ok || taskResult.Error != nil { + failedBatches = append(failedBatches, batchID) + partialFailure = true if !ok { - return nil, fmt.Errorf("batch %s not found in results", batchID) + fmt.Printf("[PARTIAL FAILURE] Batch %s not found in results, skipping\n", batchID) + } else { + fmt.Printf("[PARTIAL FAILURE] Batch %s failed: %v, skipping\n", batchID, taskResult.Error) } - return nil, fmt.Errorf("error processing batch %s: %v", batchID, taskResult.Error) + continue } batchResult, ok := taskResult.Result.(*batch.BatchResult) if !ok { - return nil, fmt.Errorf("invalid result type for batch %s", batchID) + failedBatches = append(failedBatches, batchID) + partialFailure = true + fmt.Printf("[PARTIAL FAILURE] Batch %s: invalid result type, skipping\n", batchID) + continue } // Display batch result details @@ -109,11 +132,16 @@ func (p *GeminiProvider) ReviewCodeWithBatching( fmt.Println() totalComments += len(batchResult.Comments) - batchResults[i] = batchResult + batchResults = append(batchResults, batchResult) } fmt.Printf("✨ Total comments generated: %d\n", totalComments) fmt.Printf("===================================\n\n") + // If NO batches succeeded, fail the entire review - there's nothing to aggregate. + if len(batchResults) == 0 { + return nil, fmt.Errorf("all %d batches failed: %v", len(batchInput.Batches), failedBatches) + } + // Step 5: Aggregate and combine outputs fmt.Printf("===== šŸ”„ AGGREGATING RESULTS =====\n") result, err := batchProcessor.AggregateAndCombineOutputs(ctx, p.llm, batchResults) @@ -122,6 +150,14 @@ func (p *GeminiProvider) ReviewCodeWithBatching( return nil, err } + if partialFailure { + warningMsg := fmt.Sprintf("\n\n---\n\nāš ļø **Partial Review Results**\n\nThis review completed with partial results: **%d out of %d batches** processed successfully.\n\n**Failed batches:** %v\n\nThe comments and analysis shown are based only on the files that were successfully reviewed. Some files may have been skipped due to batch processing errors.", + len(batchResults), len(batchInput.Batches), failedBatches) + result.Summary = result.Summary + warningMsg + fmt.Printf("\n[PARTIAL SUCCESS] Review completed with %d/%d successful batches (failed: %v)\n", + len(batchResults), len(batchInput.Batches), failedBatches) + } + // Filter out any useless comments and enhance the meaningful ones result.Comments = filterAndEnhanceComments(result.Comments) diff --git a/internal/ai/gemini/tests/batch_partial_failure_test.go b/internal/ai/gemini/tests/batch_partial_failure_test.go new file mode 100644 index 000000000..c2de70a9c --- /dev/null +++ b/internal/ai/gemini/tests/batch_partial_failure_test.go @@ -0,0 +1,169 @@ +package gemini_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/livereview/internal/ai/gemini" + "github.com/livereview/internal/batch" + "github.com/livereview/pkg/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tmc/langchaingo/llms" +) + +// stubSummaryLLM is a minimal llms.Model: AggregateAndCombineOutputs always +// makes one extra LLM call (via llms.GenerateFromSinglePrompt) to synthesize +// the overall review summary from per-file technical summaries, independent +// of the per-batch REST calls the gemini.APIURLFormat mock server below +// handles. This stub just satisfies that call deterministically. +type stubSummaryLLM struct{} + +func (stubSummaryLLM) GenerateContent(ctx context.Context, messages []llms.MessageContent, options ...llms.CallOption) (*llms.ContentResponse, error) { + return &llms.ContentResponse{Choices: []*llms.ContentChoice{{Content: "stub summary"}}}, nil +} + +func (stubSummaryLLM) Call(ctx context.Context, prompt string, options ...llms.CallOption) (string, error) { + return "stub summary", nil +} + +const geminiBatchOKBody = `{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "{\n \"summary\": \"Looks fine\",\n \"filesChanged\": [\"a.go\"],\n \"comments\": [{\"filePath\": \"a.go\", \"lineNumber\": 1, \"content\": \"nit: rename this\", \"severity\": \"info\", \"confidence\": \"high\", \"type\": \"style\", \"category\": \"review\", \"subcategory\": \"\", \"isInternal\": false}]\n}" + } + ] + }, + "finishReason": "STOP" + } + ] +}` + +// twoBatchDiffs builds two CodeDiffs whose combined token count exceeds a +// small MaxBatchTokens but whose individual hunks don't, forcing +// BatchInputs to split them into exactly two single-diff batches. +func twoBatchDiffs() []*models.CodeDiff { + return []*models.CodeDiff{ + { + FilePath: "a.go", + Hunks: []models.DiffHunk{{Content: "line one two three"}}, + }, + { + FilePath: "b.go", + Hunks: []models.DiffHunk{{Content: "line four five six"}}, + }, + } +} + +func newTolerantBatchProcessor() *batch.BatchProcessor { + bp := batch.DefaultBatchProcessor() + bp.MaxBatchTokens = 8 // small enough that the two diffs above land in separate batches + return bp +} + +// readAndRestoreBody reads an HTTP request body for content inspection. The +// mock server below never reads it again afterward, so there's nothing to +// restore in practice - name kept descriptive of what it does either way. +func readAndRestoreBody(r *http.Request) string { + buf := make([]byte, 0, 4096) + chunk := make([]byte, 4096) + for { + n, err := r.Body.Read(chunk) + buf = append(buf, chunk[:n]...) + if err != nil { + break + } + } + return string(buf) +} + +// TestReviewCodeWithBatchingTolerateOneFailedBatch is a regression test for +// the "Findings disappear when one batch fails" bug: a single bad batch must +// not discard every other batch's already-good comments and fail the whole +// review - only abort if every batch fails. Mirrors +// internal/ai/langchain/provider.go's ReviewCodeWithBatching, which already +// tolerates partial failures; this is the equivalent for the Gemini path, +// which previously hard-failed on the first bad batch. +func TestReviewCodeWithBatchingTolerateOneFailedBatch(t *testing.T) { + // Fail every request for the batch containing "a.go" (even across the + // task queue's own retries - internal/batch/task_queue.go retries up to + // 3 times by default), always succeed for "b.go"'s batch. Batches run + // concurrently across worker goroutines, so request ORDER isn't + // deterministic - keying off request content is. + var aCalls, bCalls int32 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := readAndRestoreBody(r) + if strings.Contains(body, "a.go") { + atomic.AddInt32(&aCalls, 1) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": "simulated transient failure"}`)) + return + } + atomic.AddInt32(&bCalls, 1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(geminiBatchOKBody)) + })) + defer mockServer.Close() + + provider := &gemini.GeminiProvider{ + TestableFields: gemini.TestableFields{ + APIKey: "test-key", + Model: "gemini-pro", + HTTPClient: &http.Client{}, + }, + } + provider.SetLLM(stubSummaryLLM{}) + + originalAPIURL := gemini.APIURLFormat + gemini.APIURLFormat = mockServer.URL + "/%s:generateContent?key=%s" + defer func() { gemini.APIURLFormat = originalAPIURL }() + + result, err := provider.ReviewCodeWithBatching(context.Background(), twoBatchDiffs(), newTolerantBatchProcessor()) + + require.NoError(t, err, "one failed batch out of two must not fail the whole review") + require.NotNil(t, result) + assert.Positive(t, atomic.LoadInt32(&aCalls), "the failing batch should have been attempted") + assert.Positive(t, atomic.LoadInt32(&bCalls), "the succeeding batch should have been attempted") + assert.NotEmpty(t, result.Comments, "the successful batch's comments must still be present") + assert.True(t, strings.Contains(result.Summary, "Partial Review Results"), + "summary should warn that this was a partial result, got: %q", result.Summary) +} + +// TestReviewCodeWithBatchingFailsWhenAllBatchesFail confirms the tolerance +// above doesn't swallow a total failure: if every batch fails, the review +// must still correctly return an error, not a silently-empty success. +func TestReviewCodeWithBatchingFailsWhenAllBatchesFail(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": "simulated failure"}`)) + })) + defer mockServer.Close() + + provider := &gemini.GeminiProvider{ + TestableFields: gemini.TestableFields{ + APIKey: "test-key", + Model: "gemini-pro", + HTTPClient: &http.Client{}, + }, + } + provider.SetLLM(stubSummaryLLM{}) + + originalAPIURL := gemini.APIURLFormat + gemini.APIURLFormat = mockServer.URL + "/%s:generateContent?key=%s" + defer func() { gemini.APIURLFormat = originalAPIURL }() + + result, err := provider.ReviewCodeWithBatching(context.Background(), twoBatchDiffs(), newTolerantBatchProcessor()) + + require.Error(t, err, "a review where every batch failed must still return an error") + assert.Nil(t, result) + assert.True(t, strings.Contains(err.Error(), "all") && strings.Contains(err.Error(), "batches failed"), + "error should say every batch failed, got: %v", err) +} diff --git a/internal/ai/langchain/provider.go b/internal/ai/langchain/provider.go index 89402d7d3..efcde36ed 100644 --- a/internal/ai/langchain/provider.go +++ b/internal/ai/langchain/provider.go @@ -28,6 +28,7 @@ import ( "github.com/livereview/internal/aidefault" "github.com/livereview/internal/batch" + "github.com/livereview/internal/llm/adaptivelimiter" "github.com/livereview/internal/logging" "github.com/livereview/internal/prompts" vendorpack "github.com/livereview/internal/prompts/vendor" @@ -303,7 +304,7 @@ func (p *LangchainProvider) effectiveTemperature() float64 { // buildCallOptions creates a slice of CallOption with the provider's default model configuration. func (p *LangchainProvider) buildCallOptions(extra ...llms.CallOption) []llms.CallOption { var opts []llms.CallOption - + isGemini := false switch strings.ToLower(p.providerType) { case "gemini", "googleai", "gemini-enterprise", "vertex": @@ -1001,13 +1002,23 @@ func (p *LangchainProvider) reviewCodeBatchFormatted(ctx context.Context, diffs fmt.Printf("[LANGCHAIN DEBUG] Provider type: %s, Model: %s, Base URL: %s\n", p.providerType, p.getLoggedModelName(), p.baseURL) - // Create a timeout context - // For Ollama, some setups (reverse proxies) buffer SSE and models can be slow to start. - // Use a longer, configurable timeout to avoid premature context deadlines. - streamTimeout := 5 * time.Minute + // Create a timeout context. Previously 5 minutes (8 for Ollama) - cut to + // 90s so one slow batch can't stall the whole review: with up to + // batch.DefaultMaxConcurrentBatches batches now running concurrently (see + // internal/batch/config.go), a single batch sitting on a 5-8 minute call + // no longer just delays itself, it risks starving the retry budget before + // review.Config.ReviewTimeout (4 minutes) fires. 90s is a real tradeoff, + // not a free win: a genuinely-slow-but-otherwise-fine response is now cut + // off more readily. Override via LIVEREVIEW_AI_STREAM_TIMEOUT_SECONDS (all + // providers) or LIVEREVIEW_OLLAMA_STREAM_TIMEOUT_SECONDS (Ollama only, + // takes precedence for Ollama) for self-hosted setups with slower models. + streamTimeout := 90 * time.Second + if v := os.Getenv("LIVEREVIEW_AI_STREAM_TIMEOUT_SECONDS"); v != "" { + if secs, err := strconv.Atoi(v); err == nil && secs > 0 { + streamTimeout = time.Duration(secs) * time.Second + } + } if strings.EqualFold(p.providerType, "ollama") { - // Default: 8 minutes for streaming - very generous for large models with slow startup - streamTimeout = 8 * time.Minute // Allow override via env var LIVEREVIEW_OLLAMA_STREAM_TIMEOUT_SECONDS if v := os.Getenv("LIVEREVIEW_OLLAMA_STREAM_TIMEOUT_SECONDS"); v != "" { if secs, err := strconv.Atoi(v); err == nil && secs > 0 { @@ -1254,8 +1265,8 @@ func (p *LangchainProvider) reviewCodeBatchFormatted(ctx context.Context, diffs // Fallback for Ollama: retry once without streaming (some reverse proxies buffer/block streams) if strings.EqualFold(p.providerType, "ollama") { fmt.Printf("[LANGCHAIN FALLBACK] Retrying Ollama request without streaming...\n") - // Use a very generous, configurable non-streaming timeout - fbTimeout := 8 * time.Minute + // Cut from 8 minutes to 90s, same rationale as streamTimeout above. + fbTimeout := 90 * time.Second if v := os.Getenv("LIVEREVIEW_OLLAMA_REQUEST_TIMEOUT_SECONDS"); v != "" { if secs, err := strconv.Atoi(v); err == nil && secs > 0 { fbTimeout = time.Duration(secs) * time.Second @@ -1405,13 +1416,23 @@ func (p *LangchainProvider) ReviewCodeWithBatching(ctx context.Context, diffs [] batchInput := batchProcessor.BatchInputs(input) // Process batches using task queue (following Gemini provider pattern) - taskQueue := batch.NewTaskQueue(4) // Use 4 workers by default + taskQueue := batch.NewTaskQueue(batch.DefaultMaxConcurrentBatches) // Use the batch processor's configuration for the task queue if available if batchProcessor.TaskQueueConfig.MaxWorkers > 0 { taskQueue = batch.ConfigureTaskQueue(batchProcessor.TaskQueueConfig) } + // Tier 2: gate every batch's LLM call through the process-wide, self-tuning + // limiter for this AI connector (shared across every concurrently-running + // review that uses the same connector - a per-review semaphore alone can't + // protect a shared provider API key). Keyed by provider type + provider + // name, the closest thing to a connector identity available here. + connectorLimiter := adaptivelimiter.ForConnector( + p.providerType+":"+p.providerName, + adaptivelimiter.Config{Floor: 2, Ceiling: 64, Initial: batch.DefaultMaxConcurrentBatches}, + ) + // Create tasks for each batch for i, batchDiffs := range batchInput.Batches { batchID := fmt.Sprintf("batch-%d", i+1) @@ -1426,6 +1447,8 @@ func (p *LangchainProvider) ReviewCodeWithBatching(ctx context.Context, diffs [] task := batch.NewBatchTask(batchID, batchDiffs, processor) task.SetBatchNumber(i + 1) task.SetLogger(batchProcessor.Logger) + task.SetLimiter(connectorLimiter) + task.SetProviderType(p.providerType) taskQueue.AddTask(task) } @@ -1447,12 +1470,20 @@ func (p *LangchainProvider) ReviewCodeWithBatching(ctx context.Context, diffs [] failedBatches = append(failedBatches, batchID) partialFailure = true + var batchErr error + if taskResult != nil { + batchErr = taskResult.Error + } else { + batchErr = fmt.Errorf("batch not found in results") + } + if p.logger != nil { - errorMsg := "batch not found in results" - if taskResult != nil && taskResult.Error != nil { - errorMsg = taskResult.Error.Error() - } - p.logger.Log("āš ļø Batch %s failed: %v (continuing with other batches)", batchID, errorMsg) + p.logger.Log("āš ļø Batch %s failed: %v (continuing with other batches)", batchID, batchErr) + // Emit an explicit terminal failure event - distinct from a + // per-attempt retry log - so the frontend can render this + // batch as genuinely Failed instead of falling back to a + // stale fileCount and rendering fake success. + p.logger.EmitBatchFailed(batchID, batchErr) } // Log to console as well @@ -1468,8 +1499,10 @@ func (p *LangchainProvider) ReviewCodeWithBatching(ctx context.Context, diffs [] if !ok { failedBatches = append(failedBatches, batchID) partialFailure = true + invalidTypeErr := fmt.Errorf("invalid result type") if p.logger != nil { p.logger.Log("āš ļø Batch %s: invalid result type, skipping", batchID) + p.logger.EmitBatchFailed(batchID, invalidTypeErr) } fmt.Printf("[PARTIAL FAILURE] Batch %s: invalid result type, skipping\n", batchID) continue @@ -1610,16 +1643,16 @@ func (p *LangchainProvider) parseResponse(response string, diffs []models.CodeDi } return &ParsedResult{ - TechnicalSummaries: summaries, - Comments: comments, - DiffClassification: resp.DiffClassification, + TechnicalSummaries: summaries, + Comments: comments, + DiffClassification: resp.DiffClassification, }, nil } type ParsedResult struct { - TechnicalSummaries []prompts.TechnicalSummary - Comments []*models.ReviewComment - DiffClassification *models.DiffClassification + 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/aiselection/aiselection.go b/internal/aiselection/aiselection.go index 408899be1..ff62706fd 100644 --- a/internal/aiselection/aiselection.go +++ b/internal/aiselection/aiselection.go @@ -12,6 +12,7 @@ import ( "github.com/livereview/internal/aidefault" "github.com/livereview/internal/license" "github.com/livereview/internal/review" + "github.com/livereview/internal/reviewperf" storageaiconnectors "github.com/livereview/storage/aiconnectors" ) @@ -21,6 +22,13 @@ type Selection struct { Helper *review.AIConfig HelperEnabled bool HelperMode string + + // EffectiveMaxConcurrentBatches and EffectiveReviewTimeoutSeconds are the + // org's optional soft preference resolved against the instance-wide + // ceiling (see internal/reviewperf) - always safe to use directly when + // building a review.Config, never requiring the caller to re-clamp. + EffectiveMaxConcurrentBatches int + EffectiveReviewTimeoutSeconds int } // GetReviewAISelection resolves the org's Leader (and, if enabled, Helper) AI connector, same rules as manual/webhook reviews. @@ -42,10 +50,17 @@ func GetReviewAISelection(ctx context.Context, db *sql.DB, orgID int64, planCode return nil, fmt.Errorf("failed to get review AI settings: %w", err) } + perfSettings, err := reviewperf.Load(ctx, db) + if err != nil { + return nil, fmt.Errorf("failed to load review performance settings: %w", err) + } + selection := &Selection{ - Leader: leaderConfig, - HelperEnabled: settings.HelperEnabled, - HelperMode: settings.HelperMode, + Leader: leaderConfig, + HelperEnabled: settings.HelperEnabled, + HelperMode: settings.HelperMode, + EffectiveMaxConcurrentBatches: perfSettings.ResolveMaxConcurrentBatches(settings.PreferredMaxConcurrentBatches), + EffectiveReviewTimeoutSeconds: perfSettings.ResolveReviewTimeoutSeconds(settings.PreferredReviewTimeoutSeconds), } if !settings.HelperEnabled { diff --git a/internal/api/ai_review_performance_settings.go b/internal/api/ai_review_performance_settings.go new file mode 100644 index 000000000..524bad786 --- /dev/null +++ b/internal/api/ai_review_performance_settings.go @@ -0,0 +1,41 @@ +package api + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/livereview/internal/reviewperf" + "github.com/rs/zerolog/log" +) + +// GetAIReviewPerformanceSettings fetches the instance-wide review +// concurrency/timeout ceiling from system_settings. Absent config (no row +// yet) returns reviewperf.DefaultSettings(), matching reviewperf.Load's own +// zero-config behavior - mirrors GetStorageSettings in storage_settings.go. +func (s *Server) GetAIReviewPerformanceSettings(c echo.Context) error { + settings, err := reviewperf.Load(c.Request().Context(), s.db) + if err != nil { + log.Error().Err(err).Msg("Failed to fetch AI review performance settings") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch AI review performance settings"}) + } + return c.JSON(http.StatusOK, settings) +} + +// UpdateAIReviewPerformanceSettings saves the instance-wide ceiling. +// internal/aiselection.GetReviewAISelection reads this fresh on every +// review (no caching), so a change here takes effect for the very next +// review - no restart required. +func (s *Server) UpdateAIReviewPerformanceSettings(c echo.Context) error { + var req reviewperf.Settings + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request payload"}) + } + if err := req.Validate(); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + if err := reviewperf.Save(c.Request().Context(), s.db, req); err != nil { + log.Error().Err(err).Msg("Failed to save AI review performance settings") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save AI review performance settings"}) + } + return c.JSON(http.StatusOK, map[string]string{"message": "AI review performance settings updated successfully"}) +} diff --git a/internal/api/ai_review_performance_settings_test.go b/internal/api/ai_review_performance_settings_test.go new file mode 100644 index 000000000..5ee3300d1 --- /dev/null +++ b/internal/api/ai_review_performance_settings_test.go @@ -0,0 +1,200 @@ +package api + +// Handler-level tests for the instance-wide AI review performance ceiling +// (GET/PUT /admin/settings/ai-review-performance) and its interaction with +// an org's own soft preference (GET/PUT /aiconnectors/settings) - the Phase +// 2/3 settings infrastructure for configurable worker count/timeout +// requested alongside the concurrency and Events-tab truth fixes. + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/labstack/echo/v4" + _ "github.com/lib/pq" + "github.com/livereview/internal/reviewperf" + storageaiconnectors "github.com/livereview/storage/aiconnectors" +) + +func perfSettingsTestDB(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 TestAIReviewPerformanceSettings_DefaultsWhenNoRowSaved(t *testing.T) { + db := perfSettingsTestDB(t) + // Clean slate - delete any row a prior run left behind. + if _, err := db.Exec(`DELETE FROM system_settings WHERE name = 'ai_review_performance'`); err != nil { + t.Fatalf("cleanup: %v", err) + } + + s := &Server{db: db} + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings/ai-review-performance", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := s.GetAIReviewPerformanceSettings(c); err != nil { + t.Fatalf("GetAIReviewPerformanceSettings: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var got reviewperf.Settings + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + want := reviewperf.DefaultSettings() + if got != want { + t.Fatalf("got %+v, want defaults %+v", got, want) + } +} + +func TestAIReviewPerformanceSettings_UpdateThenGetRoundTrips(t *testing.T) { + db := perfSettingsTestDB(t) + + s := &Server{db: db} + e := echo.New() + + newSettings := reviewperf.Settings{ + MaxConcurrentBatchesCeiling: 24, + MinReviewTimeoutSeconds: 90, + MaxReviewTimeoutSeconds: 300, + } + body, _ := json.Marshal(newSettings) + putReq := httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings/ai-review-performance", bytes.NewReader(body)) + putReq.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + putRec := httptest.NewRecorder() + putC := e.NewContext(putReq, putRec) + + if err := s.UpdateAIReviewPerformanceSettings(putC); err != nil { + t.Fatalf("UpdateAIReviewPerformanceSettings: %v", err) + } + if putRec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", putRec.Code, putRec.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings/ai-review-performance", nil) + getRec := httptest.NewRecorder() + getC := e.NewContext(getReq, getRec) + if err := s.GetAIReviewPerformanceSettings(getC); err != nil { + t.Fatalf("GetAIReviewPerformanceSettings: %v", err) + } + + var got reviewperf.Settings + if err := json.Unmarshal(getRec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if got != newSettings { + t.Fatalf("got %+v after round-trip, want %+v", got, newSettings) + } + + // Restore defaults so other tests (and a shared dev DB) aren't left + // with this test's ceiling. + if err := reviewperf.Save(putC.Request().Context(), db, reviewperf.DefaultSettings()); err != nil { + t.Fatalf("restore defaults: %v", err) + } +} + +func TestAIReviewPerformanceSettings_RejectsInvalidPayload(t *testing.T) { + db := perfSettingsTestDB(t) + s := &Server{db: db} + e := echo.New() + + invalid := reviewperf.Settings{MaxConcurrentBatchesCeiling: 0, MinReviewTimeoutSeconds: 60, MaxReviewTimeoutSeconds: 240} + body, _ := json.Marshal(invalid) + req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings/ai-review-performance", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := s.UpdateAIReviewPerformanceSettings(c); err != nil { + t.Fatalf("UpdateAIReviewPerformanceSettings: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for an invalid ceiling, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// TestUpsertReviewAISettings_PreferenceClampedToSystemCeiling is the +// end-to-end proof that an org preference above the instance ceiling never +// takes effect: it saves fine (the org is free to ask), but the *effective* +// value returned is clamped - the whole point of the two-tier design in +// internal/reviewperf (an org can turn concurrency down, never up past what +// the instance allows). +func TestUpsertReviewAISettings_PreferenceClampedToSystemCeiling(t *testing.T) { + db := perfSettingsTestDB(t) + if err := reviewperf.Save(context.Background(), db, reviewperf.Settings{ + MaxConcurrentBatchesCeiling: 10, + MinReviewTimeoutSeconds: 60, + MaxReviewTimeoutSeconds: 240, + }); err != nil { + t.Fatalf("seed system ceiling: %v", err) + } + t.Cleanup(func() { + _ = reviewperf.Save(context.Background(), db, reviewperf.DefaultSettings()) + }) + + var orgID int64 + if err := db.QueryRow(`INSERT INTO orgs (name) VALUES ('ai-perf-handler-test') RETURNING id`).Scan(&orgID); err != nil { + t.Fatalf("insert org: %v", err) + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM org_review_ai_settings WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM orgs WHERE id = $1`, orgID) + }) + + s := &Server{db: db} + e := echo.New() + + preferred := 999 // way above the system ceiling of 10 + reqBody, _ := json.Marshal(ReviewAISettingsRequest{ + HelperEnabled: true, + HelperMode: storageaiconnectors.HelperModeConciseThenExpand, + PreferredMaxConcurrentBatches: &preferred, + }) + putReq := httptest.NewRequest(http.MethodPut, "/api/v1/aiconnectors/settings", bytes.NewReader(reqBody)) + putReq.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + putRec := httptest.NewRecorder() + putC := e.NewContext(putReq, putRec) + putC.Set("org_id", orgID) + + if err := s.UpsertReviewAISettings(putC); err != nil { + t.Fatalf("UpsertReviewAISettings: %v", err) + } + if putRec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", putRec.Code, putRec.Body.String()) + } + + var resp ReviewAISettingsResponse + if err := json.Unmarshal(putRec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if resp.PreferredMaxConcurrentBatches == nil || *resp.PreferredMaxConcurrentBatches != 999 { + t.Fatalf("saved preference = %v, want the raw 999 the org asked for", resp.PreferredMaxConcurrentBatches) + } + if resp.EffectiveMaxConcurrentBatches != 10 { + t.Fatalf("effective max concurrent batches = %d, want clamped to the system ceiling of 10", resp.EffectiveMaxConcurrentBatches) + } +} diff --git a/internal/api/aiconnectors.go b/internal/api/aiconnectors.go index 2cb18d326..7e25a750c 100644 --- a/internal/api/aiconnectors.go +++ b/internal/api/aiconnectors.go @@ -12,6 +12,7 @@ import ( "github.com/labstack/echo/v4" "github.com/livereview/internal/aiconnectors" "github.com/livereview/internal/aidefault" + "github.com/livereview/internal/reviewperf" storageaiconnectors "github.com/livereview/storage/aiconnectors" "github.com/rs/zerolog/log" ) @@ -166,11 +167,29 @@ type AIConnectorResponse struct { type ReviewAISettingsRequest struct { HelperEnabled bool `json:"helper_enabled"` HelperMode string `json:"helper_mode"` + + // PreferredMaxConcurrentBatches and PreferredReviewTimeoutSeconds are + // optional soft preferences - omitted/null means "use the system + // default" (see internal/reviewperf). Always clamped server-side to the + // instance-wide ceiling; an org can turn these down, never up past what + // the instance allows. + PreferredMaxConcurrentBatches *int `json:"preferred_max_concurrent_batches"` + PreferredReviewTimeoutSeconds *int `json:"preferred_review_timeout_seconds"` } type ReviewAISettingsResponse struct { HelperEnabled bool `json:"helper_enabled"` HelperMode string `json:"helper_mode"` + + PreferredMaxConcurrentBatches *int `json:"preferred_max_concurrent_batches"` + PreferredReviewTimeoutSeconds *int `json:"preferred_review_timeout_seconds"` + + // EffectiveMaxConcurrentBatches/EffectiveReviewTimeoutSeconds show what + // will actually be used right now (preference resolved against the + // current instance-wide ceiling) - lets the Settings UI show "you asked + // for X, the instance allows up to Y" without a second API call. + EffectiveMaxConcurrentBatches int `json:"effective_max_concurrent_batches"` + EffectiveReviewTimeoutSeconds int `json:"effective_review_timeout_seconds"` } // FetchOllamaModelsRequest represents the request for fetching Ollama models @@ -615,9 +634,18 @@ func (s *Server) GetReviewAISettings(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load review AI settings: " + err.Error()}) } + perfSettings, err := reviewperf.Load(c.Request().Context(), s.db) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load review performance settings: " + err.Error()}) + } + return c.JSON(http.StatusOK, ReviewAISettingsResponse{ - HelperEnabled: settings.HelperEnabled, - HelperMode: settings.HelperMode, + HelperEnabled: settings.HelperEnabled, + HelperMode: settings.HelperMode, + PreferredMaxConcurrentBatches: settings.PreferredMaxConcurrentBatches, + PreferredReviewTimeoutSeconds: settings.PreferredReviewTimeoutSeconds, + EffectiveMaxConcurrentBatches: perfSettings.ResolveMaxConcurrentBatches(settings.PreferredMaxConcurrentBatches), + EffectiveReviewTimeoutSeconds: perfSettings.ResolveReviewTimeoutSeconds(settings.PreferredReviewTimeoutSeconds), }) } @@ -637,20 +665,37 @@ func (s *Server) UpsertReviewAISettings(c echo.Context) error { if normalizedMode == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Helper mode must be concise_then_expand or polish_only"}) } + if req.PreferredMaxConcurrentBatches != nil && *req.PreferredMaxConcurrentBatches <= 0 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Preferred max concurrent batches must be > 0"}) + } + if req.PreferredReviewTimeoutSeconds != nil && *req.PreferredReviewTimeoutSeconds <= 0 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Preferred review timeout seconds must be > 0"}) + } store := storageaiconnectors.NewReviewAISettingsStore(s.db) settings, err := store.Upsert(c.Request().Context(), storageaiconnectors.ReviewAISettings{ - OrgID: orgID, - HelperEnabled: req.HelperEnabled, - HelperMode: normalizedMode, + OrgID: orgID, + HelperEnabled: req.HelperEnabled, + HelperMode: normalizedMode, + PreferredMaxConcurrentBatches: req.PreferredMaxConcurrentBatches, + PreferredReviewTimeoutSeconds: req.PreferredReviewTimeoutSeconds, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save review AI settings: " + err.Error()}) } + perfSettings, err := reviewperf.Load(c.Request().Context(), s.db) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load review performance settings: " + err.Error()}) + } + return c.JSON(http.StatusOK, ReviewAISettingsResponse{ - HelperEnabled: settings.HelperEnabled, - HelperMode: settings.HelperMode, + HelperEnabled: settings.HelperEnabled, + HelperMode: settings.HelperMode, + PreferredMaxConcurrentBatches: settings.PreferredMaxConcurrentBatches, + PreferredReviewTimeoutSeconds: settings.PreferredReviewTimeoutSeconds, + EffectiveMaxConcurrentBatches: perfSettings.ResolveMaxConcurrentBatches(settings.PreferredMaxConcurrentBatches), + EffectiveReviewTimeoutSeconds: perfSettings.ResolveReviewTimeoutSeconds(settings.PreferredReviewTimeoutSeconds), }) } diff --git a/internal/api/auto_approve_policy_handlers.go b/internal/api/auto_approve_policy_handlers.go index 6ab295b03..5116a8a35 100644 --- a/internal/api/auto_approve_policy_handlers.go +++ b/internal/api/auto_approve_policy_handlers.go @@ -382,23 +382,70 @@ func (h *AutoApprovePoliciesHandler) Simulate(c echo.Context) error { Score float64 `json:"score,omitempty"` Stages []policyengine.StageResult `json:"stages"` } + // simSummary answers "is this org's current slider configuration actually + // differentiating anything" at a glance, instead of requiring a hand-query + // against the database (how the original br_sum saturation bug behind this + // package's riskscore changes was actually found). saturated_high/_low + // count scores pinned at either extreme, which a well-tuned model should + // rarely produce for a typical history. + type simSummary struct { + ScoredCount int `json:"scored_count"` + SkippedCount int `json:"skipped_count"` + SaturatedHigh int `json:"saturated_high"` + SaturatedLow int `json:"saturated_low"` + ScoreMin float64 `json:"score_min,omitempty"` + ScoreMax float64 `json:"score_max,omitempty"` + ScoreMean float64 `json:"score_mean,omitempty"` + } out := []simRow{} + summary := simSummary{ScoreMin: 1} + var scoreSum float64 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) + summary.SkippedCount++ continue } row := simRow{ReviewID: rid, Approved: res.Approved, Score: res.Score, Stages: res.Stages} + riskRan := false for _, st := range res.Stages { - if st.Verdict == policyengine.VerdictFail { + if row.BlockedAt == "" && st.Verdict == policyengine.VerdictFail { row.BlockedAt = st.Stage - break + } + // Risk stage is skipped (VerdictSkip) whenever an earlier stage + // (eligibility/static) already blocked the review -- res.Score + // stays its zero value in that case, which isn't a real "low + // score" and must not be counted in the summary stats below. + if st.Stage == policyengine.StageRisk && st.Verdict != policyengine.VerdictSkip { + riskRan = true } } out = append(out, row) + + if riskRan { + summary.ScoredCount++ + scoreSum += res.Score + if res.Score > summary.ScoreMax { + summary.ScoreMax = res.Score + } + if res.Score < summary.ScoreMin { + summary.ScoreMin = res.Score + } + if res.Score > 0.99 { + summary.SaturatedHigh++ + } + if res.Score < 0.01 { + summary.SaturatedLow++ + } + } } - return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"rows": out}) + if summary.ScoredCount == 0 { + summary.ScoreMin = 0 + } else { + summary.ScoreMean = scoreSum / float64(summary.ScoredCount) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"rows": out, "summary": summary}) } // loadOrgDefaultPolicy returns the org-default (repository_id IS NULL) policy diff --git a/internal/api/diff_review.go b/internal/api/diff_review.go index dfbf9ae52..89b15ff19 100644 --- a/internal/api/diff_review.go +++ b/internal/api/diff_review.go @@ -14,9 +14,9 @@ import ( "github.com/labstack/echo/v4" apimiddleware "github.com/livereview/internal/api/middleware" - "github.com/livereview/internal/githubapp" "github.com/livereview/internal/blobstore" "github.com/livereview/internal/diffutil" + "github.com/livereview/internal/githubapp" "github.com/livereview/internal/jobqueue" "github.com/livereview/internal/license" "github.com/livereview/internal/naming" @@ -43,8 +43,9 @@ type DiffReviewRequest struct { // ref(s) at request time (git-lrc's --commit/--range post-commit reviews) — // plain staged/working diffs have no commit yet, so they send none. type CommitRef struct { - Ref string `json:"ref"` - Type string `json:"type"` // "commit" | "range" + Ref string `json:"ref"` + Type string `json:"type"` // "commit" | "range" + Message string `json:"message,omitempty"` // best-effort; empty when the source never provided one } // DiffReviewResult holds persisted review output that is safe to marshal. @@ -166,43 +167,6 @@ func (s *Server) GetDiffReviewStatus(c echo.Context) error { return JSONErrorWithEnvelope(c, http.StatusNotFound, "review not found") } - if reviewRecord.Status != "completed" { - meta := map[string]interface{}{} - if len(reviewRecord.Metadata) > 0 { - _ = json.Unmarshal(reviewRecord.Metadata, &meta) - } - applyEnvelopeUsageFromMetadata(c, meta) - if v, ok := readOperationBillableLOC(meta); ok { - c.Set(EnvelopeOperationTypeContextKey, "diff_review") - c.Set(EnvelopeTriggerSourceContextKey, "api") - c.Set(EnvelopeOperationBillableLOCContextKey, v) - } - if v, ok := readStringMeta(meta, "accounted_at"); ok { - c.Set(EnvelopeAccountedAtContextKey, v) - } - if v, ok := readStringMeta(meta, "operation_id"); ok { - c.Set(EnvelopeOperationIDContextKey, v) - } - if v, ok := readStringMeta(meta, "idempotency_key"); ok { - c.Set(EnvelopeIdempotencyKeyContextKey, v) - } - - failureReason, _ := meta["failure_reason"].(string) - response := map[string]interface{}{ - "status": reviewRecord.Status, - "review_id": fmt.Sprintf("%d", reviewRecord.ID), - } - - if reviewRecord.FriendlyName != nil { - response["friendly_name"] = *reviewRecord.FriendlyName - } - if failureReason != "" { - response["message"] = failureReason - } - - return JSONWithEnvelope(c, http.StatusOK, response) - } - meta := map[string]interface{}{} if len(reviewRecord.Metadata) > 0 { _ = json.Unmarshal(reviewRecord.Metadata, &meta) @@ -223,48 +187,33 @@ func (s *Server) GetDiffReviewStatus(c echo.Context) error { c.Set(EnvelopeIdempotencyKeyContextKey, v) } - liveFetch := false - preloaded, err := s.fetchPreloadedChanges(c.Request().Context(), orgID, reviewID, meta) - if err != nil { - preloaded = nil - // fetchPreloadedChanges reads from blob storage (or legacy metadata fallback) - only cli_diff persists the diff; every other trigger type dispatches to its own live-fetch strategy by trigger_type. - switch reviewRecord.TriggerType { - case "scheduled": - live, ok, liveErr := s.fetchLiveDiffFromMetadata(c.Request().Context(), meta) - if liveErr != nil { - log.Printf("[WARN] live diff fetch failed for scheduled review %d: %v", reviewID, liveErr) - } else if ok { - preloaded = live - liveFetch = true - } - case "cli_diff": - log.Printf("[WARN] preloaded_changes unavailable for CLI review %d: %v", reviewID, err) - default: - // manual, webhook, api, mcp, or any other trigger reviewing a real PR/MR. - connectorID := reviewRecord.ConnectorID - if connectorID == nil && reviewRecord.PrMrURL != "" { - // Pre-backfill reviews have connector_id unset - resolve it the same way prepareAuthentication does, and persist it so this only runs once per review. - if _, baseURL, parseErr := validateAndParseURL(reviewRecord.PrMrURL); parseErr == nil { - if token, tokenErr := s.findIntegrationToken(baseURL, orgID); tokenErr == nil { - connectorID = &token.ID - if updErr := rm.UpdateReviewConnector(reviewRecord.ID, token.ID); updErr != nil { - log.Printf("[WARN] Failed to backfill connector_id for review %d: %v", reviewID, updErr) - } - } - } - } - if connectorID != nil && reviewRecord.PrMrURL != "" { - prLive, prErr := s.fetchLiveDiffFromPR(c.Request().Context(), *connectorID, reviewRecord.PrMrURL) - if prErr != nil { - log.Printf("[WARN] live PR diff fetch failed for review %d", reviewID) - } else { - preloaded = prLive - liveFetch = true - } - } else { - log.Printf("[WARN] preloaded_changes unavailable for review %d (trigger_type=%s): %v", reviewID, reviewRecord.TriggerType, err) - } + // Resolve the diff/hunk structure regardless of completion status, so the + // webui's Findings tab can render real files+hunks (with no comments yet) + // while the review is still in_progress, and merge in streamed per-batch + // comments from GetReviewEvents on top of them. Before this, a + // non-completed review returned no "files" at all, so there was nothing + // to merge incremental findings into (see the incremental-findings plan). + preloaded, liveFetch := s.resolvePreloadedChanges(c.Request().Context(), orgID, reviewID, reviewRecord, rm, meta) + + if reviewRecord.Status != "completed" { + failureReason, _ := meta["failure_reason"].(string) + response := map[string]interface{}{ + "status": reviewRecord.Status, + "review_id": fmt.Sprintf("%d", reviewRecord.ID), + "files": buildDiffFiles(preloaded, nil), } + + if reviewRecord.FriendlyName != nil { + response["friendly_name"] = *reviewRecord.FriendlyName + } + if failureReason != "" { + response["message"] = failureReason + } + if liveFetch { + response["live_fetch"] = true + } + + return JSONWithEnvelope(c, http.StatusOK, response) } result, err := decodeReviewResult(meta) @@ -303,6 +252,59 @@ func (s *Server) GetDiffReviewStatus(c echo.Context) error { return JSONWithEnvelope(c, http.StatusOK, response) } +// resolvePreloadedChanges fetches the diff/hunk structure for a review, +// independent of whether the review has finished analyzing it yet. +// fetchPreloadedChanges reads from blob storage (or legacy metadata +// fallback) - only cli_diff persists the diff; every other trigger type +// dispatches to its own live-fetch strategy by trigger_type. Returns +// (nil, false) if nothing is available yet (e.g. a brand-new review whose +// diff hasn't been persisted/fetched yet) - callers treat that as "no files +// to show yet", not an error. +func (s *Server) resolvePreloadedChanges(ctx context.Context, orgID, reviewID int64, reviewRecord *Review, rm *ReviewManager, meta map[string]interface{}) ([]models.CodeDiff, bool) { + preloaded, err := s.fetchPreloadedChanges(ctx, orgID, reviewID, meta) + if err == nil { + return preloaded, false + } + + switch reviewRecord.TriggerType { + case "scheduled": + live, ok, liveErr := s.fetchLiveDiffFromMetadata(ctx, meta) + if liveErr != nil { + log.Printf("[WARN] live diff fetch failed for scheduled review %d: %v", reviewID, liveErr) + } else if ok { + return live, true + } + case "cli_diff": + log.Printf("[WARN] preloaded_changes unavailable for CLI review %d: %v", reviewID, err) + default: + // manual, webhook, api, mcp, or any other trigger reviewing a real PR/MR. + connectorID := reviewRecord.ConnectorID + if connectorID == nil && reviewRecord.PrMrURL != "" { + // Pre-backfill reviews have connector_id unset - resolve it the same way prepareAuthentication does, and persist it so this only runs once per review. + if _, baseURL, parseErr := validateAndParseURL(reviewRecord.PrMrURL); parseErr == nil { + if token, tokenErr := s.findIntegrationToken(baseURL, orgID); tokenErr == nil { + connectorID = &token.ID + if updErr := rm.UpdateReviewConnector(reviewRecord.ID, token.ID); updErr != nil { + log.Printf("[WARN] Failed to backfill connector_id for review %d: %v", reviewID, updErr) + } + } + } + } + if connectorID != nil && reviewRecord.PrMrURL != "" { + prLive, prErr := s.fetchLiveDiffFromPR(ctx, *connectorID, reviewRecord.PrMrURL) + if prErr != nil { + log.Printf("[WARN] live PR diff fetch failed for review %d", reviewID) + } else { + return prLive, true + } + } else { + log.Printf("[WARN] preloaded_changes unavailable for review %d (trigger_type=%s): %v", reviewID, reviewRecord.TriggerType, err) + } + } + + return nil, false +} + // AttachDiffReviewCommitRequest is the payload for POST /api/v1/diff-review/:review_id/commit. type AttachDiffReviewCommitRequest struct { CommitSHA string `json:"commit_sha"` diff --git a/internal/api/polling_event_service.go b/internal/api/polling_event_service.go index d495ef43a..ac06064cd 100644 --- a/internal/api/polling_event_service.go +++ b/internal/api/polling_event_service.go @@ -5,7 +5,10 @@ import ( "database/sql" "encoding/json" "fmt" + "reflect" "time" + + reviewprocessor "github.com/livereview/internal/review_processor" ) // EventSink defines the interface for broadcasting events @@ -159,7 +162,30 @@ func (s *PollingEventService) CreateBatchEvent(ctx context.Context, reviewID, or Data: dataJSON, } - return s.EmitEvent(ctx, event) + if err := s.EmitEvent(ctx, event); err != nil { + return err + } + + // Mirrors internal/review_processor/events.go's CreateBatchEvent — this + // package has its own separate PollingEventService (used by the + // synchronous CLI-diff review path, internal/api/review_service.go), + // so the same hook needs wiring here too, not just there. + if status == "completed" && hasComments(comments) { + reviewprocessor.NotifyReviewEvent(ctx, reviewID, orgID, "first_finding") + } + + return nil +} + +// hasComments reports whether comments is a non-empty slice, without this +// package needing to import pkg/models for the concrete []*models.ReviewComment +// type EmitBatchComplete actually passes through here. +func hasComments(comments interface{}) bool { + if comments == nil { + return false + } + v := reflect.ValueOf(comments) + return v.Kind() == reflect.Slice && v.Len() > 0 } // CreateArtifactEvent creates an artifact reference event diff --git a/internal/api/push_handlers.go b/internal/api/push_handlers.go new file mode 100644 index 000000000..d2913f197 --- /dev/null +++ b/internal/api/push_handlers.go @@ -0,0 +1,174 @@ +package api + +import ( + "database/sql" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/livereview/internal/pushnotify" + "github.com/livereview/pkg/models" +) + +// PushHandlers serves browser push-notification subscription management and +// per-user notification preferences. Session-only (see server.go's route +// registration — these sit under authMiddleware.RequireAuth(), never +// RequireAuthOrAPIKey): a push subscription is tied to one specific +// browser's real endpoint, not something automation should be creating or +// reading on a user's behalf. +type PushHandlers struct { + db *sql.DB +} + +func NewPushHandlers(db *sql.DB) *PushHandlers { + return &PushHandlers{db: db} +} + +func currentUser(c echo.Context) (*models.User, error) { + user, ok := c.Get("user").(*models.User) + if !ok || user == nil { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "user not found in context") + } + return user, nil +} + +// GetVAPIDPublicKey returns the instance's public VAPID key for the +// frontend's pushManager.subscribe({applicationServerKey: ...}) call. +func (h *PushHandlers) GetVAPIDPublicKey(c echo.Context) error { + publicKey, err := pushnotify.GetVAPIDPublicKey(c.Request().Context(), h.db) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "failed to load VAPID public key") + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"public_key": publicKey}) +} + +// PushSubscriptionRequest mirrors the browser's PushSubscription.toJSON() shape. +type PushSubscriptionRequest struct { + Endpoint string `json:"endpoint"` + Keys struct { + P256dh string `json:"p256dh"` + Auth string `json:"auth"` + } `json:"keys"` +} + +// CreateSubscription registers (or re-registers, on conflict) the current +// browser's push subscription for the authenticated user. user_id always +// comes from the session, never from the request body. +func (h *PushHandlers) CreateSubscription(c echo.Context) error { + user, err := currentUser(c) + if err != nil { + return err + } + + var req PushSubscriptionRequest + if err := c.Bind(&req); err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid request body") + } + if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "endpoint and keys.p256dh/keys.auth are required") + } + + userAgent := c.Request().UserAgent() + _, err = h.db.ExecContext(c.Request().Context(), ` + INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth_key, user_agent, last_used_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (endpoint) DO UPDATE SET + user_id = EXCLUDED.user_id, + p256dh = EXCLUDED.p256dh, + auth_key = EXCLUDED.auth_key, + user_agent = EXCLUDED.user_agent, + last_used_at = NOW() + `, user.ID, req.Endpoint, req.Keys.P256dh, req.Keys.Auth, userAgent) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "failed to save push subscription") + } + + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"status": "subscribed"}) +} + +// DeleteSubscriptionRequest identifies which subscription to remove. +// DeleteSubscription removes the current browser's push subscription (its +// endpoint passed as a query param, not a body — DELETE-with-body has no +// clean client-side precedent elsewhere in this codebase's apiClient). +// Scoped to the authenticated user's own subscriptions — deleting by +// (endpoint, user_id), never endpoint alone, so one user can never remove +// another's row. +func (h *PushHandlers) DeleteSubscription(c echo.Context) error { + user, err := currentUser(c) + if err != nil { + return err + } + + endpoint := c.QueryParam("endpoint") + if endpoint == "" { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "endpoint query param is required") + } + + if _, err := h.db.ExecContext(c.Request().Context(), ` + DELETE FROM push_subscriptions WHERE endpoint = $1 AND user_id = $2 + `, endpoint, user.ID); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "failed to remove push subscription") + } + + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"status": "unsubscribed"}) +} + +// NotificationPreferences is both the GET response and the PUT request body. +type NotificationPreferences struct { + NotifyFirstFinding bool `json:"notify_first_finding"` + NotifyCompletion bool `json:"notify_completion"` +} + +// GetNotificationPreferences returns the authenticated user's preferences, +// defaulting both to true when no row exists yet (see the migration's +// comment — this is the agreed "on by default" behavior, not a placeholder). +func (h *PushHandlers) GetNotificationPreferences(c echo.Context) error { + user, err := currentUser(c) + if err != nil { + return err + } + + prefs := NotificationPreferences{NotifyFirstFinding: true, NotifyCompletion: true} + err = h.db.QueryRowContext(c.Request().Context(), ` + SELECT notify_first_finding, notify_completion + FROM user_notification_preferences WHERE user_id = $1 + `, user.ID).Scan(&prefs.NotifyFirstFinding, &prefs.NotifyCompletion) + if err != nil && err != sql.ErrNoRows { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "failed to load notification preferences") + } + + return JSONWithEnvelope(c, http.StatusOK, prefs.toMap()) +} + +// UpdateNotificationPreferences upserts the authenticated user's preferences. +func (h *PushHandlers) UpdateNotificationPreferences(c echo.Context) error { + user, err := currentUser(c) + if err != nil { + return err + } + + var prefs NotificationPreferences + if err := c.Bind(&prefs); err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid request body") + } + + _, err = h.db.ExecContext(c.Request().Context(), ` + INSERT INTO user_notification_preferences (user_id, notify_first_finding, notify_completion, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (user_id) DO UPDATE SET + notify_first_finding = EXCLUDED.notify_first_finding, + notify_completion = EXCLUDED.notify_completion, + updated_at = NOW() + `, user.ID, prefs.NotifyFirstFinding, prefs.NotifyCompletion) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "failed to save notification preferences") + } + + return JSONWithEnvelope(c, http.StatusOK, prefs.toMap()) +} + +func (p NotificationPreferences) toMap() map[string]interface{} { + return map[string]interface{}{ + "notify_first_finding": p.NotifyFirstFinding, + "notify_completion": p.NotifyCompletion, + } +} diff --git a/internal/api/review_commits.go b/internal/api/review_commits.go index 8258e5160..25c02005d 100644 --- a/internal/api/review_commits.go +++ b/internal/api/review_commits.go @@ -17,7 +17,7 @@ func (s *Server) insertReviewCommits(ctx context.Context, reviewID, orgID int64, func insertReviewCommitsTx(ctx context.Context, db *sql.DB, reviewID, orgID int64, repositoryID *int64, refs []CommitRef) error { converted := make([]reviewprocessor.CommitRef, len(refs)) for i, r := range refs { - converted[i] = reviewprocessor.CommitRef{Ref: r.Ref, Type: r.Type} + converted[i] = reviewprocessor.CommitRef{Ref: r.Ref, Type: r.Type, Message: r.Message} } return reviewprocessor.InsertReviewCommits(ctx, db, reviewID, orgID, repositoryID, converted) } diff --git a/internal/api/review_coverage.go b/internal/api/review_coverage.go index 7ae7c3408..285de0b7a 100644 --- a/internal/api/review_coverage.go +++ b/internal/api/review_coverage.go @@ -132,6 +132,7 @@ type ReviewCommitEntry struct { Ref string `json:"ref"` RefType string `json:"refType"` CreatedAt time.Time `json:"createdAt"` + Message string `json:"message,omitempty"` } // GetReviewCommits handles GET /api/v1/reviews/:id/commits: the commit @@ -155,7 +156,7 @@ func (s *Server) GetReviewCommits(c echo.Context) error { } rows, err := s.db.QueryContext(c.Request().Context(), ` - SELECT rc.ref, rc.ref_type, rc.created_at + SELECT rc.ref, rc.ref_type, rc.created_at, rc.message FROM review_commits rc JOIN reviews r ON r.id = rc.review_id WHERE rc.review_id = $1 AND r.org_id = $2 @@ -169,9 +170,11 @@ func (s *Server) GetReviewCommits(c echo.Context) error { commits := make([]ReviewCommitEntry, 0) for rows.Next() { var entry ReviewCommitEntry - if err := rows.Scan(&entry.Ref, &entry.RefType, &entry.CreatedAt); err != nil { + var message sql.NullString + if err := rows.Scan(&entry.Ref, &entry.RefType, &entry.CreatedAt, &message); err != nil { return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "failed to scan review commit row") } + entry.Message = message.String commits = append(commits, entry) } if err := rows.Err(); err != nil { diff --git a/internal/api/review_service.go b/internal/api/review_service.go index 21e58caf4..c401a3d8e 100644 --- a/internal/api/review_service.go +++ b/internal/api/review_service.go @@ -460,7 +460,7 @@ func (s *Server) createReviewRequest(ctx *reviewSetupContext) error { ctx.logger.Log("Creating review service...") } log.Printf("[DEBUG] TriggerReviewV2: Creating review service for request") - reviewService, err := s.createReviewService(ctx.token) + reviewService, err := s.createReviewService(ctx.token, ctx.orgID, ctx.planCode) if err != nil { if ctx.logger != nil { ctx.logger.LogError("Failed to create review service", err) @@ -588,9 +588,9 @@ func (s *Server) enrichMetadata(ctx *reviewSetupContext) { // recorded; the rest fall back to just the head/base pair from DiffRefs. var commitRefs []CommitRef if len(details.Commits) > 0 { - for _, sha := range details.Commits { - if sha = strings.TrimSpace(sha); sha != "" { - commitRefs = append(commitRefs, CommitRef{Ref: sha, Type: "commit"}) + for _, c := range details.Commits { + if sha := strings.TrimSpace(c.SHA); sha != "" { + commitRefs = append(commitRefs, CommitRef{Ref: sha, Type: "commit", Message: strings.TrimSpace(c.Message)}) } } } else { diff --git a/internal/api/reviews_api.go b/internal/api/reviews_api.go index 4821e847e..aa4de39ad 100644 --- a/internal/api/reviews_api.go +++ b/internal/api/reviews_api.go @@ -332,26 +332,27 @@ func ensureValidToken(token *IntegrationToken) string { return token.AccessToken } -// createReviewService creates a new review service instance per request -// This allows for dynamic configuration based on request context or database state -func (s *Server) createReviewService(token *IntegrationToken) (*review.Service, error) { +// createReviewService creates a new review service instance per request. +// Concurrency ceiling and review timeout are resolved per-org (soft +// preference) clamped against the instance-wide ceiling (see +// internal/reviewperf and internal/aiselection.Selection) instead of always +// using the hardcoded review.DefaultReviewConfig() defaults. +func (s *Server) createReviewService(token *IntegrationToken, orgID int64, planCode license.PlanType) (*review.Service, error) { // Create factories for this specific request providerFactory := review.NewStandardProviderFactory() aiProviderFactory := review.NewStandardAIProviderFactory() - // Build review configuration (could be customized based on database state) reviewConfig := review.DefaultReviewConfig() - // Example: Could customize config based on database state - // if token.Provider == "enterprise-gitlab" { - // reviewConfig.ReviewTimeout = 20 * time.Minute - // } - // - // Future enhancement: Load per-user/org config from database - // userConfig, err := s.loadUserReviewConfig(token.UserID) - // if err == nil { - // reviewConfig = userConfig - // } + selection, err := s.getReviewAISelectionFromDatabase(context.Background(), orgID, planCode) + if err != nil { + // Fall back to the system defaults already in reviewConfig rather + // than failing review creation over a performance-settings lookup. + log.Printf("[WARN] createReviewService: failed to resolve review performance settings for org %d, using defaults: %v", orgID, err) + } else { + reviewConfig.MaxConcurrentBatches = selection.EffectiveMaxConcurrentBatches + reviewConfig.ReviewTimeout = time.Duration(selection.EffectiveReviewTimeoutSeconds) * time.Second + } // Create review service for this specific request reviewService := review.NewService(providerFactory, aiProviderFactory, reviewConfig) diff --git a/internal/api/server.go b/internal/api/server.go index 6f381bf7e..492f8e9db 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -47,6 +47,7 @@ import ( githuboutput "github.com/livereview/internal/provider_output/github" gitlaboutput "github.com/livereview/internal/provider_output/gitlab" "github.com/livereview/internal/providers/azuredevops" + "github.com/livereview/internal/pushnotify" reviewprocessor "github.com/livereview/internal/review_processor" "github.com/livereview/internal/scheduledreview" "github.com/livereview/internal/scim" @@ -445,6 +446,19 @@ func appContext(port int, versionInfo *VersionInfo) (*Server, error) { return nil, fmt.Errorf("configuration validation failed: %w", err) } + // Wires browser push notifications (review progress) into every + // completion/batch-event write path in one place — see + // reviewprocessor.SetNotifier's doc comment for why this is a + // package-level hook rather than threaded through every ReviewManager + // construction site. Belongs in appContext, not NewServer: the actual + // batch-completion/status-update writes that trigger notifications run + // inside the background `worker` process (cmd/worker.go calls + // WorkerContext, which is appContext without the Echo routing NewServer + // adds), not the `api` process — wiring this only in NewServer meant it + // was never set in the worker process at all, so every notification + // silently no-op'd there with no error or log line. + reviewprocessor.SetNotifier(pushnotify.New(server.db)) + return server, nil } @@ -1330,6 +1344,33 @@ func (s *Server) setupRoutes() { protected.PUT("/users/profile", s.profileHandlers.UpdateProfile) protected.PUT("/users/default-org", s.orgHandlers.SetDefaultOrganization) protected.PUT("/users/time-zone", s.profileHandlers.UpdateTimeZone) + + // Browser push notifications for review progress (first finding / + // completion) and the preferences that gate them. Session-only — a + // separate group using RequireAuth() rather than `protected` + // (RequireAuthOrAPIKey): a push subscription is tied to one specific + // browser's real endpoint, not something an API key/automation should + // be creating or reading on a user's behalf. + pushHandlers := NewPushHandlers(s.db) + pushGroup := v1.Group("/push") + pushGroup.Use(authMiddleware.RequireAuth()) + pushGroup.GET("/vapid-public-key", pushHandlers.GetVAPIDPublicKey) + s.audited(pushGroup, echo.POST, "/subscriptions", auditlog.Spec{ + Action: auditlog.ActionPushSubscriptionCreate, + Category: auditlog.CategoryUser, + }, pushHandlers.CreateSubscription) + s.audited(pushGroup, echo.DELETE, "/subscriptions", auditlog.Spec{ + Action: auditlog.ActionPushSubscriptionDelete, + Category: auditlog.CategoryUser, + }, pushHandlers.DeleteSubscription) + + notificationPrefsGroup := v1.Group("/users/me/notification-preferences") + notificationPrefsGroup.Use(authMiddleware.RequireAuth()) + notificationPrefsGroup.GET("", pushHandlers.GetNotificationPreferences) + s.audited(notificationPrefsGroup, echo.PUT, "", auditlog.Spec{ + Action: auditlog.ActionNotificationPreferencesUpdate, + Category: auditlog.CategoryUser, + }, pushHandlers.UpdateNotificationPreferences) protected.PUT("/users/password", s.profileHandlers.ChangePassword) // Development mode test endpoints (only enabled when DEV_MODE=true) @@ -1443,6 +1484,9 @@ func (s *Server) setupRoutes() { adminOrOwnerGroup.PUT("/settings/blast-radius-cache", s.UpdateBlastRadiusCacheSettings) adminOrOwnerGroup.POST("/settings/blast-radius-cache/clear", s.ClearBlastRadiusCache) + adminOrOwnerGroup.GET("/settings/ai-review-performance", s.GetAIReviewPerformanceSettings) + adminOrOwnerGroup.PUT("/settings/ai-review-performance", s.UpdateAIReviewPerformanceSettings) + // Log compaction settings endpoints (same instance-owner access as production-url and storage) adminOrOwnerGroup.GET("/settings/compaction", s.GetCompactionSettings) adminOrOwnerGroup.PUT("/settings/compaction", s.UpdateCompactionSettings) diff --git a/internal/api/users/profile_handlers.go b/internal/api/users/profile_handlers.go index 2d255689e..2d77eea10 100644 --- a/internal/api/users/profile_handlers.go +++ b/internal/api/users/profile_handlers.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "strconv" + "strings" "time" "github.com/labstack/echo/v4" @@ -238,6 +239,7 @@ func (ph *ProfileHandlers) UpdateTimeZone(c echo.Context) error { if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body") } + req.TimeZone = strings.TrimSpace(req.TimeZone) if len(req.TimeZone) > 64 || req.TimeZone == "" || req.TimeZone == "Local" { return echo.NewHTTPError(http.StatusBadRequest, "time_zone must be a valid IANA zone, e.g. Asia/Kolkata") } diff --git a/internal/auditlog/catalog.go b/internal/auditlog/catalog.go index 14a05d884..ea06487da 100644 --- a/internal/auditlog/catalog.go +++ b/internal/auditlog/catalog.go @@ -133,6 +133,10 @@ const ( ActionUserBulkInvite Action = "user.bulk_invite" ActionUserAuditRead Action = "user.audit.read" + ActionPushSubscriptionCreate Action = "user.push_subscription.create" + ActionPushSubscriptionDelete Action = "user.push_subscription.delete" + ActionNotificationPreferencesUpdate Action = "user.notification_preferences.update" + ActionOrgUpdate Action = "org.update" ActionOrgDeactivate Action = "org.deactivate" ActionOrgRoleChange Action = "org.member.role.changed" @@ -279,6 +283,9 @@ var actionCatalog = func() map[Action]ActionMeta { sensitive(meta(ActionUserTransfer, CategoryUser, "user", "User transferred", "A user was moved to another organization.", SeverityCritical)), meta(ActionUserInvite, CategoryUser, "user", "User invited", "A user was invited.", SeverityInfo), meta(ActionUserBulkInvite, CategoryUser, "user", "Users invited", "Multiple users were invited.", SeverityInfo), + meta(ActionPushSubscriptionCreate, CategoryUser, "push_subscription", "Push subscription created", "A user registered a browser for push notifications.", SeverityInfo), + meta(ActionPushSubscriptionDelete, CategoryUser, "push_subscription", "Push subscription removed", "A user removed a browser's push notification registration.", SeverityInfo), + meta(ActionNotificationPreferencesUpdate, CategoryUser, "user", "Notification preferences updated", "A user changed their review push-notification preferences.", SeverityInfo), readSensitive(meta(ActionUserAuditRead, CategoryAudit, "user", "User audit viewed", "A user's audit history was viewed.", SeverityInfo)), sensitive(meta(ActionOrgUpdate, CategoryOrg, "org", "Organization updated", "Organization settings were changed.", SeverityWarning)), diff --git a/internal/batch/config.go b/internal/batch/config.go index d943b36f4..bee2a22fb 100644 --- a/internal/batch/config.go +++ b/internal/batch/config.go @@ -1,10 +1,21 @@ package batch import ( - "runtime" "time" ) +// DefaultMaxConcurrentBatches is the per-review concurrency ceiling used when +// nothing more specific (an org preference or a system-wide setting) overrides +// it. It is NOT derived from runtime.NumCPU(): a batch "worker" spends its time +// blocked on an LLM API call (I/O-bound), so CPU count has nothing to do with +// how many can usefully run at once - the real ceiling is the AI provider's +// rate limit, which the global per-connector adaptive limiter (see +// internal/llm/adaptivelimiter) discovers and enforces independently. This +// value is just a conservative per-review cap safely under a typical +// default-tier provider limit, so a review's own batches never queue behind +// only 1-2 slots the way runtime.NumCPU() did on a small box. +const DefaultMaxConcurrentBatches = 16 + // Config holds configuration for batch processing type Config struct { MaxWorkers int // Maximum number of concurrent workers @@ -16,7 +27,7 @@ type Config struct { // DefaultConfig returns a default configuration for batch processing func DefaultConfig() Config { return Config{ - MaxWorkers: runtime.NumCPU(), + MaxWorkers: DefaultMaxConcurrentBatches, MaxRetries: 3, RetryDelay: 2 * time.Second, MaxBatchSize: 10000, diff --git a/internal/batch/task_queue.go b/internal/batch/task_queue.go index 20b70ca1c..923f7a810 100644 --- a/internal/batch/task_queue.go +++ b/internal/batch/task_queue.go @@ -6,6 +6,8 @@ import ( "sync" "time" + "github.com/livereview/internal/llm/adaptivelimiter" + "github.com/livereview/internal/llm/errclass" "github.com/livereview/pkg/models" ) @@ -24,7 +26,11 @@ type TaskResult struct { Retries int } -// TaskQueue is a queue for processing tasks with retry capabilities +// TaskQueue runs tasks concurrently, capped at maxWorkers in flight at once, +// with per-task retries. maxWorkers is a per-review concurrency ceiling (tier +// 1 of the two-tier model - see internal/llm/adaptivelimiter for tier 2, the +// process-wide per-AI-connector limiter that individual BatchTasks gate +// against independently of this queue). type TaskQueue struct { tasks []Task results map[string]*TaskResult @@ -34,7 +40,7 @@ type TaskQueue struct { mu sync.Mutex } -// NewTaskQueue creates a new task queue +// NewTaskQueue creates a new task queue with the given concurrency ceiling. func NewTaskQueue(maxWorkers int) *TaskQueue { return &TaskQueue{ tasks: make([]Task, 0), @@ -62,93 +68,60 @@ func (q *TaskQueue) SetRetryDelay(delay time.Duration) { q.retryDelay = delay } -// ProcessAll processes all tasks in the queue and returns the results +// ProcessAll runs every queued task concurrently - one goroutine per task, +// gated by a semaphore sized to maxWorkers - and returns each task's result +// keyed by task ID once all of them finish. +// +// This is deliberately not a fixed pool of pre-spawned long-lived worker +// goroutines pulling from a channel: with goroutine-per-task, a review with +// fewer batches than the ceiling simply runs all of them at once (the +// semaphore never fills), and a review with more batches than the ceiling +// queues behind it - the concurrency self-scales to the review's actual +// shape instead of needing a pool pre-sized to guess it. func (q *TaskQueue) ProcessAll(ctx context.Context) map[string]*TaskResult { q.mu.Lock() tasksCopy := make([]Task, len(q.tasks)) copy(tasksCopy, q.tasks) q.mu.Unlock() - // Create a channel for tasks - taskCh := make(chan Task, len(tasksCopy)) + ceiling := max(q.maxWorkers, 1) + sem := make(chan struct{}, ceiling) - // Create a channel for results resultCh := make(chan *TaskResult, len(tasksCopy)) - - // Create worker pool var wg sync.WaitGroup - workerCount := q.maxWorkers - if workerCount > len(tasksCopy) { - workerCount = len(tasksCopy) - } - // Start workers - for i := 0; i < workerCount; i++ { + for _, task := range tasksCopy { + // Dispatch is itself ctx-aware: a task still waiting for a free slot + // when ctx expires is recorded as cancelled immediately instead of + // blocking on the semaphore send indefinitely (a send on a full + // channel does not observe context cancellation on its own). + select { + case sem <- struct{}{}: // blocks here once `ceiling` tasks are already in flight + case <-ctx.Done(): + resultCh <- &TaskResult{ + TaskID: task.ID(), + Error: fmt.Errorf("task cancelled before start: %w", ctx.Err()), + } + continue + } wg.Add(1) - go func() { + go func(task Task) { defer wg.Done() - for task := range taskCh { - // Get task-specific max retries or use queue default - maxRetries := task.MaxRetries() - if maxRetries <= 0 { - maxRetries = q.maxRetries - } - - var result interface{} - var err error - retries := 0 - - // Execute the task with retries - for retries <= maxRetries { - result, err = task.Execute(ctx) - if err == nil { - break - } - - retries++ - if retries <= maxRetries { - // Wait before retry - select { - case <-time.After(q.retryDelay): - // Continue with retry - case <-ctx.Done(): - // Context cancelled, stop retrying - err = fmt.Errorf("task cancelled: %w", ctx.Err()) - break - } - } - } - - // Send result - resultCh <- &TaskResult{ - TaskID: task.ID(), - Result: result, - Error: err, - Retries: retries, - } - } - }() - } - - // Send tasks to workers - for _, task := range tasksCopy { - taskCh <- task + defer func() { <-sem }() + resultCh <- q.runWithRetries(ctx, task) + }(task) } - close(taskCh) - // Wait for all workers to finish go func() { wg.Wait() close(resultCh) }() - // Collect results results := make(map[string]*TaskResult) for result := range resultCh { results[result.TaskID] = result } - // Store results q.mu.Lock() q.results = results q.mu.Unlock() @@ -156,6 +129,57 @@ func (q *TaskQueue) ProcessAll(ctx context.Context) map[string]*TaskResult { return results } +// runWithRetries executes task, retrying up to its configured MaxRetries (or +// the queue default) while the context remains live. A cancelled/expired +// context stops retrying immediately rather than burning through the +// remaining attempts - each of which would fail instantly anyway, since the +// prior implementation's `break` only exited the inner `select`, not this +// `for`, letting an already-dead context spin through every remaining retry +// as a near-instant guaranteed failure instead of bailing on the first one. +func (q *TaskQueue) runWithRetries(ctx context.Context, task Task) *TaskResult { + maxRetries := task.MaxRetries() + if maxRetries <= 0 { + maxRetries = q.maxRetries + } + + var result interface{} + var err error + retries := 0 + +retryLoop: + for retries <= maxRetries { + if ctx.Err() != nil { + err = fmt.Errorf("task cancelled: %w", ctx.Err()) + break + } + + result, err = task.Execute(ctx) + if err == nil { + break + } + + retries++ + if retries > maxRetries { + break + } + + select { + case <-time.After(q.retryDelay): + // continue to next retry + case <-ctx.Done(): + err = fmt.Errorf("task cancelled: %w", ctx.Err()) + break retryLoop + } + } + + return &TaskResult{ + TaskID: task.ID(), + Result: result, + Error: err, + Retries: retries, + } +} + // GetResults returns the current results func (q *TaskQueue) GetResults() map[string]*TaskResult { q.mu.Lock() @@ -172,12 +196,14 @@ func (q *TaskQueue) GetResults() map[string]*TaskResult { // BatchTask implements the Task interface for processing code review batches type BatchTask struct { - id string - batch []models.CodeDiff - processor func(context.Context, []models.CodeDiff) (*BatchResult, error) - maxRetries int - batchNumber int - logger Logger + id string + batch []models.CodeDiff + processor func(context.Context, []models.CodeDiff) (*BatchResult, error) + maxRetries int + batchNumber int + logger Logger + limiter *adaptivelimiter.Limiter + providerType string } // NewBatchTask creates a new batch task @@ -201,14 +227,52 @@ func (t *BatchTask) SetLogger(logger Logger) { t.logger = logger } +// SetLimiter wires this task into the process-wide, self-tuning per-AI-connector +// concurrency limiter (tier 2 of the two-tier model - see +// internal/llm/adaptivelimiter). Optional: a nil limiter (the default) skips +// this gating entirely, which existing callers/tests rely on. +func (t *BatchTask) SetLimiter(limiter *adaptivelimiter.Limiter) { + t.limiter = limiter +} + +// SetProviderType records which AI provider this task's processor calls into +// (e.g. "openai", "anthropic", "googleai", "bedrock", "ollama"), used by +// internal/llm/errclass to classify a failure as rate-limited vs. not. Only +// meaningful alongside SetLimiter. +func (t *BatchTask) SetProviderType(providerType string) { + t.providerType = providerType +} + // Execute processes the batch func (t *BatchTask) Execute(ctx context.Context) (interface{}, error) { if t.logger != nil { t.logger.Info("Processing batch task %s with %d files", t.id, len(t.batch)) } + if t.limiter != nil { + release, acquireErr := t.limiter.Acquire(ctx) + if acquireErr != nil { + if t.logger != nil { + t.logger.Error("Batch %s: provider concurrency slot not acquired: %v", t.id, acquireErr) + } + return nil, fmt.Errorf("acquire provider concurrency slot: %w", acquireErr) + } + defer release() + } + result, err := t.processor(ctx, t.batch) + if t.limiter != nil { + switch { + case err == nil: + t.limiter.ReportSuccess() + case errclass.Classify(t.providerType, err) == errclass.RateLimited: + t.limiter.ReportRateLimited() + default: + t.limiter.ReportOther() + } + } + if result != nil && t.batchNumber > 0 { // Set the batch ID in the result result.BatchID = fmt.Sprintf("Batch %d", t.batchNumber) diff --git a/internal/batch/task_queue_simulation_test.go b/internal/batch/task_queue_simulation_test.go new file mode 100644 index 000000000..eac231f3b --- /dev/null +++ b/internal/batch/task_queue_simulation_test.go @@ -0,0 +1,248 @@ +package batch + +import ( + "context" + "fmt" + "sync/atomic" + "testing" + "time" +) + +// simTask is a fake Task whose Execute just sleeps for a configured duration +// instead of calling a real LLM - deterministic, no network, fast in CI. +// Durations below are review 13536's real shape scaled down 1000x (seconds +// -> milliseconds) so the test suite stays fast while preserving the ratio +// that actually matters: 3 batches that were each individually slow (~5min +// real) vs. 4 batches that were fast (~1s real) once given a worker. +type simTask struct { + id string + duration time.Duration + started *atomic.Int64 // number of times Execute began, for starvation checks +} + +func (s *simTask) Execute(ctx context.Context) (interface{}, error) { + if s.started != nil { + s.started.Add(1) + } + select { + case <-time.After(s.duration): + return "ok", nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (s *simTask) ID() string { return s.id } +func (s *simTask) MaxRetries() int { return 0 } // no retries needed for this simulation + +// review13528Shape returns review 13536's real batch durations, scaled +// 1000x down from seconds to milliseconds (batches 1-3's ~5min real stalls +// become 300ms; batches 4-7's near-instant real latency becomes 1ms). +func review13536Shape() []time.Duration { + return []time.Duration{ + 300 * time.Millisecond, // batch-1 (25 files, real ~5min) + 300 * time.Millisecond, // batch-2 (9 files, real ~5min) + 300 * time.Millisecond, // batch-3 (7 files, real ~5min) + time.Millisecond, // batch-4 (10 files, real ~1s once it got a worker) + time.Millisecond, // batch-5 (1 file, real ~1s) + time.Millisecond, // batch-6 (6 files, real ~1s) + time.Millisecond, // batch-7 (5 files, real ~1s) + } +} + +func buildTasks(durations []time.Duration) []Task { + tasks := make([]Task, len(durations)) + for i, d := range durations { + tasks[i] = &simTask{id: fmt.Sprintf("batch-%d", i+1), duration: d} + } + return tasks +} + +// TestSimulation_OldTwoWorkerModel_IsSlow reproduces the OLD behavior - +// NewTaskQueue(2), matching runtime.NumCPU() on the production box this bug +// was found on - against review 13536's real shape, and shows it takes +// roughly as long as the 3 slow batches serialized two-at-a-time (~450ms +// scaled, i.e. ~7.5 real minutes), not the ~300ms a fully concurrent run +// would take. +func TestSimulation_OldTwoWorkerModel_IsSlow(t *testing.T) { + q := NewTaskQueue(2) + for _, task := range buildTasks(review13536Shape()) { + q.AddTask(task) + } + + start := time.Now() + results := q.ProcessAll(context.Background()) + elapsed := time.Since(start) + + if len(results) != 7 { + t.Fatalf("got %d results, want 7", len(results)) + } + for id, r := range results { + if r.Error != nil { + t.Fatalf("task %s failed: %v", id, r.Error) + } + } + + // With 2 workers and 3 tasks at 300ms each plus 4 at 1ms, the 3 slow + // tasks alone can't all run concurrently - they serialize in pairs, + // taking roughly 2x a single slow task's duration (~600ms) at minimum. + // This asserts the old model is measurably slower than one slow batch's + // duration, proving the starvation is real, not theoretical. + if elapsed < 450*time.Millisecond { + t.Fatalf("old 2-worker model finished in %v, expected it to be slowed by serializing the 3 slow batches (>= 450ms)", elapsed) + } +} + +// TestSimulation_NewCeilingModel_RunsAllConcurrently is the direct comparison: +// the same review13536Shape, but with a ceiling of 16 (this plan's new +// default) instead of 2. All 7 batches fit under the ceiling, so they all +// run at once - total wall-clock should be bounded by the single SLOWEST +// task (~300ms), not by the sum/serialization of the slow ones. +func TestSimulation_NewCeilingModel_RunsAllConcurrently(t *testing.T) { + q := NewTaskQueue(16) + for _, task := range buildTasks(review13536Shape()) { + q.AddTask(task) + } + + start := time.Now() + results := q.ProcessAll(context.Background()) + elapsed := time.Since(start) + + if len(results) != 7 { + t.Fatalf("got %d results, want 7", len(results)) + } + for id, r := range results { + if r.Error != nil { + t.Fatalf("task %s failed: %v", id, r.Error) + } + } + + // Bounded by the single slowest task (300ms) plus scheduling slop, not + // by any serialization - this is the ~2x+ measured improvement over the + // old model's >= 450ms floor. + if elapsed > 400*time.Millisecond { + t.Fatalf("new ceiling-16 model finished in %v, expected ~300ms (all batches run concurrently, bounded by the single slowest one)", elapsed) + } +} + +// TestSimulation_StarvationUnderDeadline reproduces the exact review-13536 +// bug directly: 3 "hog" tasks that each run for nearly the entire review +// budget, and 4 "fast" tasks that would succeed almost instantly *if given a +// worker in time*. Under the OLD model (2 workers), the fast tasks never get +// a worker before the outer deadline fires and are cancelled uncompleted - +// exactly what happened to batches 4-7. Under the NEW model (ceiling >= 7), +// every fast task gets to run immediately alongside the hogs and completes +// well before the deadline. +func TestSimulation_StarvationUnderDeadline(t *testing.T) { + const fastDuration = time.Millisecond + const deadline = 200 * time.Millisecond // the "review timeout" for this test + + // Matches review 13536's real shape, not three equal-duration hogs: + // batch-1 occupied ONE worker slot for virtually the entire 10-minute + // review (it needed two back-to-back ~5min attempts); batch-2 (one + // ~5min attempt) and batch-3 (dispatched right after, also ~5min) + // together kept occupying the OTHER slot for the full duration too. + // Net effect: both slots stay continuously busy for the whole deadline + // window, so batch-4..7 never get a slot at all - not "mostly don't." + buildShape := func() []Task { + return []Task{ + &simTask{id: "hog-1", duration: 250 * time.Millisecond}, // spans the whole deadline alone + &simTask{id: "hog-2", duration: 90 * time.Millisecond}, // frees early... + &simTask{id: "hog-3", duration: 150 * time.Millisecond}, // ...but hog-3 immediately re-occupies that slot past the deadline + &simTask{id: "fast-1", duration: fastDuration}, + &simTask{id: "fast-2", duration: fastDuration}, + &simTask{id: "fast-3", duration: fastDuration}, + &simTask{id: "fast-4", duration: fastDuration}, + } + } + + countFastSuccesses := func(results map[string]*TaskResult) int { + n := 0 + for id, r := range results { + if len(id) >= 4 && id[:4] == "fast" && r.Error == nil { + n++ + } + } + return n + } + + t.Run("old model: 2 workers lets fast tasks die uncompleted", func(t *testing.T) { + q := NewTaskQueue(2) + for _, task := range buildShape() { + q.AddTask(task) + } + ctx, cancel := context.WithTimeout(context.Background(), deadline) + defer cancel() + + results := q.ProcessAll(ctx) + fastOK := countFastSuccesses(results) + if fastOK != 0 { + t.Fatalf("old 2-worker model let %d/4 fast tasks complete, want 0 - both slots stay continuously busy with hogs for the entire deadline window, so no fast task should ever get a slot (this is exactly what happened to review 13536's batch-4..7)", fastOK) + } + }) + + t.Run("new model: ceiling >= 7 lets all fast tasks complete despite the hogs", func(t *testing.T) { + q := NewTaskQueue(16) + for _, task := range buildShape() { + q.AddTask(task) + } + ctx, cancel := context.WithTimeout(context.Background(), deadline) + defer cancel() + + results := q.ProcessAll(ctx) + fastOK := countFastSuccesses(results) + if fastOK != 4 { + t.Fatalf("new ceiling model: %d/4 fast tasks completed before the deadline, want all 4 (every batch should get a worker immediately, regardless of the 3 hogs)", fastOK) + } + }) +} + +// TestSimulation_RetryLoopBailsImmediatelyOnCancellation proves the +// break-only-exits-select bug fix: a task that always errors, under a +// context that's cancelled mid-retry, must not keep calling Execute after +// cancellation - Retries should reflect bailing out promptly, and Execute +// must not be invoked after ctx is done. +func TestSimulation_RetryLoopBailsImmediatelyOnCancellation(t *testing.T) { + var started atomic.Int64 + task := &alwaysFailsTask{id: "batch-x", started: &started} + + q := NewTaskQueue(1) + q.SetRetryDelay(5 * time.Millisecond) + q.AddTask(task) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(10 * time.Millisecond) + cancel() + }() + + results := q.ProcessAll(ctx) + r, ok := results["batch-x"] + if !ok { + t.Fatal("missing result for batch-x") + } + if r.Error == nil { + t.Fatal("expected an error after cancellation, got nil") + } + + // Each attempt takes ~1ms and retryDelay is 5ms; cancellation fires at + // ~10ms. The old (buggy) loop would keep calling Execute every retry + // iteration near-instantly once ctx was done, racking up well beyond + // what a properly-bailing loop would attempt. A generous upper bound + // catches the regression without being timing-flaky. + if got := started.Load(); got > 6 { + t.Fatalf("task.Execute was called %d times after a 10ms cancellation with instant-failing attempts - the retry loop did not bail promptly on ctx cancellation (break-only-exits-select regression)", got) + } +} + +type alwaysFailsTask struct { + id string + started *atomic.Int64 +} + +func (a *alwaysFailsTask) Execute(ctx context.Context) (interface{}, error) { + a.started.Add(1) + return nil, fmt.Errorf("simulated failure") +} +func (a *alwaysFailsTask) ID() string { return a.id } +func (a *alwaysFailsTask) MaxRetries() int { return 50 } diff --git a/internal/docindex/docs/.synced-commits.env b/internal/docindex/docs/.synced-commits.env index b7548fe7d..eafc690f3 100644 --- a/internal/docindex/docs/.synced-commits.env +++ b/internal/docindex/docs/.synced-commits.env @@ -1,4 +1,4 @@ GIT_LRC_COMMIT=5b11de1daa1c8d3f861b3571f85bff6056e53bd7 GIT_LRC_WIKI_COMMIT=a1bb17c392a46c1f3bf6cfad6396813ccf80a1a9 -HEXMOSHOMEPAGE_DOCS_COMMIT=642dee4dffacd462b38ca72e77c7521a66e68325 +HEXMOSHOMEPAGE_DOCS_COMMIT=65862078366491b0e4ce210b49556bca64898ae4 LIVEREVIEW_WIKI_COMMIT=8ade37693a4271c8785f2321dccc09e440797881 diff --git a/internal/docindex/docs/routes_guide/ai/ai-providers.md b/internal/docindex/docs/routes_guide/ai/ai-providers.md index 6e1ee95cd..3b696c0c8 100644 --- a/internal/docindex/docs/routes_guide/ai/ai-providers.md +++ b/internal/docindex/docs/routes_guide/ai/ai-providers.md @@ -25,6 +25,12 @@ typically an owner/admin action (enforced server-side). Ollama, Bedrock have dedicated forms for their unique config). - Set org-wide review AI settings / adaptive review behavior (`AdaptiveReviewInfo`). +- **Review Performance** section: optionally set a preferred max concurrent + batches and/or review timeout (seconds) for this org's reviews. Leave + blank to use the instance default. Always clamped server-side to the + instance-wide ceiling set in + [Settings → Storage → AI Review Performance](../settings/storage.md) - an + org can turn these down, never above what the instance allows. - View usage tips (`UsageTips`) for getting good review quality. ## Related pages diff --git a/internal/docindex/docs/routes_guide/explore/merge-requests.md b/internal/docindex/docs/routes_guide/explore/merge-requests.md index 3e46f0c26..a3d759c34 100644 --- a/internal/docindex/docs/routes_guide/explore/merge-requests.md +++ b/internal/docindex/docs/routes_guide/explore/merge-requests.md @@ -14,7 +14,8 @@ Any authenticated org member. ## Key actions -- Search/filter/sort PRs by provider, repo, state, etc. +- Search/filter/sort PRs by provider, repo, state, etc. Column widths can be + resized by dragging the edge of a column header. - Click a row to see that PR's review history in the [Reviews list](../reviews/reviews-list.md). Rows are real links, so right-click gives "Open in new tab" and middle/Ctrl-click work anywhere in the row; the PR title still links to the provider, and the diff --git a/internal/docindex/docs/routes_guide/reviews/review-detail.md b/internal/docindex/docs/routes_guide/reviews/review-detail.md index faeebf1c2..630bc213b 100644 --- a/internal/docindex/docs/routes_guide/reviews/review-detail.md +++ b/internal/docindex/docs/routes_guide/reviews/review-detail.md @@ -19,11 +19,43 @@ Any authenticated org member; the review must belong to their org ## Key actions - View the diff being reviewed (`DiffViewerPanel`) alongside AI findings. -- View the event/log timeline of the review run (`ReviewEventsPage`). +- View the event/log timeline of the review run (`ReviewEventsPage`), with a + **Tail Log** toggle in the Raw Events view that pins the log to the bottom + and auto-scrolls as new events arrive (off by default, so normal scrolling + isn't disrupted until you turn it on). Raw-event resiliency pills (e.g. + "circuit breaker") group repeated occurrences of the same underlying + failure into one pill with a count, expandable to see each individual + occurrence's timestamp. - View accounting: which stages ran, tokens/cost per stage (`getReviewAccounting`). -- View the commits included in this review (up to a preview limit, expandable). +- View the commits included in this review (up to a preview limit, expandable), + each with a one-line commit message underneath (truncated with a hover + tooltip for the full text when available). - Navigate back to the reviews list. +- The info bar shows a live-ticking **Elapsed** stat next to Last activity + while a review is running, and the **Events** tab label carries a live + count badge so progress is visible without switching tabs. The **Batches** + count in the expanded "Review details" panel self-corrects within 15s if + the page was opened before any batch had started. +- Enable browser push notifications from the closeable banner shown above the + tabs (shown until you enable notifications or close it; it never + auto-dismisses) — see [Settings → Notifications](../settings/notifications.md). +- Switch between the Findings/Accounting/Events/Auto Approve tabs — the + current tab is kept in the URL (`?tab=...`), so reloading the page or + sharing the link lands back on the same tab, and the browser Back/Forward + buttons step through tab switches. +- While a review is still running, the Findings tab's "Review in progress" + banner shows a time-to-first-comment readout — "Elapsed Xs • waiting for + first result" before anything has streamed in, then "First result in Xs • + N so far" once the first finding renders — and each individual finding + shows its own "Appeared in Xs" timing next to its confidence/type tags. + Findings that stream in live stay visible even if the review later ends in + "failed" (a single bad batch can empty the final result without discarding + what already streamed); they're only replaced by the authoritative final + set once the review completes successfully. + +A total comment count is shown just above the summary panel in the Findings +tab, so you can see the overall finding count without opening the filter bar. ## When comments couldn't be posted to the PR diff --git a/internal/docindex/docs/routes_guide/reviews/reviews-list.md b/internal/docindex/docs/routes_guide/reviews/reviews-list.md index 5f820e907..41b121df3 100644 --- a/internal/docindex/docs/routes_guide/reviews/reviews-list.md +++ b/internal/docindex/docs/routes_guide/reviews/reviews-list.md @@ -16,7 +16,8 @@ Any authenticated org member. ## Key actions - Search/filter/sort reviews (by status, repo, provider, etc.) using - column header filters and a search box. + column header filters and a search box. Column widths can be resized by + dragging the edge of a column header. - Click a row to open [Review Detail](review-detail.md). Rows are real links, so right-click gives "Open in new tab" and middle/Ctrl-click work anywhere in the row. - Start a new review via "New Review" (`/reviews/new`). diff --git a/internal/docindex/docs/routes_guide/settings/notifications.md b/internal/docindex/docs/routes_guide/settings/notifications.md new file mode 100644 index 000000000..38937d762 --- /dev/null +++ b/internal/docindex/docs/routes_guide/settings/notifications.md @@ -0,0 +1,38 @@ +# Settings → Notifications + +**Route:** `/settings#notifications` +**Who sees it:** every authenticated user (self-service, no org-role gate) + +## Purpose + +Control browser push notifications for reviews you personally trigger. LiveReview +can notify you the moment the first finding is ready, and again when the review +finishes (completed or failed) — even if the LiveReview tab or browser is closed, +using the Web Push API and a service worker. Reviews with no identifiable triggering +user (scheduled or webhook-triggered reviews) never send these notifications; this +tab only affects reviews started from a logged-in session. + +## Key actions + +- Enable or disable browser push notifications for this specific browser/device. + Granting permission can also happen inline, right on a review's detail page, via + an "Enable notifications" banner shown there (closeable, not auto-dismissed), which + also links back to this page — no need to visit this page first. +- If the browser has already blocked notifications for this site, this page (and the + review-detail banner) shows guidance instead of the Enable button: click the lock or + info icon next to the browser's address bar, allow notifications there, then reload — + browsers never let a site re-trigger the native permission prompt once blocked. +- Toggle "First finding is ready" — notified as soon as the first batch of results + streams in for a review you triggered. On by default. +- Toggle "Review completed or failed" — notified when the whole review finishes. + On by default. A review that finished with errors is titled "Review completed + with errors" rather than "Review failed", since most failures here are partial + (one batch erroring), not a total loss of the review. +- Each notification names the review (`#`, `owner/repo`, and the PR/MR title + when known) so it's recognizable without opening it, and clicking it opens that + review's detail page directly (focusing an already-open tab if one matches, + otherwise opening a new one). + +## Related pages + +[Settings overview](settings-overview.md), [Account](account.md) diff --git a/internal/docindex/docs/routes_guide/settings/settings-overview.md b/internal/docindex/docs/routes_guide/settings/settings-overview.md index add9dd3d6..14c457eaf 100644 --- a/internal/docindex/docs/routes_guide/settings/settings-overview.md +++ b/internal/docindex/docs/routes_guide/settings/settings-overview.md @@ -37,7 +37,7 @@ the `subscriptions` tab. ## Related pages -Each tab has its own file in this folder: [Account](account.md), [Instance](instance.md), +Each tab has its own file in this folder: [Account](account.md), [Notifications](notifications.md), [Instance](instance.md), [SMTP](smtp.md), [Storage](storage.md), [Deployment](deployment.md), [License](license.md), [SSO](sso.md), [Managed AI](managed-ai.md), [Prompts](prompts.md), [Auto Approve](auto-approve.md), [Learnings](learnings.md), diff --git a/internal/docindex/docs/routes_guide/settings/storage.md b/internal/docindex/docs/routes_guide/settings/storage.md index 55573f20b..b94ca5e87 100644 --- a/internal/docindex/docs/routes_guide/settings/storage.md +++ b/internal/docindex/docs/routes_guide/settings/storage.md @@ -16,13 +16,14 @@ switching backends or rotating credentials needs no redeploy. See ## Layout -The page has four sub-tabs, each with its own Save button. The selected one +The page has five sub-tabs, each with its own Save button. The selected one is kept in the URL (`/settings?section=#storage`), so links open it directly: - **Blob Storage** (`section=blob`, the default) - **Log Compaction** (`section=log-compaction`) - **Preloaded Changes Archival** (`section=archival`) - **Repo Cache** (`section=repo-cache`) +- **AI Review Performance** (`section=ai-review-performance`) ## Key actions @@ -41,6 +42,17 @@ is kept in the URL (`/settings?section=#storage`), so links open it directly Backed by the `system_settings` row `blast_radius_cache` (`/api/v1/admin/settings/blast-radius-cache`). +- **AI Review Performance** sub-tab: set instance-wide safety bounds for AI + review concurrency and timeout - the max concurrent batches ceiling + (default 16) and the min/max review timeout in seconds (default 60s floor, + 240s ceiling). These bound, but don't replace, the self-tuning + per-AI-connector concurrency limiter (`internal/llm/adaptivelimiter`) that + discovers the actual safe concurrency for your provider at runtime, and + they also bound any organization's own optional preference set on the + [AI Providers](../ai/ai-providers.md) page (an org can turn its own + concurrency/timeout down, never above what the instance allows). Backed by + the `system_settings` row `ai_review_performance` + (`/api/v1/admin/settings/ai-review-performance`). ## Related pages diff --git a/internal/jobqueue/review_worker.go b/internal/jobqueue/review_worker.go index 9998937df..ec70fa944 100644 --- a/internal/jobqueue/review_worker.go +++ b/internal/jobqueue/review_worker.go @@ -322,10 +322,14 @@ func (w *DiffReviewWorker) Work(ctx context.Context, job *river.Job[DiffReviewJo aiFactory = mockFactory } + reviewConfig := review.DefaultReviewConfig() + reviewConfig.MaxConcurrentBatches = selection.EffectiveMaxConcurrentBatches + reviewConfig.ReviewTimeout = time.Duration(selection.EffectiveReviewTimeoutSeconds) * time.Second + result := review.NewService( review.NewStandardProviderFactory(), aiFactory, - review.DefaultReviewConfig(), + reviewConfig, ).ProcessReview(ctx, reviewRequest) status := "failed" @@ -408,10 +412,10 @@ 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"` - DiffClassification *models.DiffClassification `json:"diffClassification,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, DiffClassification: diffClassification} meta := map[string]interface{}{"review_result": payload} diff --git a/internal/jobqueue/scheduled_review_worker.go b/internal/jobqueue/scheduled_review_worker.go index ce9796dc9..22ef299aa 100644 --- a/internal/jobqueue/scheduled_review_worker.go +++ b/internal/jobqueue/scheduled_review_worker.go @@ -9,10 +9,10 @@ import ( "time" "github.com/livereview/internal/aiselection" + "github.com/livereview/internal/githubapp" "github.com/livereview/internal/license" "github.com/livereview/internal/logging" "github.com/livereview/internal/naming" - "github.com/livereview/internal/githubapp" githubprovider "github.com/livereview/internal/providers/github" "github.com/livereview/internal/review" reviewprocessor "github.com/livereview/internal/review_processor" @@ -230,10 +230,14 @@ func (w *ScheduledReviewWorker) Work(ctx context.Context, job *river.Job[Schedul PreloadedChanges: diffs, } + reviewConfig := review.DefaultReviewConfig() + reviewConfig.MaxConcurrentBatches = selection.EffectiveMaxConcurrentBatches + reviewConfig.ReviewTimeout = time.Duration(selection.EffectiveReviewTimeoutSeconds) * time.Second + result := review.NewService( review.NewStandardProviderFactory(), review.NewStandardAIProviderFactory(), - review.DefaultReviewConfig(), + reviewConfig, ).ProcessReview(ctx, reviewRequest) status := "failed" diff --git a/internal/llm/adaptivelimiter/adaptivelimiter.go b/internal/llm/adaptivelimiter/adaptivelimiter.go new file mode 100644 index 000000000..ed8651105 --- /dev/null +++ b/internal/llm/adaptivelimiter/adaptivelimiter.go @@ -0,0 +1,189 @@ +// Package adaptivelimiter implements a self-tuning concurrency limiter for +// calls to a single AI provider connector, shared across every review +// currently running against that connector. LiveReview can run many reviews +// concurrently (internal/jobqueue/queue_config.go's reviewWorkers), so a +// per-review concurrency cap alone can't protect a shared provider API key +// from being overwhelmed - this is the process-wide second tier. +// +// It uses an AIMD (additive-increase / multiplicative-decrease) schedule, the +// same algorithm family as TCP congestion control: on a run of successful +// calls it slowly raises how much concurrency it allows; the moment a call +// is classified as rate-limited (see internal/llm/errclass), it immediately +// halves the allowance. This means nobody has to guess the provider's real +// rate limit up front - the limiter discovers it by probing just past it and +// backing off, and settles near the real ceiling on its own. +package adaptivelimiter + +import ( + "context" + "sync" + "time" +) + +// Config bounds a Limiter. Floor and Ceiling are safety bounds, not targets - +// the limiter is free to settle anywhere between them. +type Config struct { + // Floor is the minimum allowed concurrency; the limiter never drops + // below this even under sustained rate-limiting. Defaults to 1. + Floor int + // Ceiling is a safety cap on concurrency. Defaults to Floor if unset or + // smaller than Floor. + Ceiling int + // Initial is the starting capacity, clamped into [Floor, Ceiling]. + // Defaults to Floor. + Initial int + // IncreaseEvery is how many consecutive successful calls are required + // before capacity is raised by 1. Defaults to 5. + IncreaseEvery int + // PollInterval is how often Acquire re-checks for a free slot while + // waiting. Defaults to 20ms; tests may set this lower for speed. + PollInterval time.Duration +} + +// Limiter is a self-tuning concurrency limiter. Safe for concurrent use. +type Limiter struct { + mu sync.Mutex + inFlight int + capacity int + floor int + ceiling int + + successStreak int + increaseEvery int + pollInterval time.Duration +} + +// New creates a Limiter from cfg, applying defaults for any zero fields. +func New(cfg Config) *Limiter { + floor := max(cfg.Floor, 1) + ceiling := max(cfg.Ceiling, floor) + initial := max(cfg.Initial, floor) + initial = min(initial, ceiling) + increaseEvery := cfg.IncreaseEvery + if increaseEvery <= 0 { + increaseEvery = 5 + } + pollInterval := cfg.PollInterval + if pollInterval <= 0 { + pollInterval = 20 * time.Millisecond + } + + return &Limiter{ + capacity: initial, + floor: floor, + ceiling: ceiling, + increaseEvery: increaseEvery, + pollInterval: pollInterval, + } +} + +// Acquire blocks until a slot is available or ctx is done. On success it +// returns a release function the caller must call exactly once when the +// gated call finishes, regardless of outcome (defer it immediately). +func (l *Limiter) Acquire(ctx context.Context) (release func(), err error) { + for { + l.mu.Lock() + if l.inFlight < l.capacity { + l.inFlight++ + l.mu.Unlock() + var once sync.Once + return func() { + once.Do(func() { + l.mu.Lock() + l.inFlight-- + l.mu.Unlock() + }) + }, nil + } + poll := l.pollInterval + l.mu.Unlock() + + select { + case <-ctx.Done(): + return func() {}, ctx.Err() + case <-time.After(poll): + } + } +} + +// ReportSuccess records a successful call. After IncreaseEvery consecutive +// successes, capacity is raised by 1 (capped at the configured ceiling). +func (l *Limiter) ReportSuccess() { + l.mu.Lock() + defer l.mu.Unlock() + l.successStreak++ + if l.successStreak >= l.increaseEvery && l.capacity < l.ceiling { + l.capacity++ + l.successStreak = 0 + } +} + +// ReportRateLimited records a call the caller classified as rate-limited +// (see internal/llm/errclass.Classify). Capacity is immediately halved, +// never going below the configured floor. +func (l *Limiter) ReportRateLimited() { + l.mu.Lock() + defer l.mu.Unlock() + l.successStreak = 0 + l.capacity = max(l.capacity/2, l.floor) +} + +// ReportOther records a failure that was neither a success nor a rate-limit +// signal (a generic timeout, a permanent error, etc.). Capacity is left +// unchanged - a timeout is not a capacity signal - but the success streak is +// reset so a brief run of unrelated errors isn't later miscounted as +// consecutive successes once things recover. +func (l *Limiter) ReportOther() { + l.mu.Lock() + defer l.mu.Unlock() + l.successStreak = 0 +} + +// Capacity returns the limiter's current allowed concurrency. +func (l *Limiter) Capacity() int { + l.mu.Lock() + defer l.mu.Unlock() + return l.capacity +} + +// InFlight returns the number of currently acquired (not yet released) slots. +func (l *Limiter) InFlight() int { + l.mu.Lock() + defer l.mu.Unlock() + return l.inFlight +} + +// registry holds one Limiter per AI connector identity, so every review +// sharing a connector's API key draws from the same adaptive capacity instead +// of each review getting its own independent (and therefore uncoordinated) +// limiter. The key is a string rather than a database connector ID because +// callers (internal/batch's BatchTask) don't always have a DB row ID in hand +// at the point they need to gate a call - a stable identity string (e.g. +// ":") is sufficient: it still gives every +// distinct configured AI connector its own independently-tuned capacity. +var ( + registryMu sync.Mutex + registry = map[string]*Limiter{} +) + +// ForConnector returns the shared Limiter for connectorKey, creating it with +// cfg on first use. Subsequent calls for the same connectorKey ignore cfg and +// return the existing instance - the limiter's whole point is one shared, +// evolving state per connector, not a fresh one per call. +func ForConnector(connectorKey string, cfg Config) *Limiter { + registryMu.Lock() + defer registryMu.Unlock() + if l, ok := registry[connectorKey]; ok { + return l + } + l := New(cfg) + registry[connectorKey] = l + return l +} + +// ResetRegistry clears all shared limiters. Exported for tests only. +func ResetRegistry() { + registryMu.Lock() + defer registryMu.Unlock() + registry = map[string]*Limiter{} +} diff --git a/internal/llm/adaptivelimiter/adaptivelimiter_test.go b/internal/llm/adaptivelimiter/adaptivelimiter_test.go new file mode 100644 index 000000000..923139abd --- /dev/null +++ b/internal/llm/adaptivelimiter/adaptivelimiter_test.go @@ -0,0 +1,231 @@ +package adaptivelimiter + +import ( + "context" + "sync" + "testing" + "time" +) + +func TestAcquireRelease_BasicGating(t *testing.T) { + l := New(Config{Floor: 1, Ceiling: 2, Initial: 2, PollInterval: time.Millisecond}) + ctx := context.Background() + + rel1, err := l.Acquire(ctx) + if err != nil { + t.Fatalf("Acquire 1: %v", err) + } + rel2, err := l.Acquire(ctx) + if err != nil { + t.Fatalf("Acquire 2: %v", err) + } + if l.InFlight() != 2 { + t.Fatalf("InFlight = %d, want 2", l.InFlight()) + } + + // A third Acquire must block until a slot frees. + acquired := make(chan struct{}) + go func() { + rel3, err := l.Acquire(ctx) + if err != nil { + t.Errorf("Acquire 3: %v", err) + return + } + close(acquired) + rel3() + }() + + select { + case <-acquired: + t.Fatal("third Acquire returned before a slot was released") + case <-time.After(30 * time.Millisecond): + } + + rel1() + select { + case <-acquired: + case <-time.After(200 * time.Millisecond): + t.Fatal("third Acquire did not unblock after a release") + } + rel2() +} + +func TestAcquire_ContextCancelled(t *testing.T) { + l := New(Config{Floor: 1, Ceiling: 1, Initial: 1, PollInterval: time.Millisecond}) + release, err := l.Acquire(context.Background()) + if err != nil { + t.Fatalf("Acquire: %v", err) + } + defer release() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + _, err = l.Acquire(ctx) + if err == nil { + t.Fatal("Acquire with full capacity and cancelled ctx should return an error") + } +} + +func TestReleaseIsIdempotent(t *testing.T) { + l := New(Config{Floor: 1, Ceiling: 1, Initial: 1}) + release, err := l.Acquire(context.Background()) + if err != nil { + t.Fatalf("Acquire: %v", err) + } + release() + release() + release() + if l.InFlight() != 0 { + t.Fatalf("InFlight = %d after idempotent releases, want 0", l.InFlight()) + } +} + +func TestAIMD_AdditiveIncreaseOnSustainedSuccess(t *testing.T) { + l := New(Config{Floor: 1, Ceiling: 10, Initial: 1, IncreaseEvery: 3}) + if l.Capacity() != 1 { + t.Fatalf("Capacity = %d, want 1", l.Capacity()) + } + for i := 0; i < 3; i++ { + l.ReportSuccess() + } + if got := l.Capacity(); got != 2 { + t.Fatalf("Capacity after 3 successes (IncreaseEvery=3) = %d, want 2", got) + } + for i := 0; i < 3; i++ { + l.ReportSuccess() + } + if got := l.Capacity(); got != 3 { + t.Fatalf("Capacity after 6 successes = %d, want 3", got) + } +} + +func TestAIMD_NeverExceedsCeiling(t *testing.T) { + l := New(Config{Floor: 1, Ceiling: 4, Initial: 4, IncreaseEvery: 1}) + for i := 0; i < 50; i++ { + l.ReportSuccess() + } + if got := l.Capacity(); got != 4 { + t.Fatalf("Capacity = %d, want capped at ceiling 4", got) + } +} + +func TestAIMD_MultiplicativeDecreaseOnRateLimit(t *testing.T) { + l := New(Config{Floor: 1, Ceiling: 32, Initial: 16}) + l.ReportRateLimited() + if got := l.Capacity(); got != 8 { + t.Fatalf("Capacity after one rate-limit from 16 = %d, want 8", got) + } + l.ReportRateLimited() + if got := l.Capacity(); got != 4 { + t.Fatalf("Capacity after second rate-limit = %d, want 4", got) + } +} + +func TestAIMD_NeverBelowFloorEvenUnderSustainedRateLimiting(t *testing.T) { + l := New(Config{Floor: 2, Ceiling: 32, Initial: 16}) + for i := 0; i < 20; i++ { + l.ReportRateLimited() + } + if got := l.Capacity(); got != 2 { + t.Fatalf("Capacity under sustained rate-limiting = %d, want floor 2", got) + } +} + +func TestAIMD_GenericFailureDoesNotShrinkCapacity(t *testing.T) { + l := New(Config{Floor: 1, Ceiling: 32, Initial: 10}) + for i := 0; i < 20; i++ { + l.ReportOther() + } + if got := l.Capacity(); got != 10 { + t.Fatalf("Capacity after 20 generic (non-rate-limit) failures = %d, want unchanged 10 - a timeout is not a capacity signal", got) + } +} + +// TestAIMD_ConvergesToSecretCapacity is the core simulation proof: a fake +// provider has a secret true concurrent capacity. Calls made while fewer +// than that many are in flight succeed; calls made at or above it come back +// rate-limited. Many "reviews" hammer the shared limiter concurrently with +// far more demand than the secret capacity. The limiter must settle near +// (at or a little under) the secret capacity - proving it discovers the +// real ceiling by probing and backing off, the way an admin-guessed static +// number never could. +func TestAIMD_ConvergesToSecretCapacity(t *testing.T) { + const secretCapacity = 10 + const numReviews = 3 + const batchesPerReview = 15 // 45 total demand vs. a true capacity of 10 + + limiter := New(Config{Floor: 1, Ceiling: 64, Initial: 1, IncreaseEvery: 3, PollInterval: time.Millisecond}) + + var liveMu sync.Mutex + live := 0 + + callProvider := func() (ok bool) { + liveMu.Lock() + live++ + over := live > secretCapacity + liveMu.Unlock() + + time.Sleep(time.Millisecond) // simulate call latency + + liveMu.Lock() + live-- + liveMu.Unlock() + return !over + } + + var wg sync.WaitGroup + for r := 0; r < numReviews; r++ { + for b := 0; b < batchesPerReview; b++ { + wg.Add(1) + go func() { + defer wg.Done() + release, err := limiter.Acquire(context.Background()) + if err != nil { + return + } + defer release() + if callProvider() { + limiter.ReportSuccess() + } else { + limiter.ReportRateLimited() + } + }() + } + } + wg.Wait() + + final := limiter.Capacity() + // The limiter should have found its way to within a reasonable band of + // the secret capacity - neither stuck far below it (under-utilizing) + // nor wildly above it (would mean AIMD isn't backing off on signal). + if final < 2 || final > secretCapacity+5 { + t.Fatalf("final capacity = %d, want roughly near secret capacity %d (band [2, %d])", final, secretCapacity, secretCapacity+5) + } +} + +// TestAIMD_SecondConnectorUnaffected proves per-connector isolation: hammering +// one connector's limiter with rate-limit signals must not affect another +// connector's independently-tracked capacity. +func TestForConnector_PerConnectorIsolation(t *testing.T) { + ResetRegistry() + defer ResetRegistry() + + a := ForConnector("connector-a", Config{Floor: 1, Ceiling: 32, Initial: 16}) + b := ForConnector("connector-b", Config{Floor: 1, Ceiling: 32, Initial: 16}) + + for i := 0; i < 5; i++ { + a.ReportRateLimited() + } + if a.Capacity() == 16 { + t.Fatal("connector 1's limiter did not react to rate-limiting") + } + if b.Capacity() != 16 { + t.Fatalf("connector 2's limiter capacity = %d, want unaffected 16", b.Capacity()) + } + + // Same connector ID returns the same shared instance, not a fresh one. + again := ForConnector("connector-a", Config{Floor: 1, Ceiling: 32, Initial: 16}) + if again != a { + t.Fatal("ForConnector returned a different instance for the same connector ID") + } +} diff --git a/internal/llm/errclass/errclass.go b/internal/llm/errclass/errclass.go new file mode 100644 index 000000000..2f2f29aca --- /dev/null +++ b/internal/llm/errclass/errclass.go @@ -0,0 +1,123 @@ +// Package errclass classifies an error returned by an AI provider call into +// one of three buckets, so callers (the adaptive concurrency limiter in +// internal/llm/adaptivelimiter) can react differently to "the provider is +// rate-limiting us" than to "this call failed for an unrelated reason." +// +// github.com/tmc/langchaingo v0.1.15 defines a structured rate-limit error +// type (llms.Error, llms.ErrCodeRateLimit, llms.IsRateLimitError) with +// per-provider MapError functions (llms/openai/errors.go, +// llms/anthropic/errors.go, etc.) - but none of those MapError functions are +// actually called from the library's real request path (confirmed by reading +// the vendored source: llms/openai/openaillm.go, llms/anthropic/anthropicllm.go, +// llms/googleai/googleai.go, llms/bedrock/bedrockllm.go never invoke them). +// Every provider backend's HTTP client returns a plain string-wrapped error, +// so errors.As/llms.IsRateLimitError never match in practice. Substring +// matching on the error message, keyed by provider type, is therefore the +// only option available today - not a design preference. +package errclass + +import "strings" + +// Class is the outcome of classifying an error. +type Class int + +const ( + // Transient means the call failed for a reason unrelated to provider + // capacity (network blip, timeout, 5xx) - safe to retry, must NOT shrink + // the adaptive limiter's capacity. + Transient Class = iota + // RateLimited means the provider told us to slow down - the one signal + // that should shrink the adaptive limiter's capacity. + RateLimited + // Permanent means the call failed in a way retrying won't fix (bad + // request, auth failure, etc.). + Permanent +) + +func (c Class) String() string { + switch c { + case RateLimited: + return "rate_limited" + case Permanent: + return "permanent" + default: + return "transient" + } +} + +// ClassifyHTTPStatus classifies using a real HTTP status code, when the +// caller already has one in hand. This is the one reliable, non-string-match +// signal available - used by internal/ai/langchain/provider.go's own custom +// HTTP paths (anthropic-compatible, direct-POST), which decode the response +// status directly instead of going through langchaingo's call surface. +func ClassifyHTTPStatus(statusCode int) Class { + switch { + case statusCode == 429: + return RateLimited + case statusCode >= 500: + return Transient + case statusCode >= 400: + return Permanent + default: + return Transient + } +} + +// rateLimitSignals holds the characteristic substring(s) each provider's +// client library is observed to produce for a rate-limit/throttling response. +// Ollama has no entry: it's a local model with no provider-side rate limit. +var rateLimitSignals = map[string][]string{ + "openai": {"429", "rate limit exceeded", "rate_limit_exceeded"}, + "openai-compatible": {"429", "rate limit exceeded", "rate_limit_exceeded"}, + "anthropic": {"429", "rate_limit_error", "overloaded_error"}, + "anthropic-compatible": {"429", "rate_limit_error", "overloaded_error"}, + "googleai": {"429", "resource_exhausted", "quota exceeded", "rate limit"}, + "gemini": {"429", "resource_exhausted", "quota exceeded", "rate limit"}, + "vertex": {"429", "resource_exhausted", "quota exceeded", "rate limit"}, + "vertexai": {"429", "resource_exhausted", "quota exceeded", "rate limit"}, + "bedrock": {"throttlingexception", "toomanyrequestsexception"}, +} + +// fallbackRateLimitSignals is used for an unrecognized/unset provider type - +// broad enough to catch the common cases without being provider-specific. +var fallbackRateLimitSignals = []string{"429", "rate limit", "too many requests", "quota exceeded"} + +var transientSignals = []string{ + "timeout", "deadline exceeded", "connection reset", "connection refused", + "502", "503", "504", "temporarily unavailable", "eof", "broken pipe", +} + +// Classify classifies an error returned by a call to the given provider type +// (LiveReview's own providerType string, e.g. "openai", "anthropic", +// "googleai", "bedrock", "ollama"). providerType is matched case-insensitively; +// an empty/unrecognized value falls back to a generic signal set. +func Classify(providerType string, err error) Class { + if err == nil { + return Transient + } + msg := strings.ToLower(err.Error()) + provider := strings.ToLower(strings.TrimSpace(providerType)) + + if provider == "ollama" { + // Local model: never rate-limited by a remote provider. Still check + // transient signals below (a local timeout is still a timeout). + } else { + signals, ok := rateLimitSignals[provider] + if !ok { + signals = fallbackRateLimitSignals + } + for _, s := range signals { + if strings.Contains(msg, s) { + return RateLimited + } + } + } + + for _, s := range transientSignals { + if strings.Contains(msg, s) { + return Transient + } + } + + return Permanent +} diff --git a/internal/llm/errclass/errclass_test.go b/internal/llm/errclass/errclass_test.go new file mode 100644 index 000000000..5bd05ae78 --- /dev/null +++ b/internal/llm/errclass/errclass_test.go @@ -0,0 +1,89 @@ +package errclass + +import ( + "errors" + "testing" +) + +func TestClassifyHTTPStatus(t *testing.T) { + cases := []struct { + status int + want Class + }{ + {429, RateLimited}, + {500, Transient}, + {503, Transient}, + {400, Permanent}, + {401, Permanent}, + {200, Transient}, // not really an error path, but must not panic/misclassify as permanent + } + for _, c := range cases { + if got := ClassifyHTTPStatus(c.status); got != c.want { + t.Errorf("ClassifyHTTPStatus(%d) = %v, want %v", c.status, got, c.want) + } + } +} + +func TestClassify_RateLimitPerProvider(t *testing.T) { + cases := []struct { + provider string + errMsg string + }{ + {"openai", "API returned unexpected status code: 429: rate limit exceeded"}, + {"anthropic", "api error: 429 rate_limit_error"}, + {"googleai", "googleapi: Error 429: Resource has been exhausted (e.g. check quota)., RESOURCE_EXHAUSTED"}, + {"bedrock", "ThrottlingException: Rate exceeded"}, + {"bedrock", "operation error Bedrock Runtime: TooManyRequestsException"}, + } + for _, c := range cases { + got := Classify(c.provider, errors.New(c.errMsg)) + if got != RateLimited { + t.Errorf("Classify(%q, %q) = %v, want RateLimited", c.provider, c.errMsg, got) + } + } +} + +func TestClassify_OllamaNeverRateLimited(t *testing.T) { + // Ollama is local - even a message containing "429" must not be treated + // as a provider rate-limit signal for it. + got := Classify("ollama", errors.New("unexpected error: 429 something")) + if got == RateLimited { + t.Errorf("Classify(ollama, ...) = RateLimited, want non-rate-limited (ollama has no provider rate limit)") + } +} + +func TestClassify_TransientIsNotRateLimited(t *testing.T) { + cases := []string{ + "request timeout: API call exceeded deadline", + "context deadline exceeded", + "connection reset by peer", + "502 Bad Gateway", + "EOF", + } + for _, msg := range cases { + got := Classify("openai", errors.New(msg)) + if got != Transient { + t.Errorf("Classify(openai, %q) = %v, want Transient", msg, got) + } + } +} + +func TestClassify_PermanentFallback(t *testing.T) { + got := Classify("openai", errors.New("invalid request: missing required field 'model'")) + if got != Permanent { + t.Errorf("Classify(...) = %v, want Permanent", got) + } +} + +func TestClassify_UnrecognizedProviderUsesFallbackSignals(t *testing.T) { + got := Classify("some-future-provider", errors.New("429 too many requests")) + if got != RateLimited { + t.Errorf("Classify(unrecognized provider, 429 msg) = %v, want RateLimited via fallback signals", got) + } +} + +func TestClassify_NilError(t *testing.T) { + if got := Classify("openai", nil); got != Transient { + t.Errorf("Classify(_, nil) = %v, want Transient (no-op default)", got) + } +} diff --git a/internal/logging/review_logger.go b/internal/logging/review_logger.go index b9df61996..190c1f5b4 100644 --- a/internal/logging/review_logger.go +++ b/internal/logging/review_logger.go @@ -207,6 +207,32 @@ func (r *ReviewLogger) EmitBatchStart(batchID string, fileCount int) { _ = r.eventSink.EmitLogEvent(ctx, r.reviewIDInt, r.orgID, "info", message, batchID) } +// EmitBatchFailed emits an explicit terminal failure event for a batch that +// exhausted all retries - distinct from a per-attempt retry error, so the +// frontend (ReviewProgressView.tsx) can render this batch as genuinely +// Failed instead of falling back to a stale fileCount and rendering fake +// success. Previously only a free-text log line was emitted here, which the +// frontend's error-parsing had no reliable way to distinguish from a +// transient retry log - this is the fix for that. +func (r *ReviewLogger) EmitBatchFailed(batchID string, err error) { + if r == nil || r.eventSink == nil { + return + } + + ctx := context.Background() + errorSummary := "batch failed" + if err != nil { + errorSummary = err.Error() + } + // Status "failed" - comments is nil since none were produced. + _ = r.eventSink.EmitBatchEvent(ctx, r.reviewIDInt, r.orgID, batchID, "failed", 0, 0, nil) + + // r.Log already emits a matching "log" event through the sink (see its + // own eventSink branch above) - no separate EmitLogEvent call needed here. + message := fmt.Sprintf("Batch %s failed permanently: %s", batchID, errorSummary) + r.Log("āŒ %s", message) +} + // EmitBatchComplete emits an event when a batch completes processing func (r *ReviewLogger) EmitBatchComplete(batchID string, commentCount int, comments interface{}) { if r == nil || r.eventSink == nil { diff --git a/internal/logging/review_logger_failed_test.go b/internal/logging/review_logger_failed_test.go new file mode 100644 index 000000000..700522ec8 --- /dev/null +++ b/internal/logging/review_logger_failed_test.go @@ -0,0 +1,125 @@ +package logging + +import ( + "context" + "errors" + "os" + "testing" + "time" +) + +// fakeEventSink records every call so the test can assert on exact shape, +// instead of a real DB-backed sink. +type fakeEventSink struct { + batchEvents []fakeBatchEvent + logEvents []fakeLogEvent +} + +type fakeBatchEvent struct { + batchID string + status string + tokenEstimate int + fileCount int +} + +type fakeLogEvent struct { + level string + message string + batchID string +} + +func (f *fakeEventSink) EmitStatusEvent(ctx context.Context, reviewID, orgID int64, status string) error { + return nil +} + +func (f *fakeEventSink) EmitLogEvent(ctx context.Context, reviewID, orgID int64, level, message, batchID string) error { + f.logEvents = append(f.logEvents, fakeLogEvent{level: level, message: message, batchID: batchID}) + return nil +} + +func (f *fakeEventSink) EmitBatchEvent(ctx context.Context, reviewID, orgID int64, batchID, status string, tokenEstimate, fileCount int, comments interface{}) error { + f.batchEvents = append(f.batchEvents, fakeBatchEvent{ + batchID: batchID, status: status, tokenEstimate: tokenEstimate, fileCount: fileCount, + }) + return nil +} + +func (f *fakeEventSink) EmitArtifactEvent(ctx context.Context, reviewID, orgID int64, kind, url, batchID string, sizeBytes int64, previewHead, previewTail string) error { + return nil +} + +func (f *fakeEventSink) EmitCompletionEvent(ctx context.Context, reviewID, orgID int64, resultSummary string, commentCount int, errorSummary string) error { + return nil +} + +func newTestLogger(t *testing.T, sink EventSink) *ReviewLogger { + t.Helper() + tmpFile, err := os.CreateTemp(t.TempDir(), "review-log-*.log") + if err != nil { + t.Fatalf("CreateTemp: %v", err) + } + t.Cleanup(func() { tmpFile.Close() }) + + return &ReviewLogger{ + reviewID: "13536", + reviewIDInt: 13536, + orgID: 3, + logFile: tmpFile, + startTime: time.Now(), + eventSink: sink, + } +} + +// TestEmitBatchFailed_EmitsDistinctTerminalFailureEvent is the regression +// test for the Events-tab fake-success bug: a batch that exhausts retries +// must emit a batch event whose status is explicitly "failed" - not fold +// into the same free-text "log" event type a transient per-attempt retry +// error uses, which the frontend had no reliable way to distinguish from a +// genuine terminal failure (see ReviewProgressView.tsx's markFailed/ +// markCompleted handling). +func TestEmitBatchFailed_EmitsDistinctTerminalFailureEvent(t *testing.T) { + sink := &fakeEventSink{} + logger := newTestLogger(t, sink) + + logger.EmitBatchFailed("batch-6", errors.New("request timeout: API call exceeded deadline")) + + if len(sink.batchEvents) != 1 { + t.Fatalf("got %d batch events, want exactly 1", len(sink.batchEvents)) + } + got := sink.batchEvents[0] + if got.batchID != "batch-6" { + t.Errorf("batchID = %q, want batch-6", got.batchID) + } + if got.status != "failed" { + t.Errorf("status = %q, want %q - this is the field the frontend must key off instead of fileCount fallback", got.status, "failed") + } + + if len(sink.logEvents) != 1 { + t.Fatalf("got %d log events, want exactly 1 (r.Log's own eventSink branch, not a duplicate)", len(sink.logEvents)) + } + if sink.logEvents[0].level != "error" { + t.Errorf("log event level = %q, want error", sink.logEvents[0].level) + } +} + +func TestEmitBatchFailed_NilSafe(t *testing.T) { + var logger *ReviewLogger + logger.EmitBatchFailed("batch-1", errors.New("x")) // must not panic + + logger2 := newTestLogger(t, nil) + logger2.EmitBatchFailed("batch-1", errors.New("x")) // nil eventSink must not panic +} + +func TestEmitBatchFailed_NilErrorUsesFallbackMessage(t *testing.T) { + sink := &fakeEventSink{} + logger := newTestLogger(t, sink) + + logger.EmitBatchFailed("batch-2", nil) + + if len(sink.logEvents) != 1 { + t.Fatalf("got %d log events, want 1", len(sink.logEvents)) + } + if sink.logEvents[0].message == "" { + t.Error("expected a non-empty fallback message when err is nil") + } +} diff --git a/internal/policyengine/engine.go b/internal/policyengine/engine.go index 7c31bf3b3..0ac7abf63 100644 --- a/internal/policyengine/engine.go +++ b/internal/policyengine/engine.go @@ -322,6 +322,7 @@ func (e *Engine) riskStage(ctx context.Context, orgID, reviewID int64, p *Policy scores, _ := riskscore.LoadRecentScores(ctx, e.db, orgID, 1000) cutoff := riskscore.PercentileCutoff(scores, p.RiskThresholdPercentile) pct := riskscore.PercentileOf(score, scores) + cutoffPct := riskscore.PercentileOf(cutoff, scores) if !dryRun { info, infoErr := e.scorer.Info(ctx, orgID) @@ -331,13 +332,14 @@ func (e *Engine) riskStage(ctx context.Context, orgID, reviewID int64, p *Policy } detail := map[string]interface{}{ - "score": score, - "percentile": pct, - "cutoff": cutoff, - "threshold": p.RiskThresholdPercentile, - "features": featureTable(features), - } - detail["reason"] = riskReason(score, cutoff, pct, p.RiskThresholdPercentile, len(scores) > 0) + "score": score, + "percentile": pct, + "cutoff": cutoff, + "cutoff_percentile": cutoffPct, + "threshold": p.RiskThresholdPercentile, + "features": featureTable(features), + } + detail["reason"] = riskReason(score, cutoff, pct, cutoffPct, p.RiskThresholdPercentile, riskscore.HasEnoughHistory(scores)) if score >= cutoff { return riskStageResult{StageResult: StageResult{Stage: StageRisk, Verdict: VerdictFail, Detail: detail}, Score: score, Percentile: pct, Cutoff: cutoff} } @@ -346,16 +348,32 @@ func (e *Engine) riskStage(ctx context.Context, orgID, reviewID int64, p *Policy // riskReason builds the risk stage message. P numbers only mean something // when there are past scores to rank against, so they're left out otherwise. -func riskReason(score, cutoff float64, pct, threshold int, hasHistory bool) string { +// +// Both P-labels must answer the same question — the value's actual rank in +// the org's historical score distribution, via PercentileOf — rather than +// mixing "actual rank" for the score with "configured policy knob" for the +// cutoff. Those are different numbers, and showing both as "(P__)" falsely +// implied they were comparable (e.g. P100 vs the configured P20, for the +// same raw value). The configured threshold is still surfaced, but only +// called out by name when it diverges from the cutoff's actual rank, which +// happens for a small/skewed historical distribution. +func riskReason(score, cutoff float64, scorePct, cutoffPct, threshold int, hasHistory bool) string { scoreLabel, cutoffLabel := fmt.Sprintf("%.2f", score), fmt.Sprintf("%.2f", cutoff) if hasHistory { - scoreLabel += fmt.Sprintf(" (P%d)", pct) - cutoffLabel += fmt.Sprintf(" (P%d)", threshold) + scoreLabel += fmt.Sprintf(" (P%d)", scorePct) + cutoffLabel += fmt.Sprintf(" (P%d)", cutoffPct) + if cutoffPct != threshold { + cutoffLabel += fmt.Sprintf(", configured for P%d", threshold) + } } - if score >= cutoff { + switch { + case score > cutoff: return fmt.Sprintf("Risk score %s is above the cutoff of %s", scoreLabel, cutoffLabel) + case score < cutoff: + return fmt.Sprintf("Risk score %s is below the cutoff of %s", scoreLabel, cutoffLabel) + default: + return fmt.Sprintf("Risk score %s is at the cutoff of %s", scoreLabel, cutoffLabel) } - return fmt.Sprintf("Risk score %s is below the cutoff of %s", scoreLabel, cutoffLabel) } // featureTable renders the feature vector as a human-readable list for the UI's diff --git a/internal/policyengine/risk_reason_test.go b/internal/policyengine/risk_reason_test.go index ef837b91e..22351277b 100644 --- a/internal/policyengine/risk_reason_test.go +++ b/internal/policyengine/risk_reason_test.go @@ -19,33 +19,44 @@ func TestRiskReasonScenarios(t *testing.T) { }{ {"no history, blocked", nil, 1.0, 20, "Risk score 1.00 is above the cutoff of 0.50"}, {"no history, passes", nil, 0.3, 20, "Risk score 0.30 is below the cutoff of 0.50"}, - {"no history, score equals default cutoff", nil, 0.5, 20, "Risk score 0.50 is above the cutoff of 0.50"}, - {"one past score, higher score blocked", []float64{0.4}, 0.9, 20, "Risk score 0.90 (P100) is above the cutoff of 0.40 (P20)"}, - {"one past score, lower score passes", []float64{0.4}, 0.2, 20, "Risk score 0.20 (P0) is below the cutoff of 0.40 (P20)"}, + {"no history, score equals default cutoff", nil, 0.5, 20, "Risk score 0.50 is at the cutoff of 0.50"}, + // Below minScoresForPercentile (10): too few points to rank + // meaningfully, so PercentileCutoff falls back to the same default + // as no history, and no P-labels are shown at all. + {"one past score, higher score blocked", []float64{0.4}, 0.9, 20, "Risk score 0.90 is above the cutoff of 0.50"}, + {"one past score, lower score passes", []float64{0.4}, 0.2, 20, "Risk score 0.20 is below the cutoff of 0.50"}, + {"five past scores (still below min sample), all tied", []float64{1, 1, 1, 1, 1}, 1.0, 20, "Risk score 1.00 is above the cutoff of 0.50"}, + // At/above minScoresForPercentile: real percentile ranking kicks in, + // and both the score's and cutoff's P-labels come from the same + // PercentileOf function, so they stay directly comparable. {"full history, blocked", ten, 0.9, 20, "Risk score 0.90 (P90) is above the cutoff of 0.20 (P20)"}, {"full history, passes", ten, 0.15, 20, "Risk score 0.15 (P10) is below the cutoff of 0.20 (P20)"}, - {"full history, score equals cutoff", ten, 0.2, 20, "Risk score 0.20 (P20) is above the cutoff of 0.20 (P20)"}, + {"full history, score equals cutoff", ten, 0.2, 20, "Risk score 0.20 (P20) is at the cutoff of 0.20 (P20)"}, {"full history, balanced passes", ten, 0.45, 50, "Risk score 0.45 (P40) is below the cutoff of 0.50 (P50)"}, - {"all past scores the same", []float64{1, 1, 1, 1, 1}, 1.0, 20, "Risk score 1.00 (P100) is above the cutoff of 1.00 (P20)"}, } for _, c := range cases { cutoff := riskscore.PercentileCutoff(c.history, c.threshold) pct := riskscore.PercentileOf(c.score, c.history) - if got := riskReason(c.score, cutoff, pct, c.threshold, len(c.history) > 0); got != c.want { + cutoffPct := riskscore.PercentileOf(cutoff, c.history) + hasHistory := riskscore.HasEnoughHistory(c.history) + if got := riskReason(c.score, cutoff, pct, cutoffPct, c.threshold, hasHistory); got != c.want { t.Errorf("[%s]\n got: %s\nwant: %s", c.name, got, c.want) } } } -// With past scores, a blocked review's P number is never below the threshold -// and a passing one's is never at or above it, so the message can't contradict itself. +// With a large enough history for PercentileCutoff to rank against, a +// blocked review's P number is never below the threshold and a passing +// one's is never at or above it, so the message can't contradict itself. +// Below the minimum sample size, PercentileCutoff intentionally decouples +// from the percentile ranking (falls back to the default cutoff instead of +// a misleading rank), so the invariant only applies once there's enough +// history for it to mean something. func TestRiskReasonPercentilesNeverContradict(t *testing.T) { histories := [][]float64{ - {0.4}, - {0.6, 0.7}, {0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0}, {0.42, 1, 1, 1, 1, 1, 1, 1, 1, 1}, - {0.05, 0.05, 0.3, 0.3, 0.3, 0.9}, + {0.05, 0.05, 0.3, 0.3, 0.3, 0.9, 0.9, 0.95, 0.99, 1.0}, } for _, h := range histories { for _, threshold := range []int{10, 20, 50, 80} { @@ -62,3 +73,24 @@ func TestRiskReasonPercentilesNeverContradict(t *testing.T) { } } } + +// Below the minimum sample size, PercentileCutoff always falls back to the +// cold-start default rather than ranking off a handful of points — this is +// what prevents the degenerate case from review 13543 (a tiny/skewed history +// producing a cutoff that collapses onto the distribution's max). +func TestPercentileCutoffFallsBackBelowMinSample(t *testing.T) { + small := [][]float64{ + nil, + {0.4}, + {0.6, 0.7}, + {1, 1, 1, 1, 1}, + {0.1, 0.9, 1, 1, 1, 1, 1, 1, 1}, + } + for _, h := range small { + for _, threshold := range []int{1, 20, 50, 100} { + if got := riskscore.PercentileCutoff(h, threshold); got != 0.5 { + t.Errorf("history %v P%d: cutoff = %.2f, want default 0.50", h, threshold, got) + } + } + } +} diff --git a/internal/policyengine/testdata/scenarios.json b/internal/policyengine/testdata/scenarios.json index d9591837e..1f7475c1f 100644 --- a/internal/policyengine/testdata/scenarios.json +++ b/internal/policyengine/testdata/scenarios.json @@ -398,7 +398,7 @@ "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, + "combined": 3, "new_lines": 10, "file_path": "a/mod.go" } @@ -438,7 +438,7 @@ "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, + "combined": 3, "new_lines": 10, "file_path": "a/mod.go" } diff --git a/internal/providers/github/github.go b/internal/providers/github/github.go index df5231c1a..87e267cb6 100644 --- a/internal/providers/github/github.go +++ b/internal/providers/github/github.go @@ -317,8 +317,8 @@ func (p *GitHubProvider) GetMergeRequestDetails(ctx context.Context, mrURL strin } // fetchPullRequestCommits returns every commit SHA on a PR, oldest first; paginates at 100/page, capped at 250 commits (GitHub's own limit for this endpoint regardless of pagination). -func (p *GitHubProvider) fetchPullRequestCommits(ctx context.Context, owner, repo, number string) ([]string, error) { - var shas []string +func (p *GitHubProvider) fetchPullRequestCommits(ctx context.Context, owner, repo, number string) ([]providers.CommitSummary, error) { + var result []providers.CommitSummary const perPage = 100 for page := 1; page <= 3; page++ { apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/pulls/%s/commits?per_page=%d&page=%d", owner, repo, number, perPage, page) @@ -333,7 +333,10 @@ func (p *GitHubProvider) fetchPullRequestCommits(ctx context.Context, owner, rep return nil, err } var commits []struct { - SHA string `json:"sha"` + SHA string `json:"sha"` + Commit struct { + Message string `json:"message"` + } `json:"commit"` } decodeErr := json.NewDecoder(resp.Body).Decode(&commits) statusCode := resp.StatusCode @@ -345,13 +348,16 @@ func (p *GitHubProvider) fetchPullRequestCommits(ctx context.Context, owner, rep return nil, decodeErr } for _, c := range commits { - shas = append(shas, c.SHA) + // Only the subject line (first line) - the body can be long/multi-paragraph + // and this is shown as a short hover label, not a full commit view. + message := strings.SplitN(c.Commit.Message, "\n", 2)[0] + result = append(result, providers.CommitSummary{SHA: c.SHA, Message: message}) } if len(commits) < perPage { break } } - return shas, nil + return result, nil } func (p *GitHubProvider) GetMergeRequestChanges(ctx context.Context, mrID string) ([]*models.CodeDiff, error) { diff --git a/internal/providers/gitlab/gitlab.go b/internal/providers/gitlab/gitlab.go index dcedeef81..b35434382 100644 --- a/internal/providers/gitlab/gitlab.go +++ b/internal/providers/gitlab/gitlab.go @@ -82,11 +82,12 @@ func (p *GitLabProvider) GetMergeRequestDetails(ctx context.Context, mrURL strin if commits, commitsErr := p.httpClient.GetMergeRequestCommits(projectID, mrIID); commitsErr != nil { fmt.Printf("[WARN] GitLabProvider: failed to fetch MR commits for project=%s mrIID=%d: %v\n", projectID, mrIID, commitsErr) } else { - shas := make([]string, 0, len(commits)) + summaries := make([]providers.CommitSummary, 0, len(commits)) for _, c := range commits { - shas = append(shas, c.ID) + // Title is GitLab's first line of the commit message (Message is the full body). + summaries = append(summaries, providers.CommitSummary{SHA: c.ID, Message: c.Title}) } - details.Commits = shas + details.Commits = summaries } return details, nil diff --git a/internal/providers/shared.go b/internal/providers/shared.go index 83db29197..5f2785670 100644 --- a/internal/providers/shared.go +++ b/internal/providers/shared.go @@ -42,8 +42,14 @@ type MergeRequestDetails struct { WebURL string ProviderType string RepositoryURL string - // Commits is every individual commit SHA on this PR/MR, oldest first - populated by providers that support fetching it (GitHub, GitLab so far); empty for the rest, who fall back to DiffRefs' head/base pair. - Commits []string + // Commits is every individual commit on this PR/MR, oldest first - populated by providers that support fetching it (GitHub, GitLab so far); empty for the rest, who fall back to DiffRefs' head/base pair. + Commits []CommitSummary +} + +// CommitSummary is a single PR/MR commit, as returned by a provider's commits endpoint. +type CommitSummary struct { + SHA string + Message string // best-effort; may be empty if the provider's response omitted it } // DiffRefs contains references to the diff endpoints diff --git a/internal/pushnotify/payload_test.go b/internal/pushnotify/payload_test.go new file mode 100644 index 000000000..15c55292c --- /dev/null +++ b/internal/pushnotify/payload_test.go @@ -0,0 +1,67 @@ +package pushnotify + +import "testing" + +func TestShortRepoLabel(t *testing.T) { + cases := map[string]string{ + "https://github.com/HexmosTech/LiveReview": "HexmosTech/LiveReview", + "https://github.com/HexmosTech/LiveReview/pull/150": "HexmosTech/LiveReview", + "git@github.com:HexmosTech/LiveReview.git": "HexmosTech/LiveReview", + "HexmosTech/LiveReview": "HexmosTech/LiveReview", + "": "", + } + for input, want := range cases { + if got := shortRepoLabel(input); got != want { + t.Errorf("shortRepoLabel(%q) = %q, want %q", input, got, want) + } + } +} + +func TestTruncate(t *testing.T) { + if got := truncate("short", 10); got != "short" { + t.Errorf("truncate short string should be unchanged, got %q", got) + } + long := "this is a much longer string than the limit allows" + got := truncate(long, 10) + if len([]rune(got)) > 11 { // 10 runes + the ellipsis rune + t.Errorf("truncate(%q, 10) = %q, too long", long, got) + } +} + +func TestBuildPayloadUsesReviewSpecifics(t *testing.T) { + info := &reviewInfo{ + repository: "https://github.com/HexmosTech/LiveReview", + mrTitle: "Stream findings live, and notify the triggering user via browser push", + } + + p := buildPayload(KindFirstFinding, 13524, info, "https://livereview.hexmos.com") + if p.URL != "https://livereview.hexmos.com/#/reviews/13524" { + t.Errorf("unexpected URL: %q", p.URL) + } + if p.Tag != "review-13524" { + t.Errorf("unexpected tag: %q", p.Tag) + } + wantSubstrings := []string{"#13524", "HexmosTech/LiveReview", "Stream findings live"} + for _, want := range wantSubstrings { + if !contains(p.Body, want) { + t.Errorf("body %q missing %q", p.Body, want) + } + } + + failed := buildPayload(KindFailed, 13524, info, "") + if failed.Title == "Review failed" { + t.Errorf("expected a softer failure title, got %q", failed.Title) + } + if failed.Title != "Review completed with errors" { + t.Errorf("unexpected failed title: %q", failed.Title) + } +} + +func contains(s, substr string) bool { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/pushnotify/pushnotify.go b/internal/pushnotify/pushnotify.go new file mode 100644 index 000000000..f3e3b4b68 --- /dev/null +++ b/internal/pushnotify/pushnotify.go @@ -0,0 +1,352 @@ +// Package pushnotify sends browser push notifications (Web Push API) for +// review progress: the first finding becoming available, and the review +// completing or failing. See /home/shrsv/.claude/plans/wiggly-floating-thunder.md +// for the design this implements. +// +// Scope is intentionally limited to reviews triggered by a logged-in session +// — Review.UserEmail (internal/review_processor's Review struct) is only +// reliably populated in that case; scheduled/webhook-triggered reviews have +// no identifiable human to notify, and silently not notifying anyone is the +// correct behavior for those, not a gap. +package pushnotify + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + "time" + + webpush "github.com/SherClockHolmes/webpush-go" +) + +// Kind identifies which of the two notification moments fired. +type Kind string + +const ( + KindFirstFinding Kind = "first_finding" + KindCompleted Kind = "completed" + KindFailed Kind = "failed" +) + +// Notifier sends review-progress push notifications. It satisfies +// internal/review_processor's ReviewNotifier interface structurally, so that +// package never needs to import this one (avoiding an import cycle, since +// this package itself has no need to depend on review_processor's types — +// a review id and org id, looked up fresh here, are enough). +type Notifier struct { + db *sql.DB +} + +// New creates a Notifier backed by db. Call reviewprocessor.SetNotifier(New(db)) +// once at server startup (internal/api/server.go's NewServer) to wire it in. +func New(db *sql.DB) *Notifier { + return &Notifier{db: db} +} + +// NotifyReviewEvent is fire-and-forget: it spawns its own goroutine with a +// context detached from the caller's, so a slow or unreachable push +// endpoint never blocks the hot paths that call it (batch-completion event +// persistence, review status updates). Errors are logged, never returned — +// a failed push must never fail the review itself. +func (n *Notifier) NotifyReviewEvent(ctx context.Context, reviewID, orgID int64, kind string) { + go func() { + dispatchCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if err := n.dispatch(dispatchCtx, reviewID, Kind(kind)); err != nil { + log.Printf("[WARN] push notification dispatch failed for review %d (kind=%s): %v", reviewID, kind, err) + } + }() +} + +type reviewInfo struct { + userEmail string + friendlyName string + repository string + prMrURL string + mrTitle string +} + +func (n *Notifier) loadReviewInfo(ctx context.Context, reviewID int64) (*reviewInfo, error) { + var info reviewInfo + var friendlyName, prMrURL, mrTitle sql.NullString + err := n.db.QueryRowContext(ctx, ` + SELECT user_email, friendly_name, repository, pr_mr_url, mr_title + FROM reviews WHERE id = $1 + `, reviewID).Scan(&info.userEmail, &friendlyName, &info.repository, &prMrURL, &mrTitle) + if err != nil { + return nil, fmt.Errorf("load review %d: %w", reviewID, err) + } + info.friendlyName = friendlyName.String + info.prMrURL = prMrURL.String + info.mrTitle = mrTitle.String + return &info, nil +} + +func (n *Notifier) resolveUserID(ctx context.Context, email string) (int64, bool, error) { + if strings.TrimSpace(email) == "" { + // No identifiable session user triggered this review (scheduled/webhook) — nothing to notify, not an error. + return 0, false, nil + } + var userID int64 + err := n.db.QueryRowContext(ctx, `SELECT id FROM users WHERE email = $1`, email).Scan(&userID) + if err == sql.ErrNoRows { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + return userID, true, nil +} + +// preferenceAllows reports whether userID has notifications enabled for +// kind. A missing user_notification_preferences row means both types are +// enabled (the agreed default) — see the migration's comment. +func (n *Notifier) preferenceAllows(ctx context.Context, userID int64, kind Kind) (bool, error) { + var notifyFirstFinding, notifyCompletion bool + err := n.db.QueryRowContext(ctx, ` + SELECT notify_first_finding, notify_completion + FROM user_notification_preferences WHERE user_id = $1 + `, userID).Scan(¬ifyFirstFinding, ¬ifyCompletion) + if err == sql.ErrNoRows { + return true, nil + } + if err != nil { + return false, err + } + if kind == KindFirstFinding { + return notifyFirstFinding, nil + } + return notifyCompletion, nil +} + +type subscriptionRow struct { + id int64 + endpoint string + p256dh string + authKey string +} + +func (n *Notifier) loadSubscriptions(ctx context.Context, userID int64) ([]subscriptionRow, error) { + rows, err := n.db.QueryContext(ctx, ` + SELECT id, endpoint, p256dh, auth_key FROM push_subscriptions WHERE user_id = $1 + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var subs []subscriptionRow + for rows.Next() { + var s subscriptionRow + if err := rows.Scan(&s.id, &s.endpoint, &s.p256dh, &s.authKey); err != nil { + return nil, err + } + subs = append(subs, s) + } + return subs, rows.Err() +} + +func (n *Notifier) deleteSubscription(ctx context.Context, id int64) { + if _, err := n.db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE id = $1`, id); err != nil { + log.Printf("[WARN] failed to prune dead push subscription %d: %v", id, err) + } +} + +type pushPayload struct { + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url,omitempty"` + Tag string `json:"tag,omitempty"` +} + +// shortRepoLabel turns a stored repository/PR value — which can be a bare +// "owner/repo", a git remote URL, or (for webui-triggered reviews) the PR/MR +// URL itself stored verbatim in the repository column — into a compact +// "owner/repo" label, by dropping a leading host segment (the one +// containing a dot) and any trailing path segments (e.g. "pull/150") past +// the first two. +func shortRepoLabel(raw string) string { + s := strings.TrimSuffix(strings.TrimSpace(raw), "/") + s = strings.TrimSuffix(s, ".git") + s = strings.TrimPrefix(s, "https://") + s = strings.TrimPrefix(s, "http://") + s = strings.TrimPrefix(s, "git@") + s = strings.ReplaceAll(s, ":", "/") + parts := strings.Split(s, "/") + if len(parts) > 0 && strings.Contains(parts[0], ".") { + parts = parts[1:] + } + if len(parts) >= 2 { + return parts[0] + "/" + parts[1] + } + if len(parts) == 1 && parts[0] != "" { + return parts[0] + } + return raw +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return strings.TrimSpace(s[:max]) + "…" +} + +// reviewDetail builds the "quickly recognize which review this is" portion +// shown in the notification body — the MR/PR title when known (most +// specific), falling back to a friendly name, then a short "owner/repo". +func reviewDetail(info *reviewInfo) string { + if title := strings.TrimSpace(info.mrTitle); title != "" { + return truncate(title, 80) + } + if info.friendlyName != "" { + return info.friendlyName + } + if info.repository != "" { + return shortRepoLabel(info.repository) + } + return "your review" +} + +func buildPayload(kind Kind, reviewID int64, info *reviewInfo, prodURL string) pushPayload { + ref := fmt.Sprintf("#%d", reviewID) + repo := shortRepoLabel(info.repository) + detail := reviewDetail(info) + identity := ref + if repo != "" { + identity = fmt.Sprintf("%s Ā· %s", ref, repo) + } + + var p pushPayload + switch kind { + case KindFirstFinding: + p = pushPayload{Title: "First finding ready", Body: fmt.Sprintf("%s — %s: first results are in.", identity, detail)} + case KindFailed: + // Not "Review failed" — most failures here are partial/recoverable + // (a batch errored, not necessarily every finding lost), so this + // avoids implying total loss before the user has even looked. + p = pushPayload{Title: "Review completed with errors", Body: fmt.Sprintf("%s — %s: didn't finish cleanly, take a look.", identity, detail)} + default: + p = pushPayload{Title: "Review completed", Body: fmt.Sprintf("%s — %s: ready to view.", identity, detail)} + } + p.Tag = fmt.Sprintf("review-%d", reviewID) + if prodURL != "" { + p.URL = strings.TrimRight(prodURL, "/") + fmt.Sprintf("/#/reviews/%d", reviewID) + } + return p +} + +func (n *Notifier) getProductionURL(ctx context.Context) string { + var url sql.NullString + if err := n.db.QueryRowContext(ctx, `SELECT livereview_prod_url FROM instance_details LIMIT 1`).Scan(&url); err != nil { + return "" + } + return strings.TrimSpace(url.String) +} + +func (n *Notifier) dispatch(ctx context.Context, reviewID int64, kind Kind) error { + // Callers fire "first_finding" on every batch that completes with + // comments (internal/review_processor/events.go's CreateBatchEvent) — + // batches run concurrently, so the atomic claim below, not the caller, + // is what guarantees only the first one actually sends a notification. + if kind == KindFirstFinding { + won, err := ClaimFirstFinding(ctx, n.db, reviewID) + if err != nil { + return fmt.Errorf("claim first finding: %w", err) + } + if !won { + return nil + } + } + + info, err := n.loadReviewInfo(ctx, reviewID) + if err != nil { + return err + } + + userID, found, err := n.resolveUserID(ctx, info.userEmail) + if err != nil { + return err + } + if !found { + return nil + } + + allowed, err := n.preferenceAllows(ctx, userID, kind) + if err != nil { + return err + } + if !allowed { + return nil + } + + subs, err := n.loadSubscriptions(ctx, userID) + if err != nil { + return err + } + if len(subs) == 0 { + return nil + } + + vapidPublic, vapidPrivate, err := loadOrCreateVAPIDKeys(ctx, n.db) + if err != nil { + return fmt.Errorf("load VAPID keys: %w", err) + } + + payload, err := json.Marshal(buildPayload(kind, reviewID, info, n.getProductionURL(ctx))) + if err != nil { + return err + } + + options := &webpush.Options{ + Subscriber: "mailto:support@hexmos.com", + VAPIDPublicKey: vapidPublic, + VAPIDPrivateKey: vapidPrivate, + TTL: 60 * 60, + } + + for _, sub := range subs { + resp, sendErr := webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{ + Endpoint: sub.endpoint, + Keys: webpush.Keys{Auth: sub.authKey, P256dh: sub.p256dh}, + }, options) + if sendErr != nil { + log.Printf("[WARN] push send failed for subscription %d: %v", sub.id, sendErr) + continue + } + resp.Body.Close() + if resp.StatusCode == http.StatusGone || resp.StatusCode == http.StatusNotFound { + // The browser revoked this subscription; stop sending to it. + n.deleteSubscription(ctx, sub.id) + continue + } + if resp.StatusCode >= 300 { + log.Printf("[WARN] push send for subscription %d got status %d", sub.id, resp.StatusCode) + } + } + + return nil +} + +// ClaimFirstFinding atomically marks reviewID as having notified its first +// finding, returning true only for the caller that won the race — batches +// run concurrently (internal/batch/task_queue.go's worker pool), so this +// must be a single atomic UPDATE, not an in-memory flag. +func ClaimFirstFinding(ctx context.Context, db *sql.DB, reviewID int64) (bool, error) { + res, err := db.ExecContext(ctx, ` + UPDATE reviews SET first_finding_notified_at = NOW() + WHERE id = $1 AND first_finding_notified_at IS NULL + `, reviewID) + if err != nil { + return false, err + } + affected, err := res.RowsAffected() + if err != nil { + return false, err + } + return affected > 0, nil +} diff --git a/internal/pushnotify/pushnotify_test.go b/internal/pushnotify/pushnotify_test.go new file mode 100644 index 000000000..95b273545 --- /dev/null +++ b/internal/pushnotify/pushnotify_test.go @@ -0,0 +1,219 @@ +package pushnotify + +import ( + "context" + "database/sql" + "os" + "sync" + "testing" + + _ "github.com/lib/pq" +) + +// testDB mirrors internal/jobqueue/pr_policy_claim_sql_test.go's testDB: +// runs the real claim-once UPDATE against a real Postgres DB rather than +// mocking it, since the whole point of ClaimFirstFinding is that the SQL +// itself (not application code) resolves the race. +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 seedOrgAndReview(t *testing.T, db *sql.DB, userEmail string) (orgID, reviewID int64) { + if err := db.QueryRow(`INSERT INTO orgs (name) VALUES ('pushnotify-test-org') RETURNING id`).Scan(&orgID); err != nil { + t.Fatalf("insert org: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM orgs WHERE id = $1`, orgID) }) + + if err := db.QueryRow(` + INSERT INTO reviews (repository, status, org_id, user_email, metadata) + VALUES ('test/repo', 'in_progress', $1, $2, '{}'::jsonb) RETURNING id`, orgID, userEmail).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 TestClaimFirstFindingOnlyOneWinnerUnderConcurrency(t *testing.T) { + db := testDB(t) + _, reviewID := seedOrgAndReview(t, db, "") + + const n = 20 + var wg sync.WaitGroup + wins := make([]bool, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + won, err := ClaimFirstFinding(context.Background(), db, reviewID) + if err != nil { + t.Errorf("ClaimFirstFinding: %v", err) + return + } + wins[i] = won + }(i) + } + wg.Wait() + + winCount := 0 + for _, w := range wins { + if w { + winCount++ + } + } + if winCount != 1 { + t.Fatalf("expected exactly 1 winner out of %d concurrent claims, got %d", n, winCount) + } +} + +func TestClaimFirstFindingSecondCallLoses(t *testing.T) { + db := testDB(t) + _, reviewID := seedOrgAndReview(t, db, "") + + first, err := ClaimFirstFinding(context.Background(), db, reviewID) + if err != nil { + t.Fatalf("first claim: %v", err) + } + if !first { + t.Fatalf("expected first claim to win") + } + + second, err := ClaimFirstFinding(context.Background(), db, reviewID) + if err != nil { + t.Fatalf("second claim: %v", err) + } + if second { + t.Fatalf("expected second claim to lose") + } +} + +func TestDispatchSkipsReviewsWithNoIdentifiableUser(t *testing.T) { + db := testDB(t) + // Scheduled/webhook-triggered reviews have an empty user_email — the + // documented "no one to notify" case, not an error. + _, reviewID := seedOrgAndReview(t, db, "") + + n := New(db) + if err := n.dispatch(context.Background(), reviewID, KindCompleted); err != nil { + t.Fatalf("dispatch should silently no-op for an empty UserEmail, got error: %v", err) + } +} + +func TestDispatchSkipsWhenUserHasNoSubscriptions(t *testing.T) { + db := testDB(t) + + var userID int64 + email := "pushnotify-test-user@example.com" + if err := db.QueryRow(` + INSERT INTO users (email, password_hash) VALUES ($1, 'x') RETURNING id + `, email).Scan(&userID); err != nil { + t.Fatalf("insert user: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }) + + _, reviewID := seedOrgAndReview(t, db, email) + + n := New(db) + // No push_subscriptions row for this user — dispatch should return + // cleanly without attempting to send anything. + if err := n.dispatch(context.Background(), reviewID, KindCompleted); err != nil { + t.Fatalf("dispatch should no-op with zero subscriptions, got error: %v", err) + } +} + +func TestPreferenceAllowsDefaultsTrueWithNoPreferenceRow(t *testing.T) { + db := testDB(t) + + var userID int64 + email := "pushnotify-pref-default-test@example.com" + if err := db.QueryRow(` + INSERT INTO users (email, password_hash) VALUES ($1, 'x') RETURNING id + `, email).Scan(&userID); err != nil { + t.Fatalf("insert user: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }) + + n := New(db) + for _, kind := range []Kind{KindFirstFinding, KindCompleted} { + allowed, err := n.preferenceAllows(context.Background(), userID, kind) + if err != nil { + t.Fatalf("preferenceAllows(%s): %v", kind, err) + } + if !allowed { + t.Fatalf("expected %s to default to allowed with no preference row", kind) + } + } +} + +func TestGetVAPIDPublicKeyGeneratesOnceAndPersists(t *testing.T) { + db := testDB(t) + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM system_settings WHERE name = 'push_vapid'`) }) + _, _ = db.Exec(`DELETE FROM system_settings WHERE name = 'push_vapid'`) + + first, err := GetVAPIDPublicKey(context.Background(), db) + if err != nil { + t.Fatalf("first GetVAPIDPublicKey: %v", err) + } + if first == "" { + t.Fatalf("expected a non-empty public key") + } + + second, err := GetVAPIDPublicKey(context.Background(), db) + if err != nil { + t.Fatalf("second GetVAPIDPublicKey: %v", err) + } + if second != first { + t.Fatalf("expected the same key to be returned on repeat calls, got %q then %q", first, second) + } +} + +func TestPreferenceAllowsRespectsStoredFalse(t *testing.T) { + db := testDB(t) + + var userID int64 + email := "pushnotify-pref-false-test@example.com" + if err := db.QueryRow(` + INSERT INTO users (email, password_hash) VALUES ($1, 'x') RETURNING id + `, email).Scan(&userID); err != nil { + t.Fatalf("insert user: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }) + + if _, err := db.Exec(` + INSERT INTO user_notification_preferences (user_id, notify_first_finding, notify_completion) + VALUES ($1, false, true) + `, userID); err != nil { + t.Fatalf("insert preferences: %v", err) + } + + n := New(db) + allowedFirst, err := n.preferenceAllows(context.Background(), userID, KindFirstFinding) + if err != nil { + t.Fatalf("preferenceAllows(first_finding): %v", err) + } + if allowedFirst { + t.Fatalf("expected notify_first_finding=false to be respected") + } + + allowedCompletion, err := n.preferenceAllows(context.Background(), userID, KindCompleted) + if err != nil { + t.Fatalf("preferenceAllows(completed): %v", err) + } + if !allowedCompletion { + t.Fatalf("expected notify_completion=true to be respected") + } +} diff --git a/internal/pushnotify/vapid.go b/internal/pushnotify/vapid.go new file mode 100644 index 000000000..dea14c331 --- /dev/null +++ b/internal/pushnotify/vapid.go @@ -0,0 +1,83 @@ +package pushnotify + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + webpush "github.com/SherClockHolmes/webpush-go" +) + +// vapidKeys is stored as the 'push_vapid' row in system_settings, the same +// name/data JSONB pattern internal/api/system_settings.go already uses for +// SMTP credentials — no new secret-storage mechanism invented here. +type vapidKeys struct { + PublicKey string `json:"public_key"` + PrivateKey string `json:"private_key"` +} + +func readVAPIDKeys(ctx context.Context, db *sql.DB) (*vapidKeys, error) { + var data []byte + err := db.QueryRowContext(ctx, `SELECT data FROM system_settings WHERE name = 'push_vapid'`).Scan(&data) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + var keys vapidKeys + if err := json.Unmarshal(data, &keys); err != nil { + return nil, err + } + return &keys, nil +} + +// loadOrCreateVAPIDKeys returns the instance's VAPID keypair, generating and +// persisting one on first use. ON CONFLICT DO NOTHING plus a re-read handles +// the race between two processes generating keys at the same time — only one +// write wins, and every caller ends up reading that same canonical pair. +func loadOrCreateVAPIDKeys(ctx context.Context, db *sql.DB) (publicKey, privateKey string, err error) { + if keys, err := readVAPIDKeys(ctx, db); err != nil { + return "", "", err + } else if keys != nil { + return keys.PublicKey, keys.PrivateKey, nil + } + + newPrivate, newPublic, err := webpush.GenerateVAPIDKeys() + if err != nil { + return "", "", fmt.Errorf("generate VAPID keys: %w", err) + } + data, err := json.Marshal(vapidKeys{PublicKey: newPublic, PrivateKey: newPrivate}) + if err != nil { + return "", "", err + } + + _, err = db.ExecContext(ctx, ` + INSERT INTO system_settings (name, data) VALUES ('push_vapid', $1) + ON CONFLICT (name) DO NOTHING + `, data) + if err != nil { + return "", "", fmt.Errorf("store VAPID keys: %w", err) + } + + // Re-read instead of trusting newPublic/newPrivate: if another process + // lost this race, this returns the winner's keys, which is what every + // subsequently-issued subscription must be signed/verified against. + keys, err := readVAPIDKeys(ctx, db) + if err != nil { + return "", "", err + } + if keys == nil { + return "", "", fmt.Errorf("VAPID keys missing immediately after insert") + } + return keys.PublicKey, keys.PrivateKey, nil +} + +// GetVAPIDPublicKey returns the instance's public VAPID key for the +// frontend's pushManager.subscribe({applicationServerKey: ...}) call, +// generating the keypair on first use. +func GetVAPIDPublicKey(ctx context.Context, db *sql.DB) (string, error) { + public, _, err := loadOrCreateVAPIDKeys(ctx, db) + return public, err +} diff --git a/internal/review/config.go b/internal/review/config.go index 267ddef54..9ebeea341 100644 --- a/internal/review/config.go +++ b/internal/review/config.go @@ -88,7 +88,14 @@ func (cs *ConfigurationService) buildAIConfig() (AIConfig, error) { // DefaultReviewConfig returns a sensible default configuration for reviews func DefaultReviewConfig() Config { return Config{ - ReviewTimeout: 10 * time.Minute, + // 4 minutes, not 10: most reviews should finish well under that, and a + // hard ceiling this long mostly served to mask the old 2-worker + // starvation bug by giving stuck batches more time to eventually get a + // turn. With up to batch.DefaultMaxConcurrentBatches batches now + // running concurrently and a ~90s per-batch call timeout (see + // internal/ai/langchain/provider.go's streamTimeout/fbTimeout), 4 + // minutes is generous headroom, not a tight cut-off. + ReviewTimeout: 4 * time.Minute, DefaultAI: "langchain", DefaultModel: gemini.DefaultModel, Temperature: 0.4, diff --git a/internal/review/service.go b/internal/review/service.go index c33d815b5..bd3b78404 100644 --- a/internal/review/service.go +++ b/internal/review/service.go @@ -43,6 +43,15 @@ type Config struct { DefaultAI string DefaultModel string Temperature float64 + + // MaxConcurrentBatches bounds how many of a review's batches run at once + // (tier 1 of the two-tier concurrency model - see + // internal/llm/adaptivelimiter for tier 2, the process-wide per-connector + // limiter). Zero means "use batch.DefaultMaxConcurrentBatches" - see + // createBatchProcessor below. Callers that resolved an org/system value + // via internal/aiselection.Selection should set this explicitly instead + // of leaving it at the zero-value default. + MaxConcurrentBatches int } // ReviewRequest contains all the information needed to perform a review @@ -92,28 +101,28 @@ type AIConfig struct { // ReviewResult contains the results of a review process type ReviewResult struct { - ReviewID string - Success bool - Error error - Summary string - CommentsCount int - Comments []*models.ReviewComment // Added to track actual comment details + ReviewID string + Success bool + Error error + Summary string + CommentsCount int + Comments []*models.ReviewComment // Added to track actual comment details // CommentsNotPosted is set when the review itself succeeded but the provider refused the comments for lack of // permission (e.g. the GitHub App is not installed on a public repo that was reviewed by URL). The findings are // still returned and stored so they can be shown in LiveReview; it holds the provider's reason. - CommentsNotPosted string - Quiz []models.QuizQuestion // Optional comprehension quiz for this review, nil if none generated + CommentsNotPosted string + 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 - PricingVersion string - InputTokens *int64 - OutputTokens *int64 - CostUSD *float64 - LeaderUsage *AIStageUsage - HelperUsage *AIStageUsage - Duration time.Duration + BillableLOC int64 + Provider string + Model string + PricingVersion string + InputTokens *int64 + OutputTokens *int64 + CostUSD *float64 + LeaderUsage *AIStageUsage + HelperUsage *AIStageUsage + Duration time.Duration } // ReviewWorkflowResult contains the full workflow result including MR details @@ -746,6 +755,13 @@ func (s *Service) createBatchProcessor(provider ai.Provider) *batch.BatchProcess // Configure retry logic (3 retries, 2s delay) processor.TaskQueueConfig.MaxRetries = 3 processor.TaskQueueConfig.RetryDelay = 2 * time.Second + // Per-review concurrency ceiling (tier 1 - see internal/batch/task_queue.go). + // s.config.MaxConcurrentBatches is 0 unless a caller explicitly resolved + // an org/system value via internal/aiselection.Selection, in which case + // ConfigureTaskQueue below (via DefaultConfig's MaxWorkers) is overridden. + if s.config.MaxConcurrentBatches > 0 { + processor.TaskQueueConfig.MaxWorkers = s.config.MaxConcurrentBatches + } return processor } diff --git a/internal/review_processor/commits.go b/internal/review_processor/commits.go index c9caa416f..f264bc560 100644 --- a/internal/review_processor/commits.go +++ b/internal/review_processor/commits.go @@ -9,8 +9,9 @@ import ( // CommitRef identifies a commit or commit range associated with a review. type CommitRef struct { - Ref string - Type string // "commit" | "range" + Ref string + Type string // "commit" | "range" + Message string // best-effort; empty when the source never provided one } // InsertReviewCommits records commit refs for a review (review_commits table), so a later @@ -22,7 +23,7 @@ func InsertReviewCommits(ctx context.Context, db *sql.DB, reviewID, orgID int64, } valueRows := make([]string, 0, len(refs)) - args := make([]interface{}, 0, len(refs)*5) + args := make([]interface{}, 0, len(refs)*6) argN := 1 for _, r := range refs { ref := strings.TrimSpace(r.Ref) @@ -33,20 +34,24 @@ func InsertReviewCommits(ctx context.Context, db *sql.DB, reviewID, orgID int64, if refType != "range" { refType = "commit" } - valueRows = append(valueRows, fmt.Sprintf("($%d, $%d, $%d, $%d, $%d)", argN, argN+1, argN+2, argN+3, argN+4)) - args = append(args, reviewID, orgID, repositoryID, ref, refType) - argN += 5 + var message interface{} + if msg := strings.TrimSpace(r.Message); msg != "" { + message = msg + } + valueRows = append(valueRows, fmt.Sprintf("($%d, $%d, $%d, $%d, $%d, $%d)", argN, argN+1, argN+2, argN+3, argN+4, argN+5)) + args = append(args, reviewID, orgID, repositoryID, ref, refType, message) + argN += 6 } if len(valueRows) == 0 { return nil } // Safe: valueRows contains only $N parameter placeholders generated by - // fmt.Sprintf above (line 36), never user-supplied values. Actual values - // are passed through db.ExecContext's args parameter. + // fmt.Sprintf above, never user-supplied values. Actual values are passed + // through db.ExecContext's args parameter. // nosemgrep: go.lang.security.audit.database.string-formatted-query.string-formatted-query query := fmt.Sprintf( - `INSERT INTO review_commits (review_id, org_id, repository_id, ref, ref_type) VALUES %s ON CONFLICT (review_id, ref) DO NOTHING`, + `INSERT INTO review_commits (review_id, org_id, repository_id, ref, ref_type, message) VALUES %s ON CONFLICT (review_id, ref) DO NOTHING`, strings.Join(valueRows, ", "), ) _, err := db.ExecContext(ctx, query, args...) diff --git a/internal/review_processor/events.go b/internal/review_processor/events.go index 69eb4077b..559843043 100644 --- a/internal/review_processor/events.go +++ b/internal/review_processor/events.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "fmt" + "reflect" "strconv" "strings" "time" @@ -527,7 +528,30 @@ func (s *PollingEventService) CreateBatchEvent(ctx context.Context, reviewID, or data.FinishedAt = &finishedAtStr } - return s.repo.createTypedEvent(ctx, reviewID, orgID, "batch", "info", &batchID, data) + if err := s.repo.createTypedEvent(ctx, reviewID, orgID, "batch", "info", &batchID, data); err != nil { + return err + } + + // Fire on every batch that completes with findings — batches run + // concurrently, so which one is actually "first" is decided by + // pushnotify.Notifier's atomic claim, not by this call site. See + // reviewprocessor.SetNotifier's doc comment. + if status == "completed" && hasComments(comments) { + NotifyReviewEvent(ctx, reviewID, orgID, "first_finding") + } + + return nil +} + +// hasComments reports whether comments is a non-empty slice, without this +// package needing to import pkg/models for the concrete []*models.ReviewComment +// type EmitBatchComplete actually passes through here. +func hasComments(comments interface{}) bool { + if comments == nil { + return false + } + v := reflect.ValueOf(comments) + return v.Kind() == reflect.Slice && v.Len() > 0 } // CreateArtifactEvent creates an artifact reference event diff --git a/internal/review_processor/notifier.go b/internal/review_processor/notifier.go new file mode 100644 index 000000000..28056aa2f --- /dev/null +++ b/internal/review_processor/notifier.go @@ -0,0 +1,47 @@ +package reviewprocessor + +import "context" + +// ReviewNotifier is the cross-cutting hook used to fire browser push +// notifications for review progress. Implemented by internal/pushnotify, +// which this package deliberately does not import — pushnotify needs no +// types from here, and keeping the dependency one-directional (set from +// outside, called from inside) avoids an import cycle and matches how +// internal/jobqueue already wires DatabaseEventSink into a ReviewLogger. +// +// A package-level hook (not a constructor parameter on ReviewManager/ +// PollingEventService) is used because NewReviewManager(db) and +// NewPollingEventService(db) are each constructed fresh at many unrelated +// call sites throughout the codebase; threading a notifier through every one +// of them would be the same "touch everything" problem this hook exists to +// avoid for a cross-cutting concern. +type ReviewNotifier interface { + // NotifyReviewEvent should be non-blocking (fire-and-forget) — it is + // called from hot paths (batch-completion event persistence, review + // status updates) that must never wait on a push send. kind is one of + // "first_finding", "completed", "failed". + NotifyReviewEvent(ctx context.Context, reviewID, orgID int64, kind string) +} + +var notifier ReviewNotifier + +// SetNotifier wires the push-notification dispatcher in. Call once at server +// startup (internal/api/server.go's NewServer); until called, notifyReviewEvent +// is a no-op, which is also the correct behavior for tests and for any +// command that constructs a ReviewManager without a full server (e.g. one-off +// scripts), rather than requiring every caller to nil-check. +func SetNotifier(n ReviewNotifier) { + notifier = n +} + +// NotifyReviewEvent is the entry point every event-sink implementation calls +// (this package's own, and internal/api's separate +// DatabaseEventSink/PollingEventService used by the synchronous CLI-diff +// review path — see internal/api/review_service.go) so both write paths +// fire push notifications identically, through this one no-op-if-unset hook. +func NotifyReviewEvent(ctx context.Context, reviewID, orgID int64, kind string) { + if notifier == nil { + return + } + notifier.NotifyReviewEvent(ctx, reviewID, orgID, kind) +} diff --git a/internal/review_processor/reviews.go b/internal/review_processor/reviews.go index c9eeb01ad..26b33f83f 100644 --- a/internal/review_processor/reviews.go +++ b/internal/review_processor/reviews.go @@ -1,6 +1,7 @@ package reviewprocessor import ( + "context" "database/sql" "encoding/json" "fmt" @@ -159,13 +160,28 @@ func (rm *ReviewManager) UpdateReviewStatus(reviewID int64, status string) error query = `UPDATE reviews SET status = $1, started_at = NOW() WHERE id = $2` args = []interface{}{status, reviewID} case "completed", "failed": - query = `UPDATE reviews SET status = $1, completed_at = NOW() WHERE id = $2` + query = `UPDATE reviews SET status = $1, completed_at = NOW() WHERE id = $2 RETURNING org_id` args = []interface{}{status, reviewID} default: query = `UPDATE reviews SET status = $1 WHERE id = $2` args = []interface{}{status, reviewID} } + // completed/failed is a terminal status reached from several independent + // call sites across the codebase (review_worker.go, scheduled_review_worker.go, + // manual.go) — centralizing the push-notification hook here, rather than at + // each of those, guarantees every terminal transition fires it exactly once. + // See reviewprocessor.SetNotifier's doc comment for why this is a + // package-level hook rather than a constructor parameter. + if status == "completed" || status == "failed" { + var orgID int64 + if err := rm.store.QueryRow(query, args...).Scan(&orgID); err != nil { + return fmt.Errorf("failed to update review status: %w", err) + } + NotifyReviewEvent(context.Background(), reviewID, orgID, status) + return nil + } + _, err := rm.store.Exec(query, args...) if err != nil { return fmt.Errorf("failed to update review status: %w", err) diff --git a/internal/reviewperf/reviewperf.go b/internal/reviewperf/reviewperf.go new file mode 100644 index 000000000..08b12078f --- /dev/null +++ b/internal/reviewperf/reviewperf.go @@ -0,0 +1,147 @@ +// Package reviewperf holds the instance-wide review performance settings: +// the safety ceiling on per-review batch concurrency and the floor/ceiling +// on the per-review timeout. These bound - but never replace - an org's own +// optional soft preference (see storage/aiconnectors.ReviewAISettings' +// PreferredMaxConcurrentBatches/PreferredReviewTimeoutSeconds): an org can +// turn its own concurrency/timeout down, never up past what the instance +// allows. Stored the same way as internal/api/storage_settings.go's +// blob_storage row - a system_settings JSONB row, read fresh on every +// review (no caching, no restart needed to pick up a change). +package reviewperf + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "github.com/livereview/internal/batch" +) + +const settingsRowName = "ai_review_performance" + +// Defaults match internal/batch.DefaultMaxConcurrentBatches and +// internal/review.DefaultReviewConfig's ReviewTimeout - the same conservative +// numbers used when no admin has ever touched this settings page. +const ( + DefaultMaxConcurrentBatchesCeiling = batch.DefaultMaxConcurrentBatches + DefaultMinReviewTimeoutSeconds = 60 // 1 minute floor - below this, a review can't realistically do anything useful + DefaultMaxReviewTimeoutSeconds = 240 // 4 minutes - matches review.DefaultReviewConfig's ReviewTimeout +) + +// Settings is the instance-wide review performance configuration. +type Settings struct { + // MaxConcurrentBatchesCeiling bounds how high any org's own preference + // (or the system default) can push per-review batch concurrency. + MaxConcurrentBatchesCeiling int `json:"max_concurrent_batches_ceiling"` + // MinReviewTimeoutSeconds/MaxReviewTimeoutSeconds bound the per-review + // timeout the same way. + MinReviewTimeoutSeconds int `json:"min_review_timeout_floor_seconds"` + MaxReviewTimeoutSeconds int `json:"max_review_timeout_ceiling_seconds"` +} + +// DefaultSettings returns the settings used when no admin has saved a row yet. +func DefaultSettings() Settings { + return Settings{ + MaxConcurrentBatchesCeiling: DefaultMaxConcurrentBatchesCeiling, + MinReviewTimeoutSeconds: DefaultMinReviewTimeoutSeconds, + MaxReviewTimeoutSeconds: DefaultMaxReviewTimeoutSeconds, + } +} + +// Validate checks that the settings are internally consistent. +func (s Settings) Validate() error { + if s.MaxConcurrentBatchesCeiling <= 0 { + return fmt.Errorf("max concurrent batches ceiling must be > 0") + } + if s.MinReviewTimeoutSeconds <= 0 { + return fmt.Errorf("min review timeout must be > 0 seconds") + } + if s.MaxReviewTimeoutSeconds < s.MinReviewTimeoutSeconds { + return fmt.Errorf("max review timeout (%ds) must be >= min review timeout (%ds)", s.MaxReviewTimeoutSeconds, s.MinReviewTimeoutSeconds) + } + return nil +} + +// Load reads the instance-wide settings from system_settings, falling back +// to DefaultSettings when no row has ever been saved. +func Load(ctx context.Context, db *sql.DB) (Settings, error) { + var data []byte + err := db.QueryRowContext(ctx, `SELECT data FROM system_settings WHERE name = $1`, settingsRowName).Scan(&data) + if err != nil { + if err == sql.ErrNoRows { + return DefaultSettings(), nil + } + return Settings{}, fmt.Errorf("load ai_review_performance settings: %w", err) + } + + settings := DefaultSettings() + if err := json.Unmarshal(data, &settings); err != nil { + return Settings{}, fmt.Errorf("parse ai_review_performance settings: %w", err) + } + return settings, nil +} + +// Save upserts the instance-wide settings. +func Save(ctx context.Context, db *sql.DB, settings Settings) error { + if err := settings.Validate(); err != nil { + return err + } + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal ai_review_performance settings: %w", err) + } + _, err = db.ExecContext(ctx, ` + INSERT INTO system_settings (name, data) + VALUES ($1, $2) + ON CONFLICT (name) DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP + `, settingsRowName, data) + if err != nil { + return fmt.Errorf("save ai_review_performance settings: %w", err) + } + return nil +} + +// ResolveMaxConcurrentBatches combines an org's optional preference with the +// system ceiling: NULL preference falls back to the ceiling itself (today's +// best known default - see internal/batch.DefaultMaxConcurrentBatches's +// doc comment on why 16 is a safe, non-guessed starting point), and any +// preference is clamped to [1, ceiling] so an org can only ever turn its own +// concurrency down, never above what the instance allows. +func (s Settings) ResolveMaxConcurrentBatches(orgPreference *int) int { + ceiling := s.MaxConcurrentBatchesCeiling + if ceiling <= 0 { + ceiling = DefaultMaxConcurrentBatchesCeiling + } + if orgPreference == nil { + return ceiling + } + return clamp(*orgPreference, 1, ceiling) +} + +// ResolveReviewTimeoutSeconds combines an org's optional preference with the +// system floor/ceiling the same way. +func (s Settings) ResolveReviewTimeoutSeconds(orgPreference *int) int { + floor := s.MinReviewTimeoutSeconds + if floor <= 0 { + floor = DefaultMinReviewTimeoutSeconds + } + ceiling := s.MaxReviewTimeoutSeconds + if ceiling < floor { + ceiling = DefaultMaxReviewTimeoutSeconds + } + if orgPreference == nil { + return ceiling + } + return clamp(*orgPreference, floor, ceiling) +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/internal/reviewperf/reviewperf_test.go b/internal/reviewperf/reviewperf_test.go new file mode 100644 index 000000000..fe42c27cb --- /dev/null +++ b/internal/reviewperf/reviewperf_test.go @@ -0,0 +1,72 @@ +package reviewperf + +import "testing" + +func intPtr(v int) *int { return &v } + +func TestResolveMaxConcurrentBatches(t *testing.T) { + settings := Settings{MaxConcurrentBatchesCeiling: 16} + + if got := settings.ResolveMaxConcurrentBatches(nil); got != 16 { + t.Errorf("nil preference: got %d, want 16 (system ceiling)", got) + } + if got := settings.ResolveMaxConcurrentBatches(intPtr(4)); got != 4 { + t.Errorf("preference below ceiling: got %d, want 4", got) + } + if got := settings.ResolveMaxConcurrentBatches(intPtr(64)); got != 16 { + t.Errorf("preference above ceiling: got %d, want clamped to 16", got) + } + if got := settings.ResolveMaxConcurrentBatches(intPtr(-5)); got != 1 { + t.Errorf("negative preference: got %d, want clamped to floor 1", got) + } +} + +func TestResolveReviewTimeoutSeconds(t *testing.T) { + settings := Settings{MinReviewTimeoutSeconds: 60, MaxReviewTimeoutSeconds: 240} + + if got := settings.ResolveReviewTimeoutSeconds(nil); got != 240 { + t.Errorf("nil preference: got %d, want 240 (system ceiling)", got) + } + if got := settings.ResolveReviewTimeoutSeconds(intPtr(90)); got != 90 { + t.Errorf("preference within bounds: got %d, want 90", got) + } + if got := settings.ResolveReviewTimeoutSeconds(intPtr(600)); got != 240 { + t.Errorf("preference above ceiling: got %d, want clamped to 240", got) + } + if got := settings.ResolveReviewTimeoutSeconds(intPtr(10)); got != 60 { + t.Errorf("preference below floor: got %d, want clamped to 60", got) + } +} + +func TestResolve_ZeroValueSettingsFallsBackToDefaults(t *testing.T) { + // A Settings{} (e.g. corrupt/legacy row) must not resolve to a ceiling + // of 0, which would make every review's concurrency clamp to 0. + var settings Settings + if got := settings.ResolveMaxConcurrentBatches(nil); got != DefaultMaxConcurrentBatchesCeiling { + t.Errorf("zero-value settings: got %d, want default %d", got, DefaultMaxConcurrentBatchesCeiling) + } + if got := settings.ResolveReviewTimeoutSeconds(nil); got != DefaultMaxReviewTimeoutSeconds { + t.Errorf("zero-value settings: got %d, want default %d", got, DefaultMaxReviewTimeoutSeconds) + } +} + +func TestValidate(t *testing.T) { + cases := []struct { + name string + s Settings + wantErr bool + }{ + {"valid", Settings{MaxConcurrentBatchesCeiling: 16, MinReviewTimeoutSeconds: 60, MaxReviewTimeoutSeconds: 240}, false}, + {"zero ceiling", Settings{MaxConcurrentBatchesCeiling: 0, MinReviewTimeoutSeconds: 60, MaxReviewTimeoutSeconds: 240}, true}, + {"zero floor", Settings{MaxConcurrentBatchesCeiling: 16, MinReviewTimeoutSeconds: 0, MaxReviewTimeoutSeconds: 240}, true}, + {"ceiling below floor", Settings{MaxConcurrentBatchesCeiling: 16, MinReviewTimeoutSeconds: 100, MaxReviewTimeoutSeconds: 60}, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := c.s.Validate() + if (err != nil) != c.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, c.wantErr) + } + }) + } +} diff --git a/internal/riskscore/calibration.go b/internal/riskscore/calibration.go index a790a7dc4..8b84b8413 100644 --- a/internal/riskscore/calibration.go +++ b/internal/riskscore/calibration.go @@ -17,6 +17,23 @@ import ( // finding scores ~0.6 (blocks). const defaultCutoff = 0.5 +// minScoresForPercentile is the smallest historical sample PercentileCutoff +// will rank against. Below it, the nearest-rank index (p*n+99)/100-1 can land +// at or near the top of a tiny sorted array for any p, including a "strict" +// low percentile like 20 — producing a cutoff that collapses onto the +// distribution's max (often the very review being scored), which reads as a +// contradiction (e.g. "P100" score tying a "P20" cutoff). Too few points to +// rank meaningfully falls back to the same cold-start default as no history. +const minScoresForPercentile = 10 + +// HasEnoughHistory reports whether scores is large enough for PercentileCutoff +// to rank against meaningfully, rather than falling back to defaultCutoff. The +// UI should hide P-number labels when this is false — a tiny sample can't +// support a "P20" claim that means anything. +func HasEnoughHistory(scores []float64) bool { + return len(scores) >= minScoresForPercentile +} + // 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) { @@ -45,7 +62,7 @@ func LoadRecentScores(ctx context.Context, db *sql.DB, orgID int64, limit int) ( // 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 { + if len(scores) < minScoresForPercentile { return defaultCutoff } if p <= 0 { diff --git a/internal/riskscore/features.go b/internal/riskscore/features.go index d8f49bf26..5fea08b29 100644 --- a/internal/riskscore/features.go +++ b/internal/riskscore/features.go @@ -114,16 +114,28 @@ func LoadBlastStats(ctx context.Context, db *sql.DB, orgID, reviewID int64) (Bla // (when present) the diff-level semantic classification. Feature values are // lightly bounded/compressed (log1p on counts) so no single feature dominates // the dot product. +// log1p'd features: every count/sum that is structurally unbounded (grows with +// diff size or finding volume, not capped to a known range) is compressed with +// math.Log1p before it reaches the dot product. br_sum is the feature that +// actually saturated production (a raw SUM(combined) across every blast-radius +// hunk in the diff, real values 1700-4840, against a weight calibrated for an +// assumed 0-100 range) - but every count feature here shares the identical +// structural flaw once a diff/review has enough findings (production data +// already shows warning_count up to 37, critical_count up to 12, both alone +// enough to saturate at their un-compressed weights). br_max, br_weighted_avg +// stay raw because they're genuinely bounded (a MAX or a weighted AVERAGE of a +// per-hunk score can't runaway the way a SUM or a COUNT can); llm_class_risk +// stays raw because it's already a bounded ordinal (0/0.5/1). func ExtractFeatures(br BlastStats, doc *canonicaldoc.ReviewDoc) []float64 { f := make([]float64, FeatureCount()) - f[0] = br.Sum + f[0] = math.Log1p(br.Sum) f[1] = br.Max f[2] = br.WeightedAvg - f[3] = float64(br.HighCount) + f[3] = math.Log1p(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[4] = math.Log1p(float64(doc.Counts.BySeverity["critical"])) + f[5] = math.Log1p(float64(doc.Counts.BySeverity["warning"])) + f[6] = math.Log1p(float64(doc.Counts.ByCategory["security"])) } f[7] = math.Log1p(float64(br.FileCount)) f[8] = math.Log1p(float64(br.LinesAdded)) @@ -132,15 +144,15 @@ func ExtractFeatures(br BlastStats, doc *canonicaldoc.ReviewDoc) []float64 { // 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"]) + f[10] = math.Log1p(float64(doc.Counts.ByCategory["reliability"])) + f[11] = math.Log1p(float64(doc.Counts.ByCategory["correctness"])) + f[12] = math.Log1p(float64(doc.Counts.ByCategory["performance"])) + f[13] = math.Log1p(float64(doc.Counts.ByCategory["scalability"])) + f[14] = math.Log1p(float64(doc.Counts.ByCategory["compliance & governance"])) + f[15] = math.Log1p(float64(doc.Counts.ByCategory["maintainability"])) + f[16] = math.Log1p(float64(doc.Counts.ByCategory["architecture"])) + f[17] = math.Log1p(float64(doc.Counts.ByCategory["developer experience"])) + f[18] = math.Log1p(float64(doc.Counts.ByCategory["cost"])) } return f } diff --git a/internal/riskscore/learning_cycle_test.go b/internal/riskscore/learning_cycle_test.go index df6483f52..041d2049b 100644 --- a/internal/riskscore/learning_cycle_test.go +++ b/internal/riskscore/learning_cycle_test.go @@ -87,10 +87,19 @@ func TestLearningCycleProvesFeedbackLoopWorks(t *testing.T) { // 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. + // br_max/br_weighted_avg (indices 1/2) stay raw -- ExtractFeatures never + // compresses them, they're already a bounded 0-100 scale. br_sum (index + // 0) is log1p'd by ExtractFeatures before scoring ever sees it, so a + // fixture that builds the vector by hand (bypassing ExtractFeatures, like + // this one) must apply the same transform or it's feeding the model a + // value outside anything ExtractFeatures could ever actually produce -- + // exactly the mismatch that caused this test to fail after the feature + // rescaling fix (a raw 15 read as an already-log1p'd br_sum implies a + // real underlying sum of roughly e^15, nothing like this test intends). highRisk := make([]float64, FeatureCount()) - highRisk[0], highRisk[1], highRisk[2] = 15, 15, 15 // br_sum / br_max / br_weighted_avg + highRisk[0], highRisk[1], highRisk[2] = math.Log1p(15), 15, 15 // br_sum / br_max / br_weighted_avg lowRisk := make([]float64, FeatureCount()) - lowRisk[0], lowRisk[1], lowRisk[2] = 2, 2, 2 + lowRisk[0], lowRisk[1], lowRisk[2] = math.Log1p(2), 2, 2 // --- Step 0: cold start. Every org begins at the shared common-sense // seed, with zero outcomes recorded. --- @@ -124,11 +133,11 @@ func TestLearningCycleProvesFeedbackLoopWorks(t *testing.T) { // 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 + features[0], features[1], features[2] = math.Log1p(v), v, v outcome = "revert" } else { v := float64(i % 10) - features[0], features[1], features[2] = v, v, v + features[0], features[1], features[2] = math.Log1p(v), v, v } seedLabeledExampleFromSource(t, db, orgID, features, outcome, "ci") } @@ -178,7 +187,7 @@ func TestLearningCycleProvesFeedbackLoopWorks(t *testing.T) { // 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 + freshFeatures[0], freshFeatures[1], freshFeatures[2] = math.Log1p(15), 15, 15 raw := mustMarshal(t, freshFeatures) if _, err := db.Exec(` INSERT INTO risk_feature_snapshots (review_id, org_id, model_version, features, score) diff --git a/internal/riskscore/model.go b/internal/riskscore/model.go index 7975fe226..837442607 100644 --- a/internal/riskscore/model.go +++ b/internal/riskscore/model.go @@ -26,31 +26,53 @@ const AnchorLambda = 1.0 // 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. +// +// Every count/sum feature below is log1p-compressed by ExtractFeatures (see its +// doc comment) except br_max/br_weighted_avg (genuinely bounded already) and +// llm_class_risk (already a bounded ordinal). These weights are retuned to that +// compressed scale: for each, `new_weight = intended_contribution_at_reference / +// log1p(reference)`, where `reference` is that feature's realistic high end +// (informed by real production data - e.g. warning_count observed up to 37, +// critical_count up to 12, br_sum up to ~4840 - rounded up for headroom) and +// `intended_contribution_at_reference` preserves the old weight's relative +// priority (critical dominates > security weighted up > correctness/reliability +// > performance > the rest modest), just evaluated at the reference instead of +// at the raw (unbounded) value. This replaces the previous br_sum weight, which +// assumed a 0-100 range for what is actually an unbounded sum across every +// blast-radius hunk in the diff - real values reaching into the thousands fed +// the sigmoid a z far past the ~37 threshold where float64 can no longer +// represent 1+exp(-z) as anything but exactly 1.0, pinning every sufficiently +// large review's score to a literal, indistinguishable ceiling (see the +// production investigation that found this: org 3's entire score history was +// exactly 1.0). Score/scoreRaw additionally clamp the final z to [-zClampAbs, +// zClampAbs] as a hard backstop independent of these weights ever being wrong +// again. 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 + w[1] = 0.24 // br_sum, log1p'd (reference ~5000; preserves the old design's intended "fully maxed assumed-0-100-range" contribution of 2.0) + w[2] = 0.01 // br_max (unchanged - a MAX can't run away the way a SUM/COUNT can) + w[3] = 0.01 // br_weighted_avg (unchanged - same reasoning as br_max) + w[4] = 0.42 // br_high_count, log1p'd (reference 10) + w[5] = 1.6 // critical_count, log1p'd (reference 15; dominates) + w[6] = 0.24 // warning_count, log1p'd (reference 40) + w[7] = 1.0 // security_count, log1p'd (reference 15; weighted up) + w[8] = 0.10 // files_changed (already log1p'd, unchanged) + w[9] = 0.05 // lines_added (already log1p'd, unchanged) + w[10] = 0.30 // llm_class_risk (already a bounded ordinal, unchanged) + // Per-taxonomy-category counts, log1p'd (reference 15 each): 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.54 // reliability_count + w[12] = 0.54 // correctness_count + w[13] = 0.36 // performance_count + w[14] = 0.22 // scalability_count + w[15] = 0.22 // compliance_governance_count + w[16] = 0.18 // maintainability_count + w[17] = 0.18 // architecture_count + w[18] = 0.18 // developer_experience_count + w[19] = 0.18 // cost_count return w } @@ -60,6 +82,33 @@ func AnchorStrength(n int) float64 { return AnchorLambda / (AnchorLambda + float64(n)) } +// zClampAbs bounds the weighted sum fed to sigmoid. float64 can no longer +// represent 1+exp(-z) as anything but exactly 1.0 once |z| passes roughly 36-37 +// (exp(-37) ā‰ˆ 8.5e-17, below float64's ~2.22e-16 epsilon relative to 1.0) — that +// is the real danger zone this clamp exists to stay well clear of. 25 leaves a +// wide safety margin below it (sigmoid(25) ā‰ˆ 0.99999999986, still 11 nines of +// real precision, nowhere near collapsing to literally 1.0) while leaving +// enough headroom that real, differentiated high-risk reviews don't collapse +// into each other at the clamp boundary the way the original br_sum bug did at +// the sigmoid's true ceiling — confirmed against the real production feature +// vectors in saturation_test.go, where the most extreme real review observed +// still lands comfortably under this clamp. This is a hard backstop against +// any future weight, category-override (sliders go 0-10x), or feature-scale +// mistake ever again producing a score pinned to a literal ceiling/floor — +// independent of whether Prior()'s tuning is ever imperfect. See Prior()'s doc +// comment for the production bug this guards against. +const zClampAbs = 25.0 + +func clampZ(z float64) float64 { + if z > zClampAbs { + return zClampAbs + } + if z < -zClampAbs { + return -zClampAbs + } + return z +} + func sigmoid(z float64) float64 { if z >= 0 { zExp := math.Exp(-z) @@ -121,10 +170,12 @@ func Score(w Weights, x []float64, overrides map[string]float64) float64 { for i := 0; i < len(x) && i+1 < len(w); i++ { z += w[i+1] * categoryOverride(overrides, i+1) * x[i] } - return sigmoid(z) + return sigmoid(clampZ(z)) } // scoreRaw is the raw dot product (no overrides), used during training and AUC. +// Clamped the same way as Score so a fit can't learn weights that only behave +// correctly within the clamped regime inconsistently with inference. func scoreRaw(w Weights, x []float64) float64 { var z float64 if len(w) > 0 { @@ -133,7 +184,7 @@ func scoreRaw(w Weights, x []float64) float64 { for i := 0; i < len(x) && i+1 < len(w); i++ { z += w[i+1] * x[i] } - return sigmoid(z) + return sigmoid(clampZ(z)) } // BatchFit fits a fresh logistic model over (X, Y) using goml's batch gradient diff --git a/internal/riskscore/referencemix/referencemix.go b/internal/riskscore/referencemix/referencemix.go index e77270de3..64556ba6e 100644 --- a/internal/riskscore/referencemix/referencemix.go +++ b/internal/riskscore/referencemix/referencemix.go @@ -31,15 +31,21 @@ func Build(featureCount int) []Example { rng := rand.New(rand.NewSource(7)) var out []Example + // Mirrors riskscore.ExtractFeatures' encoding exactly (duplicated here + // rather than imported so this package stays dependency-free of + // riskscore -- see the package doc comment): brSum/brHigh/critical/ + // warning/security are log1p'd, matching ExtractFeatures' compression of + // every structurally-unbounded count/sum feature. brMax/brWeighted stay + // raw (genuinely bounded), same as ExtractFeatures. f := func(brSum, brMax, brWeighted, brHigh, critical, warning, security, files, lines, llmRisk float64) []float64 { v := make([]float64, featureCount) - v[0] = brSum + v[0] = math.Log1p(brSum) v[1] = brMax v[2] = brWeighted - v[3] = brHigh - v[4] = critical - v[5] = warning - v[6] = security + v[3] = math.Log1p(brHigh) + v[4] = math.Log1p(critical) + v[5] = math.Log1p(warning) + v[6] = math.Log1p(security) v[7] = math.Log1p(files) v[8] = math.Log1p(lines) v[9] = llmRisk diff --git a/internal/riskscore/riskscore_test.go b/internal/riskscore/riskscore_test.go index aca2b45f9..ba3d94e8b 100644 --- a/internal/riskscore/riskscore_test.go +++ b/internal/riskscore/riskscore_test.go @@ -29,8 +29,15 @@ func TestScoreInRange(t *testing.T) { func TestScoreOverrideEffect(t *testing.T) { prior := Prior() x := make([]float64, FeatureCount()) - x[0] = 50 // br_sum - x[4] = 1 // critical_count + // Realistic log1p'd-scale values, not raw counts (features are + // log1p-compressed by ExtractFeatures before Score ever sees them -- a + // log1p'd br_sum realistically tops out around 8-9, so a raw value like + // 50 here would already be past where a real feature vector could land, + // and would clamp both branches to the same z ceiling, hiding the + // override's effect entirely -- exactly what this test is supposed to + // catch). + x[0] = 2.0 // br_sum, log1p(~6.4) -- a sizeable but plausible blast radius + x[4] = math.Log1p(1) // critical_count: one critical finding base := Score(prior, x, nil) boosted := Score(prior, x, map[string]float64{"blast_radius": 10}) @@ -172,15 +179,24 @@ func TestPriorAllFeatureWeightsNonNegative(t *testing.T) { func TestPriorScoreMonotonicInEachFeature(t *testing.T) { prior := Prior() rng := rand.New(rand.NewSource(42)) + // Sampled up to 60, not 10: a log1p'd feature realistically tops out + // around 8-9 even for an extreme real-world value (log1p(4840) ā‰ˆ 8.5, + // the actual br_sum behind the production bug this suite guards + // against), but this test exercises Score as a pure function over any + // input, including well past that -- including past the zClampAbs + // clamp boundary (12) -- so a future unbounded feature added without + // log1p compression, or a future clamp/weight regression, gets caught + // here instead of only showing up in production. + const sampleMax = 60.0 for trial := 0; trial < 20; trial++ { base := make([]float64, FeatureCount()) for i := range base { - base[i] = rng.Float64() * 10 + base[i] = rng.Float64() * sampleMax } baseScore := Score(prior, base, nil) for i := range base { bumped := append([]float64{}, base...) - bumped[i] += rng.Float64()*10 + 0.1 // strictly positive delta + bumped[i] += rng.Float64()*sampleMax + 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)", diff --git a/internal/riskscore/saturation_test.go b/internal/riskscore/saturation_test.go new file mode 100644 index 000000000..e6ba76350 --- /dev/null +++ b/internal/riskscore/saturation_test.go @@ -0,0 +1,197 @@ +package riskscore + +import ( + "math" + "math/rand" + "testing" +) + +// prodFeatureVector reconstructs a feature vector the way the new +// ExtractFeatures would produce it, from the RAW values actually stored in +// production's risk_feature_snapshots.features column (pulled read-only from +// .env.prod's DATABASE_URL during the investigation that found this bug). +// Those rows were captured under the OLD ExtractFeatures, where only +// files/lines (args 7/8 here) were already log1p'd -- everything else was +// raw. This helper applies the log1p transform ExtractFeatures now applies, +// to the same raw numbers, so these are honest real-world regression +// fixtures rather than synthetic guesses. +func prodFeatureVector(brSum, brMax, brWeighted, brHigh, critical, warning, security, filesLog1p, linesLog1p, llmRisk, reliability, correctness, performance, scalability, complianceGov, maintainability, architecture, devExp, cost float64) []float64 { + return []float64{ + math.Log1p(brSum), brMax, brWeighted, math.Log1p(brHigh), + math.Log1p(critical), math.Log1p(warning), math.Log1p(security), + filesLog1p, linesLog1p, llmRisk, + math.Log1p(reliability), math.Log1p(correctness), math.Log1p(performance), + math.Log1p(scalability), math.Log1p(complianceGov), math.Log1p(maintainability), + math.Log1p(architecture), math.Log1p(devExp), math.Log1p(cost), + } +} + +// TestRealProductionScoresNoLongerPinned is the regression test that would +// have caught the original bug before it shipped: org 3's entire score +// history was exactly 1.0 for every one of its last 10 reviews, despite +// materially different underlying feature vectors (different finding +// counts, different blast-radius magnitudes) -- because br_sum (a raw, +// unbounded SUM(combined) across every blast-radius hunk, real values +// 1700-4840) fed a weight calibrated for an assumed 0-100 range, saturating +// the sigmoid past the point float64 can represent anything but exactly +// 1.0. Org 677 showed the same mechanism directly: score behaved normally +// when br_sum=0, pinned to 1.0 the instant br_sum crossed ~150. +func TestRealProductionScoresNoLongerPinned(t *testing.T) { + prior := Prior() + + // Org 3's 10 most recent production reviews, newest first (review IDs + // and raw values exactly as queried from risk_feature_snapshots). + org3 := map[string][]float64{ + "13543": prodFeatureVector(4839.98, 82.22, 26.14934136243884, 2, 0, 7, 0, 4.624972813284271, 8.983690681332686, 1, 1, 2, 0, 0, 0, 1, 0, 3, 0), + "13542": prodFeatureVector(4839.98, 82.22, 26.14934136243884, 2, 0, 0, 0, 4.624972813284271, 8.983690681332686, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), + "13539": prodFeatureVector(3586.34, 82.27, 26.178802709802294, 2, 1, 6, 0, 4.477336814478207, 8.886547412512042, 1, 3, 1, 0, 0, 0, 1, 0, 2, 0), + "13523": prodFeatureVector(1708.27, 96.42, 37.37333796457103, 2, 5, 25, 3, 3.6635616461296463, 7.965545573129992, 1, 12, 12, 1, 0, 1, 8, 2, 4, 1), + "13528": prodFeatureVector(2365.97, 97.87, 33.26371175908222, 2, 1, 25, 4, 3.970291913552122, 8.339261982923576, 1, 6, 11, 0, 0, 0, 2, 1, 2, 0), + "13527": prodFeatureVector(1902, 97.87, 36.93545110410095, 2, 2, 28, 6, 3.784189633918261, 8.061802274538348, 1, 8, 7, 1, 0, 0, 5, 1, 2, 0), + "13526": prodFeatureVector(1775.62, 98.06, 37.01939345314506, 2, 1, 19, 4, 3.784189633918261, 8.044626279767337, 1, 4, 7, 0, 0, 0, 4, 0, 1, 0), + "13522": prodFeatureVector(1824.93, 96.46, 38.080896277556924, 2, 9, 7, 2, 3.7376696182833684, 7.92588031673756, 1, 4, 5, 1, 0, 0, 8, 0, 0, 0), + "13521": prodFeatureVector(1805.47, 96.46, 38.10692504570384, 2, 8, 24, 4, 3.6635616461296463, 7.914252278742441, 1, 8, 11, 3, 0, 0, 9, 4, 4, 0), + "13484": prodFeatureVector(2417.72, 80.24, 37.482306049822064, 2, 7, 37, 12, 3.7376696182833684, 7.941295570906532, 1, 14, 15, 3, 1, 0, 8, 1, 1, 0), + } + + scores := map[string]float64{} + for id, x := range org3 { + scores[id] = Score(prior, x, nil) + } + + // None of these real, materially-different reviews should be bit-identical. + seen := map[float64]string{} + for id, s := range scores { + if prev, ok := seen[s]; ok { + t.Errorf("review %s and review %s scored bit-identical (%v) despite different features -- the exact artifact this test guards against", id, prev, s) + } + seen[s] = id + } + + // None should be pinned at the literal ceiling the clamp exists to prevent. + for id, s := range scores { + if s >= 1.0 { + t.Errorf("review %s score = %v, want strictly < 1.0", id, s) + } + } + + // 13542 and 13543 share identical blast-radius features (same diff) but + // 13543 has real findings (7 warnings, 3 taxonomy findings) and 13542 has + // none -- 13543 must score higher. + if scores["13543"] <= scores["13542"] { + t.Errorf("13543 (has findings) should score above 13542 (identical blast radius, zero findings): %v vs %v", scores["13543"], scores["13542"]) + } +} + +// TestOrg677BeforeAfterBlastRadius replays the exact before/after pair found +// in production: two reviews with br_sum=0 score normally and distinctly; +// two reviews with real blast-radius data (br_sum 150-230, crossing the +// threshold that used to saturate the old model) must no longer be +// bit-identical to each other or pinned at the ceiling. +func TestOrg677BeforeAfterBlastRadius(t *testing.T) { + prior := Prior() + + noBlastA := prodFeatureVector(0, 0, 0, 0, 0, 0, 0, 0.6931471805599453, 2.0794415416798357, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + noBlastB := prodFeatureVector(0, 0, 0, 0, 0, 0, 0, 0.6931471805599453, 2.1972245773362196, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + withBlastA := prodFeatureVector(230.26, 100, 60.22118343195266, 1, 3, 1, 1, 1.6094379124341003, 5.135798437050262, 1, 1, 2, 0, 0, 0, 0, 0, 0, 0) + withBlastB := prodFeatureVector(153.66, 100, 51.22, 1, 0, 0, 0, 1.0986122886681096, 3.091042453358316, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0) + + sNoBlastA := Score(prior, noBlastA, nil) + sNoBlastB := Score(prior, noBlastB, nil) + sWithBlastA := Score(prior, withBlastA, nil) + sWithBlastB := Score(prior, withBlastB, nil) + + if sWithBlastA >= 1.0 || sWithBlastB >= 1.0 { + t.Errorf("real blast-radius reviews pinned at ceiling: %v, %v", sWithBlastA, sWithBlastB) + } + if sWithBlastA == sWithBlastB { + t.Errorf("the two with-blast-radius reviews scored bit-identical (%v) despite different features (230.26/3 critical/1 warning vs 153.66/0 findings)", sWithBlastA) + } + if sNoBlastA >= sWithBlastA || sNoBlastB >= sWithBlastB { + t.Errorf("a review with real blast-radius data should score higher than one with none: noBlastA=%v withBlastA=%v, noBlastB=%v withBlastB=%v", + sNoBlastA, sWithBlastA, sNoBlastB, sWithBlastB) + } +} + +// TestCategoryOverrideSweepNeverInstantlySaturatesRealisticInputs sweeps every +// category override from 0 to 10 against a battery of realistic feature +// vectors (including the real production ones above) and checks the score +// moves smoothly rather than jumping straight to the clamp ceiling/floor for +// any single step -- a crude proxy for "no default+override combination +// alone collapses all differentiation," answering "how do I know this works +// when a customer tweaks the sliders" with a sweep instead of a handful of +// hand-picked examples. +func TestCategoryOverrideSweepNeverInstantlySaturatesRealisticInputs(t *testing.T) { + prior := Prior() + battery := [][]float64{ + prodFeatureVector(4839.98, 82.22, 26.14934136243884, 2, 0, 7, 0, 4.624972813284271, 8.983690681332686, 1, 1, 2, 0, 0, 0, 1, 0, 3, 0), + prodFeatureVector(230.26, 100, 60.22118343195266, 1, 3, 1, 1, 1.6094379124341003, 5.135798437050262, 1, 1, 2, 0, 0, 0, 0, 0, 0, 0), + prodFeatureVector(0, 0, 0, 0, 0, 0, 0, 0.6931471805599453, 2.0794415416798357, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), + } + categories := OverrideCategories() + // All explicit, strictly increasing, and never 0/negative: categoryOverride + // treats any override <= 0 as "fall through to the category's own default + // multiplier" (DefaultCategoryMultiplier, which varies per category -- 2.0 + // for blast_radius, 1.5 for security, 1.0 for most others). An earlier + // version of this sweep used 0 as its first step expecting it to mean "no + // effect", but since 0 actually resolves to that category's default, and + // the swept category's next explicit step (1.0) is *below* blast_radius's + // own default (2.0), the score legitimately dropped between those two + // steps -- not a model bug, a test bug (comparing "default 2.0x" against + // "explicit 1.0x" isn't a monotonic increase). Starting low and explicit + // avoids that trap. + overrideSteps := []float64{0.1, 0.5, 1, 2, 5, 10} + + for _, cat := range categories { + var prevScores []float64 + for _, step := range overrideSteps { + overrides := map[string]float64{cat: step} + var stepScores []float64 + for _, x := range battery { + s := Score(prior, x, overrides) + if math.IsNaN(s) || math.IsInf(s, 0) { + t.Fatalf("category %s override %v: score is NaN/Inf for feature vector %v", cat, step, x) + } + if s <= 0 || s >= 1 { + t.Fatalf("category %s override %v: score %v out of open range (0,1)", cat, step, s) + } + stepScores = append(stepScores, s) + } + if prevScores != nil { + for i := range stepScores { + // Monotonic in override strength for a category whose + // weights are all non-negative (already asserted + // separately by TestPriorAllFeatureWeightsNonNegative). + if stepScores[i] < prevScores[i]-1e-9 { + t.Errorf("category %s: increasing override to %v decreased score for battery item %d (%v -> %v)", cat, step, i, prevScores[i], stepScores[i]) + } + } + } + prevScores = stepScores + } + } +} + +// TestZClampNeverExactlyReached fuzzes large/adversarial feature vectors and +// confirms the score is always strictly inside (0,1) -- i.e. the clamp is +// actually doing its job of keeping sigmoid's input away from the ~37 +// threshold where float64 can no longer represent 1+exp(-z) as anything but +// exactly 1.0 (or exp(z)/(1+exp(z)) as anything but exactly 0.0). +func TestZClampNeverExactlyReached(t *testing.T) { + prior := Prior() + rng := rand.New(rand.NewSource(99)) + for trial := 0; trial < 200; trial++ { + x := make([]float64, FeatureCount()) + for i := range x { + x[i] = rng.Float64() * 1000 // deliberately adversarial, well past any realistic log1p'd value + } + overrides := map[string]float64{} + for _, cat := range OverrideCategories() { + overrides[cat] = rng.Float64() * 10 + } + s := Score(prior, x, overrides) + if s <= 0 || s >= 1 { + t.Fatalf("trial %d: score %v not strictly inside (0,1) despite zClampAbs=%v", trial, s, zClampAbs) + } + } +} diff --git a/scripts/dbmate-dump.sh b/scripts/dbmate-dump.sh new file mode 100755 index 000000000..c34038ff0 --- /dev/null +++ b/scripts/dbmate-dump.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Regenerates db/schema.sql via `dbmate dump`. +# +# dbmate shells out to whatever `pg_dump` it finds on PATH, and refuses to +# run if that binary's major version is older than the Postgres server's +# ("pg_dump: error: aborting because of server version mismatch"). This +# script checks that up front and fails with install instructions instead of +# letting dbmate's own confusing error surface, or silently working around it +# via Docker - a mismatch here means the host's postgresql-client package is +# genuinely stale and should be fixed once, not routed around on every dump. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [ -f .env ]; then + export $(grep -v '^#' .env | xargs) +fi + +if [ -z "${DATABASE_URL:-}" ]; then + echo "ERROR: DATABASE_URL not set (expected in .env)." >&2 + exit 1 +fi + +if ! command -v pg_dump >/dev/null 2>&1; then + echo "ERROR: pg_dump not found. Install postgresql-client - see scripts/install-pgdg-client.sh." >&2 + exit 1 +fi + +if ! command -v psql >/dev/null 2>&1; then + echo "ERROR: psql not found. Install postgresql-client - see scripts/install-pgdg-client.sh." >&2 + exit 1 +fi + +local_major="$(pg_dump --version | grep -oE '[0-9]+' | head -1)" +server_version_num="$(psql "$DATABASE_URL" -tAc "SHOW server_version_num" 2>/dev/null | tr -d '[:space:]')" + +if [ -z "$server_version_num" ]; then + echo "ERROR: could not connect to the dev database at \$DATABASE_URL to check its version." >&2 + exit 1 +fi + +# server_version_num is e.g. 180006 for 18.6 - the major version is the +# leading 2 digits for >=10, matching how pg_dump's --version already prints. +server_major="${server_version_num:0:2}" + +if [ "$local_major" -lt "$server_major" ]; then + cat >&2 </dev/null 2>&1; then + echo "ERROR: curl is required." >&2 + exit 1 +fi + +echo "Installing postgresql-common (provides the PGDG repo setup helper)..." +sudo apt-get update +sudo apt-get install -y postgresql-common + +echo "Adding the PGDG apt repository for this OS release..." +sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y + +echo "Installing postgresql-client-${PG_VERSION}..." +sudo apt-get install -y "postgresql-client-${PG_VERSION}" + +echo +echo "Done. Verify with:" +echo " /usr/lib/postgresql/${PG_VERSION}/bin/pg_dump --version" +echo +echo "If that's not first on PATH yet, either:" +echo " - add it: export PATH=\"/usr/lib/postgresql/${PG_VERSION}/bin:\$PATH\"" +echo " - or let the Debian alternatives system manage it: sudo apt-get install -y postgresql-client (meta-package tracks the newest installed major)" diff --git a/storage/aiconnectors/review_ai_settings_store.go b/storage/aiconnectors/review_ai_settings_store.go index d2d96646b..d84bfe8a6 100644 --- a/storage/aiconnectors/review_ai_settings_store.go +++ b/storage/aiconnectors/review_ai_settings_store.go @@ -19,6 +19,15 @@ type ReviewAISettings struct { OrgID int64 HelperEnabled bool HelperMode string + + // PreferredMaxConcurrentBatches and PreferredReviewTimeoutSeconds are + // optional soft preferences (NULL = "use the system default", resolved + // by internal/aiselection's review-performance resolver and always + // clamped to the system-wide ceiling in system_settings row + // 'ai_review_performance' - an org can lower its own concurrency/timeout + // but can never raise it past what the instance allows). + PreferredMaxConcurrentBatches *int + PreferredReviewTimeoutSeconds *int } type ReviewAISettingsStore struct { @@ -67,10 +76,11 @@ func (s *ReviewAISettingsStore) GetByOrgID(ctx context.Context, orgID int64) (Re } err := s.db.QueryRowContext(ctx, ` - SELECT helper_enabled, helper_mode + SELECT helper_enabled, helper_mode, preferred_max_concurrent_batches, preferred_review_timeout_seconds FROM org_review_ai_settings WHERE org_id = $1 - `, orgID).Scan(&settings.HelperEnabled, &settings.HelperMode) + `, orgID).Scan(&settings.HelperEnabled, &settings.HelperMode, + &settings.PreferredMaxConcurrentBatches, &settings.PreferredReviewTimeoutSeconds) if err != nil { if err == sql.ErrNoRows { return settings, nil @@ -102,14 +112,24 @@ func (s *ReviewAISettingsStore) Upsert(ctx context.Context, settings ReviewAISet settings.HelperMode = normalizedMode + if settings.PreferredMaxConcurrentBatches != nil && *settings.PreferredMaxConcurrentBatches <= 0 { + return ReviewAISettings{}, fmt.Errorf("preferred max concurrent batches must be > 0") + } + if settings.PreferredReviewTimeoutSeconds != nil && *settings.PreferredReviewTimeoutSeconds <= 0 { + return ReviewAISettings{}, fmt.Errorf("preferred review timeout seconds must be > 0") + } + if _, err := s.db.ExecContext(ctx, ` - INSERT INTO org_review_ai_settings (org_id, helper_enabled, helper_mode, created_at, updated_at) - VALUES ($1, $2, $3, NOW(), NOW()) + INSERT INTO org_review_ai_settings (org_id, helper_enabled, helper_mode, preferred_max_concurrent_batches, preferred_review_timeout_seconds, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) ON CONFLICT (org_id) DO UPDATE SET helper_enabled = EXCLUDED.helper_enabled, helper_mode = EXCLUDED.helper_mode, + preferred_max_concurrent_batches = EXCLUDED.preferred_max_concurrent_batches, + preferred_review_timeout_seconds = EXCLUDED.preferred_review_timeout_seconds, updated_at = NOW() - `, settings.OrgID, settings.HelperEnabled, settings.HelperMode); err != nil { + `, settings.OrgID, settings.HelperEnabled, settings.HelperMode, + settings.PreferredMaxConcurrentBatches, settings.PreferredReviewTimeoutSeconds); err != nil { return ReviewAISettings{}, fmt.Errorf("upsert review ai settings: %w", err) } diff --git a/storage/license/review_accounting_store.go b/storage/license/review_accounting_store.go index 85ac4b8f6..a81607f65 100644 --- a/storage/license/review_accounting_store.go +++ b/storage/license/review_accounting_store.go @@ -62,13 +62,13 @@ func (s *ReviewAccountingStore) GetReviewAccountingTotals(ctx context.Context, o SUM(COALESCE(llm_cost_usd, CASE WHEN jsonb_typeof(metadata->'llm_cost_usd') = 'number' THEN (metadata->>'llm_cost_usd')::double precision ELSE 0 END)) AS cost_sum, - SUM(CASE WHEN input_tokens IS NOT NULL + COALESCE(SUM(CASE WHEN input_tokens IS NOT NULL OR output_tokens IS NOT NULL OR llm_cost_usd IS NOT NULL OR jsonb_typeof(metadata->'input_tokens') = 'number' OR jsonb_typeof(metadata->'output_tokens') = 'number' OR jsonb_typeof(metadata->'llm_cost_usd') = 'number' - THEN 1 ELSE 0 END) AS token_tracked_ops + THEN 1 ELSE 0 END), 0) AS token_tracked_ops FROM loc_usage_ledger WHERE org_id = $1 AND review_id = $2 AND status = 'accounted' `, orgID, reviewID).Scan( diff --git a/storage/license/review_accounting_store_test.go b/storage/license/review_accounting_store_test.go new file mode 100644 index 000000000..b4b680f82 --- /dev/null +++ b/storage/license/review_accounting_store_test.go @@ -0,0 +1,70 @@ +package license + +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 +} + +// TestGetReviewAccountingTotalsNoRowsReturnsZero is a regression test: with +// zero matching loc_usage_ledger rows (every review, until its first LLM call +// is accounted), Postgres's bare SUM() aggregates return NULL, not 0 - only +// total_billable_loc was wrapped in COALESCE, so scanning the unwrapped +// token_tracked_ops SUM into a non-nullable int64 failed with a scan error, +// which the API handler (GetReviewAccounting) turned into a 500 on every +// freshly-triggered review the frontend polled. +func TestGetReviewAccountingTotalsNoRowsReturnsZero(t *testing.T) { + db := testDB(t) + ctx := context.Background() + + var orgID int64 + if err := db.QueryRowContext(ctx, `INSERT INTO orgs (name) VALUES ('accounting-test-org') RETURNING id`).Scan(&orgID); err != nil { + t.Fatalf("insert org: %v", err) + } + t.Cleanup(func() { _, _ = db.ExecContext(ctx, `DELETE FROM orgs WHERE id = $1`, orgID) }) + + var reviewID int64 + if err := db.QueryRowContext(ctx, ` + INSERT INTO reviews (repository, status, org_id, metadata) + VALUES ('test/repo', 'in_progress', $1, '{}'::jsonb) RETURNING id`, orgID).Scan(&reviewID); err != nil { + t.Fatalf("insert review: %v", err) + } + t.Cleanup(func() { _, _ = db.ExecContext(ctx, `DELETE FROM reviews WHERE id = $1`, reviewID) }) + + store := NewReviewAccountingStore(db) + totals, err := store.GetReviewAccountingTotals(ctx, orgID, reviewID) + if err != nil { + t.Fatalf("GetReviewAccountingTotals with no ledger rows returned an error: %v", err) + } + if totals.TokenTrackedOps != 0 { + t.Errorf("expected TokenTrackedOps = 0, got %d", totals.TokenTrackedOps) + } + if totals.TotalBillableLOC != 0 { + t.Errorf("expected TotalBillableLOC = 0, got %d", totals.TotalBillableLOC) + } + if totals.AccountedOperations != 0 { + t.Errorf("expected AccountedOperations = 0, got %d", totals.AccountedOperations) + } +} diff --git a/ui/package-lock.json b/ui/package-lock.json index 4a4be2886..8d829259b 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -80,6 +80,7 @@ "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.30.1", + "fast-check": "^4.10.2", "fork-ts-checker-webpack-plugin": "^9.0.2", "html-loader": "^5.1.0", "html-webpack-plugin": "^5.6.0", @@ -9964,6 +9965,46 @@ "node": ">=0.6" } }, + "node_modules/fast-check": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", + "integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fast-check/node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/ui/package.json b/ui/package.json index e3262c837..0ef492a6c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -58,6 +58,7 @@ "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.30.1", + "fast-check": "^4.10.2", "fork-ts-checker-webpack-plugin": "^9.0.2", "html-loader": "^5.1.0", "html-webpack-plugin": "^5.6.0", diff --git a/ui/public/notification-icon.png b/ui/public/notification-icon.png new file mode 100644 index 000000000..42bec0464 Binary files /dev/null and b/ui/public/notification-icon.png differ diff --git a/ui/public/sw.js b/ui/public/sw.js new file mode 100644 index 000000000..c5b0cafd3 --- /dev/null +++ b/ui/public/sw.js @@ -0,0 +1,64 @@ +// Minimal push-only service worker for LiveReview — not a full PWA/offline +// cache, just enough to receive and show a browser notification for review +// progress (first finding / completion) even when no tab is open, and to +// focus/open the right review page on click. +// +// Registered by ui/src/utils/pushNotifications.ts. Served as-is from +// ui/public/ via webpack's existing CopyPlugin (ui/webpack.config.js) — +// no build config change needed. + +// Force updated workers to take over immediately instead of waiting for +// every open LiveReview tab to be fully closed — otherwise a browser that +// subscribed before an sw.js change (e.g. the notification-icon fix below) +// keeps running the old worker indefinitely for background push events. +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('push', (event) => { + let payload = { title: 'LiveReview', body: 'A review you triggered has an update.' }; + try { + if (event.data) { + payload = { ...payload, ...event.data.json() }; + } + } catch (err) { + // Non-JSON payload (shouldn't happen — the backend always sends JSON) — fall back to the default text above. + } + + const options = { + body: payload.body, + tag: payload.tag, + data: { url: payload.url }, + // .ico isn't reliably rendered as a notification icon across OS/browser + // notification UIs (Chrome/Windows in particular) - a PNG is required. + // notification-icon.png is favicon.svg rasterized at 192x192 (vl-convert + // svg2png), the size Chrome's Web Push notifications expect. + icon: '/notification-icon.png', + badge: '/notification-icon.png', + }; + + event.waitUntil(self.registration.showNotification(payload.title, options)); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const targetUrl = event.notification.data && event.notification.data.url; + if (!targetUrl) { + return; + } + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { + for (const client of clients) { + if (client.url === targetUrl && 'focus' in client) { + return client.focus(); + } + } + return self.clients.openWindow(targetUrl); + }) + ); +}); diff --git a/ui/src/api/connectors.ts b/ui/src/api/connectors.ts index 565822433..9d72ffc6e 100644 --- a/ui/src/api/connectors.ts +++ b/ui/src/api/connectors.ts @@ -1,4 +1,5 @@ import apiClient from './apiClient'; +import { ReviewAISettings, AIReviewPerformanceSettings } from '../pages/AIProviders/types'; // Types for webhook status summary export interface WebhookStatusSummary { @@ -303,7 +304,7 @@ export const reorderAIConnectors = async ( } }; -export const getReviewAISettings = async (): Promise<{ helper_enabled: boolean; helper_mode: string }> => { +export const getReviewAISettings = async (): Promise => { try { return await apiClient.get('/api/v1/aiconnectors/settings'); } catch (error) { @@ -314,12 +315,16 @@ export const getReviewAISettings = async (): Promise<{ helper_enabled: boolean; export const updateReviewAISettings = async ( helperEnabled: boolean, - helperMode: string -): Promise<{ helper_enabled: boolean; helper_mode: string }> => { + helperMode: string, + preferredMaxConcurrentBatches?: number | null, + preferredReviewTimeoutSeconds?: number | null +): Promise => { try { return await apiClient.put('/api/v1/aiconnectors/settings', { helper_enabled: helperEnabled, helper_mode: helperMode, + preferred_max_concurrent_batches: preferredMaxConcurrentBatches ?? null, + preferred_review_timeout_seconds: preferredReviewTimeoutSeconds ?? null, }); } catch (error) { console.error('Error updating review AI settings:', error); @@ -327,6 +332,27 @@ export const updateReviewAISettings = async ( } }; +// Instance-wide ceiling (super-admin / self-hosted owner only). +export const getAIReviewPerformanceSettings = async (): Promise => { + try { + return await apiClient.get('/api/v1/admin/settings/ai-review-performance'); + } catch (error) { + console.error('Error fetching AI review performance settings:', error); + throw error; + } +}; + +export const updateAIReviewPerformanceSettings = async ( + settings: AIReviewPerformanceSettings +): Promise<{ message: string }> => { + try { + return await apiClient.put('/api/v1/admin/settings/ai-review-performance', settings); + } catch (error) { + console.error('Error updating AI review performance settings:', error); + throw error; + } +}; + /** * Fetch available models from an Ollama instance * @param baseURL The base URL of the Ollama instance (e.g., 'http://localhost:11434') diff --git a/ui/src/api/push.ts b/ui/src/api/push.ts new file mode 100644 index 000000000..5fd4bf402 --- /dev/null +++ b/ui/src/api/push.ts @@ -0,0 +1,32 @@ +import apiClient from './apiClient'; + +export interface NotificationPreferences { + notify_first_finding: boolean; + notify_completion: boolean; +} + +export const getVapidPublicKey = async (): Promise => { + const res = await apiClient.get<{ public_key: string }>('/push/vapid-public-key'); + return res.public_key; +}; + +export interface PushSubscriptionKeys { + endpoint: string; + keys: { p256dh: string; auth: string }; +} + +export const createPushSubscription = async (subscription: PushSubscriptionKeys): Promise => { + await apiClient.post('/push/subscriptions', subscription); +}; + +export const deletePushSubscription = async (endpoint: string): Promise => { + await apiClient.delete(`/push/subscriptions?endpoint=${encodeURIComponent(endpoint)}`); +}; + +export const getNotificationPreferences = async (): Promise => { + return apiClient.get('/users/me/notification-preferences'); +}; + +export const updateNotificationPreferences = async (prefs: NotificationPreferences): Promise => { + return apiClient.put('/users/me/notification-preferences', prefs); +}; diff --git a/ui/src/api/riskPolicy.ts b/ui/src/api/riskPolicy.ts index 521f8d6ba..f56088d3f 100644 --- a/ui/src/api/riskPolicy.ts +++ b/ui/src/api/riskPolicy.ts @@ -83,6 +83,16 @@ export interface SimulateRow { score?: number; } +export interface SimulateSummary { + scored_count: number; + skipped_count: number; + saturated_high: number; + saturated_low: number; + score_min?: number; + score_max?: number; + score_mean?: number; +} + export interface ScoreHistogramBucket { range_start: number; range_end: number; @@ -156,7 +166,7 @@ export const simulatePolicy = (body: { policy: AutoApprovePolicyUpsert; review_ids?: number[]; limit?: number; -}): Promise<{ rows: SimulateRow[] }> => apiClient.post('/auto-approve-policies/simulate', body); +}): Promise<{ rows: SimulateRow[]; summary: SimulateSummary }> => apiClient.post('/auto-approve-policies/simulate', body); // ---- review policy actions ------------------------------------------------- diff --git a/ui/src/components/DataTable/ClientTable.tsx b/ui/src/components/DataTable/ClientTable.tsx index 36b6aeb58..b1b8be4e6 100644 --- a/ui/src/components/DataTable/ClientTable.tsx +++ b/ui/src/components/DataTable/ClientTable.tsx @@ -45,6 +45,12 @@ export interface ClientTableProps { * computed. Omit for client-side tables (derives it from the table's own * row model, same as before). */ manualTotal?: number; + /** Lets the user drag column edges to resize. Off by default (unchanged + * behavior for existing callers). When on, the caller's `table` must have + * been constructed with `enableColumnResizing: true` and each column + * given an initial `size` (TanStack's resizing API) - columnWidths is + * still used as the pre-resize starting layout. */ + enableColumnResizing?: boolean; } /** @@ -67,6 +73,7 @@ export function ClientTable({ onRowClick, getRowHref, manualTotal, + enableColumnResizing = false, }: ClientTableProps) { // Show loading until the first rows arrive, even when filters keep isEmpty false. if (loading && (isEmpty || table.getRowModel().rows.length === 0)) { @@ -122,16 +129,34 @@ export function ClientTable({
- {columnWidths.map((width, i) => ( - - ))} + {enableColumnResizing + ? // Percentage widths (relative to the table's current total size) + // rather than raw px - a fixed px colgroup leaves the table + // narrower than its container on wide screens (table-layout: + // fixed doesn't stretch fixed-width columns to fill extra + // space). Percentages keep the table at a full 100% while + // still reflecting each column's size/resize state. + table.getFlatHeaders().map((header) => ( + + )) + : columnWidths.map((width, i) => )} {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( - ))} diff --git a/ui/src/components/DataTable/HeaderControls.tsx b/ui/src/components/DataTable/HeaderControls.tsx index e1c3a14d1..23dbce1e2 100644 --- a/ui/src/components/DataTable/HeaderControls.tsx +++ b/ui/src/components/DataTable/HeaderControls.tsx @@ -12,13 +12,15 @@ import { parseMultiFilterValue } from '../UIPrimitives'; // rather than generic UI. /** Wraps `children` in a native `title` attribute (plain browser tooltip, - * not the app's custom floating `Tooltip` component) only when `text` - * actually got cut down to `max` chars - otherwise the tooltip would just - * repeat what's already visible. Table cells hover a lot as the cursor - * crosses the row, so the instant, no-JS native tooltip reads better here - * than a positioned popup. */ -export const TruncatedWithTooltip: React.FC<{ text: string; max: number; children: React.ReactNode }> = ({ text, max, children }) => { - if (text.length <= max) return <>{children}; + * not the app's custom floating `Tooltip` component). Table cells hover a + * lot as the cursor crosses the row, so the instant, no-JS native tooltip + * reads better here than a positioned popup. Always set (not just when + * `text` exceeds `max`) - columns are user-resizable, so content well under + * `max` chars can still be visually clipped by a narrow column; there's no + * way to know the rendered width here, so showing the tooltip whenever + * there's any text is the only way to guarantee it's available when needed. */ +export const TruncatedWithTooltip: React.FC<{ text: string; max: number; children: React.ReactNode }> = ({ text, children }) => { + if (!text) return <>{children}; // block + min-w-0 (not inline-block): inline-block sizes to its child's // intrinsic content width, which under white-space:nowrap is the FULL // untruncated text width - it ignores the table cell's fixed width @@ -77,6 +79,17 @@ export const HeaderFilterPopover: React.FC<{ children: React.ReactNode; icon?: R return () => document.removeEventListener('mousedown', onClickOutside); }, [open]); + // Focus the popover's input/search box as soon as it opens, so clicking + // the trigger icon drops the user straight into typing instead of + // requiring a second click into the box. + useEffect(() => { + if (!open) return; + const id = requestAnimationFrame(() => { + rootRef.current?.querySelector('input, [tabindex]')?.focus(); + }); + return () => cancelAnimationFrame(id); + }, [open]); + return (
+ )} + +
+ +

+ {blocked + ? 'Click the lock or info icon next to your browser\'s address bar, allow notifications for this site, then reload the page.' + : 'Your browser may show a permission request near its address bar - allow it there to finish.'} +

+ + + ); +}; + +export default PushNotificationBanner; diff --git a/ui/src/components/reviews/ReviewEventsPage.tsx b/ui/src/components/reviews/ReviewEventsPage.tsx index 6c226c9d0..9b27bfbc5 100644 --- a/ui/src/components/reviews/ReviewEventsPage.tsx +++ b/ui/src/components/reviews/ReviewEventsPage.tsx @@ -12,6 +12,10 @@ interface ReviewEventsPageProps { isLive?: boolean; pollingInterval?: number; className?: string; + // The review's real top-level status - passed through to ReviewProgressView + // so it can resolve stages stuck pending/in-progress once the review is + // actually done. See ReviewProgressView's reviewStatus prop comment. + reviewStatus?: string; } type ViewMode = 'progress' | 'raw'; @@ -20,9 +24,10 @@ export default function ReviewEventsPage({ reviewId, initialEvents, initialEventCount, - isLive = false, + isLive = false, pollingInterval = 5000, - className + className, + reviewStatus, }: ReviewEventsPageProps) { const [currentView, setCurrentView] = useState('progress'); const [events, setEvents] = useState(initialEvents); @@ -30,10 +35,21 @@ export default function ReviewEventsPage({ const [displayCount, setDisplayCount] = useState(initialEventCount ?? initialEvents.length); const [isPolling, setIsPolling] = useState(true); // Default to ON const [lastEventCount, setLastEventCount] = useState(initialEvents.length); + // "Tail Log" (ported UX from git-lrc's local review UI Toolbar.js): when on, + // the raw log view becomes a fixed-height scrollable pane that auto-scrolls + // to the bottom on every new event; when off, it reads like a normal page. + const [isTailing, setIsTailing] = useState(false); + const tailContainerRef = useRef(null); const scrollPositionRef = useRef(0); const containerRef = useRef(null); const pollingTimeoutRef = useRef(null); + useEffect(() => { + if (isTailing && currentView === 'raw' && tailContainerRef.current) { + tailContainerRef.current.scrollTop = tailContainerRef.current.scrollHeight; + } + }, [isTailing, currentView, events]); + // Save scroll position before updates const saveScrollPosition = () => { if (containerRef.current) { @@ -272,6 +288,22 @@ export default function ReviewEventsPage({ {/* Single consolidated control */}
+ {currentView === 'raw' && ( + + )} {currentView === 'raw' && events.length > 0 && (
- {/* Content area without inner scroll - use page scroll */} + {/* Content area: normally no inner scroll (uses page scroll), except + the raw view while tailing, which gets a fixed-height scrollable + pane pinned to the bottom. */}
{currentView === 'progress' ? ( + ) : isTailing ? ( +
+ +
) : ( ({ + id: `err-${n}`, + timestamp: '2026-10-31T05:00:00.000Z', + eventType: 'log', + message: 'ERROR in LLM call batch batch-6: request timeout: API call exceeded deadline', + severity: 'error', +}); + +// The NEW terminal-failure event this fix adds (ReviewLogger.EmitBatchFailed, +// EmitBatchEvent status="failed") - previously never emitted at all. +const batch6TerminalFailureEvent: ReviewEvent = { + id: 'failed-1', + timestamp: '2026-10-31T05:04:00.000Z', + eventType: 'batch', + message: 'Batch batch-6 failed permanently: request timeout: API call exceeded deadline', + details: { batchId: 'batch-6', status: 'failed' }, + severity: 'error', +}; + +// A stray later event that loosely matches "completed" wording while still +// mentioning batch-6 - simulates the exact kind of unrelated/stale event +// that used to flip a failed batch back to fake success. +const staleCompletedLookingEvent: ReviewEvent = { + id: 'stale-1', + timestamp: '2026-10-31T05:05:00.000Z', + eventType: 'log', + message: 'Review stage completed; batch-6 posted to summary', + severity: 'info', +}; + +const expandReviewStage = () => { + fireEvent.click(screen.getByText('Review')); +}; + +// Replays review 13543's real shape: the frontend fetches at most 1000 +// events (ReviewDetail.tsx hardcodes that limit), but a long review can emit +// well over that, so the Finalization stage's own "stage completed"/ +// "completion" events - which only ever occur right at the very end - can +// fall outside the fetched window entirely. Once the review itself stops +// being live (status leaves in_progress), the page never fetches further +// events to pick them up, so this stage would stay "in-progress" forever +// even though the review record says "completed" - exactly what the review +// detail header, the Events tab, and the overall progress bar disagreed +// about for review 13543. +const stageEvent = (message: string, id: string): ReviewEvent => ({ + id, + timestamp: '2026-10-31T10:12:05.000Z', + eventType: 'log', + message, + severity: 'info', +}); + +const truncatedCompletedReviewEvents: ReviewEvent[] = [ + stageEvent('Stage started: Preparation', 'p1'), + stageEvent('Stage completed: Preparation', 'p2'), + stageEvent('Stage started: Analysis', 'a1'), + stageEvent('Stage completed: Analysis', 'a2'), + stageEvent('Stage started: Review', 'r1'), + stageEvent('Stage completed: Review', 'r2'), + stageEvent('Stage started: Artifact Generation', 'g1'), + stageEvent('Stage completed: Artifact Generation', 'g2'), + // Finalization only ever got its start marker in this truncated window - + // its own completion/"review process completed" event landed past event + // #1000 and was never fetched. + stageEvent('Stage started: Finalization', 'f1'), +]; + +describe('ReviewProgressView - batch failure truth (review 13536 regression)', () => { + it('renders a batch that failed outright as Failed, never as fake success', () => { + render( + + ); + expandReviewStage(); + + expect(screen.getByText('āœ— Failed')).toBeInTheDocument(); + expect(screen.queryByText(/āœ“ Generated/)).not.toBeInTheDocument(); + expect(screen.queryByText(/6 comments?/)).not.toBeInTheDocument(); + }); + + it('does not let a later loosely-matched event flip a failed batch back to completed', () => { + render( + + ); + expandReviewStage(); + + expect(screen.getByText('āœ— Failed')).toBeInTheDocument(); + expect(screen.queryByText(/āœ“ Generated/)).not.toBeInTheDocument(); + }); + + it('renders a batch that genuinely completed with its real comment count', () => { + const completedEvent: ReviewEvent = { + id: 'completed-1', + timestamp: '2026-10-31T05:01:00.000Z', + eventType: 'batch', + message: 'Batch batch-2 completed: generated 3 comments', + details: { batchId: 'batch-2', status: 'completed', commentCount: 3 }, + severity: 'success', + }; + const processingEvent: ReviewEvent = { + id: 'processing-2', + timestamp: '2026-10-31T04:54:45.000Z', + eventType: 'batch', + message: 'Batch batch-2 started: processing 9 files', + details: { batchId: 'batch-2', status: 'processing', fileCount: 9 }, + severity: 'info', + }; + + render(); + expandReviewStage(); + + // Must show the real comment count (3), never the unrelated file count (9). + expect(screen.getByText('āœ“ Generated 3 comments')).toBeInTheDocument(); + expect(screen.queryByText(/9 comments?/)).not.toBeInTheDocument(); + }); +}); + +describe('ReviewProgressView - stuck-stage truth once the review is actually done (review 13543 regression)', () => { + it('without reviewStatus, a stage missing its completion event (outside the fetched window) stays stuck in-progress', () => { + render(); + + expect(screen.getByText('90%')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Finalization')); + expect(screen.getByText('in-progress')).toBeInTheDocument(); + }); + + it('once reviewStatus is "completed", a stuck stage resolves to completed and the bar reaches 100%', () => { + render( + + ); + + expect(screen.getByText('100%')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Finalization')); + expect(screen.queryByText('in-progress')).not.toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/reviews/ReviewProgressView.tsx b/ui/src/components/reviews/ReviewProgressView.tsx index 369b8bd2a..9fec796e5 100644 --- a/ui/src/components/reviews/ReviewProgressView.tsx +++ b/ui/src/components/reviews/ReviewProgressView.tsx @@ -7,6 +7,14 @@ interface ReviewProgressViewProps { events: ReviewEvent[]; isLive?: boolean; className?: string; + // The review's real top-level status (from the review record, not derived + // from this event stream). Used to resolve stages that are stuck + // pending/in-progress once the review itself has actually finished - + // some stages (e.g. Finalization) only ever get marked complete by a + // specific message/eventType match, and a review can legitimately finish + // without ever emitting that exact marker, leaving the stage frozen + // in-progress forever even though the review is done. + reviewStatus?: string; } type StageStatus = 'pending' | 'in-progress' | 'completed' | 'failed'; @@ -106,12 +114,64 @@ interface ResiliencyEvent { attempt?: number; details: string; resolved: boolean; + // Repeated occurrences of the same type within a substage are grouped into + // one pill instead of rendering a duplicate pill per raw event - occurrences + // holds each underlying raw event (with its own message) so the grouped + // count can still be expanded to see exactly when/why each one happened. + // Grouping is by type alone (not exact message text): a single underlying + // LLM failure is commonly logged as several near-identical lines - a + // top-level wrapped error plus one line per unwrapped cause in its error + // chain - which previously defeated message-based dedup and rendered as + // several near-duplicate pills for what is really one failure. + occurrences: { timestamp?: string; attempt?: number; details: string }[]; } -export default function ReviewProgressView({ reviewId, events, isLive = false, className }: ReviewProgressViewProps) { +// Groups a new resiliency event into an existing pill with the same type +// within the same substage instead of always pushing a new one, so e.g. +// three retries/error-chain entries for the same underlying failure render +// as one "circuit breaker (Ɨ3)" pill rather than three near-identical pills. +function pushResiliencyEvent( + substage: Substage, + type: ResiliencyEvent['type'], + details: string, + attempt: number | undefined, + resolved: boolean, + timestamp: string | undefined +) { + const existing = substage.resiliencyEvents.find((e) => e.type === type); + if (existing) { + existing.occurrences.push({ timestamp, attempt, details }); + existing.resolved = existing.resolved || resolved; + if (attempt !== undefined) { + existing.attempt = attempt; + } + return; + } + substage.resiliencyEvents.push({ + type, + details, + attempt, + resolved, + occurrences: [{ timestamp, attempt, details }], + }); +} + +export default function ReviewProgressView({ reviewId, events, isLive = false, className, reviewStatus }: ReviewProgressViewProps) { const [stages, setStages] = useState([]); const [expandedStages, setExpandedStages] = useState>(new Set(['stage-4'])); // Default expand "Processing Batches" const [expandedEvents, setExpandedEvents] = useState>(new Set()); // Track which stages have expanded events + const [expandedResiliency, setExpandedResiliency] = useState>(new Set()); // Which grouped resiliency pills are expanded + const toggleResiliencyExpanded = (key: string) => { + setExpandedResiliency((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; useEffect(() => { const processedStages = processEventsIntoStages(events); @@ -130,7 +190,7 @@ export default function ReviewProgressView({ reviewId, events, isLive = false, c } setStages(processedStages); - }, [events]); + }, [events, reviewStatus]); const processEventsIntoStages = (events: ReviewEvent[]): Stage[] => { const stageMap = new Map(); @@ -352,6 +412,16 @@ export default function ReviewProgressView({ reviewId, events, isLive = false, c }; const markCompleted = (countHint?: number) => { + // Once a batch has been explicitly marked failed (a genuine + // terminal-failure event, not a per-attempt retry), no later + // loosely-matched event (e.g. a log line that happens to contain + // "completed"/"generated"/"posted") may flip it back to success and + // force-resolve its error pills - that is exactly how fake "āœ“ + // Generated N comments" badges got shown for batches that actually + // failed outright. + if (substage.status === 'failed') { + return; + } substage.status = 'completed'; substage.endTime = event.timestamp; ensureCount(countHint ?? parseCountFromMessage(event.message)); @@ -371,7 +441,14 @@ export default function ReviewProgressView({ reviewId, events, isLive = false, c substage.endTime = event.timestamp; }; - const countFromDetails = event.details?.commentCount ?? event.details?.fileCount; + // Only a genuine comment count may ever populate substage.count, which + // render() displays verbatim as "Generated N comments" - never fall + // back to fileCount (a "processing" event's file total). Falling back + // used to preload substage.count with a batch's file count during + // processing, so if that batch later failed outright and some + // unrelated event loosely matched into markCompleted() without its own + // real comment count, the stale file count rendered as fake success. + const countFromDetails = event.details?.commentCount; if (event.eventType === 'batch' || event.eventType === 'status') { const statusValue = normalize(String(event.details?.status || '')); @@ -403,12 +480,7 @@ export default function ReviewProgressView({ reviewId, events, isLive = false, c markCompleted(countFromDetails); } if (severityIsError(event.severity)) { - substage.resiliencyEvents.push({ - type: 'circuit_breaker', - details: event.message, - attempt: event.details?.attempt, - resolved: false - }); + pushResiliencyEvent(substage, 'circuit_breaker', event.message, event.details?.attempt, false, event.timestamp); markInProgress(); } } @@ -420,22 +492,12 @@ export default function ReviewProgressView({ reviewId, events, isLive = false, c } if (event.eventType === 'retry' || event.eventType === 'json_repair' || event.eventType === 'timeout') { - substage.resiliencyEvents.push({ - type: event.eventType, - details: event.message, - attempt: event.details?.attempt, - resolved: !severityIsError(event.severity) - }); + pushResiliencyEvent(substage, event.eventType, event.message, event.details?.attempt, !severityIsError(event.severity), event.timestamp); markInProgress(); } if (event.eventType === 'error') { - substage.resiliencyEvents.push({ - type: 'circuit_breaker', - details: event.message, - attempt: event.details?.attempt, - resolved: false - }); + pushResiliencyEvent(substage, 'circuit_breaker', event.message, event.details?.attempt, false, event.timestamp); markFailed(); } @@ -613,6 +675,30 @@ export default function ReviewProgressView({ reviewId, events, isLive = false, c } }); + // The review itself is the source of truth for whether it's actually + // over - some stages (Finalization in particular) only flip to + // 'completed' above on a specific message/eventType match, and a review + // can legitimately finish without ever emitting that exact marker. Once + // the review record says it's done, any stage/substage still stuck + // pending/in-progress is resolved to match rather than spinning forever + // (e.g. "Finalization: in-progress" + a stale "70% complete" bar next to + // a review the header already shows as Completed). + if (reviewStatus === 'completed' || reviewStatus === 'failed') { + const resolvedStatus: StageStatus = reviewStatus === 'completed' ? 'completed' : 'failed'; + stageMap.forEach(stage => { + if (stage.status === 'pending' || stage.status === 'in-progress') { + stage.status = resolvedStatus; + stage.endTime = stage.endTime ?? stage.events[stage.events.length - 1]?.timestamp; + } + stage.substages?.forEach(substage => { + if (substage.status === 'pending' || substage.status === 'in-progress') { + substage.status = resolvedStatus; + substage.endTime = substage.endTime ?? substage.events[substage.events.length - 1]?.timestamp; + } + }); + }); + } + return STAGE_DEFINITIONS.map(def => stageMap.get(def.key)!); }; @@ -854,26 +940,50 @@ export default function ReviewProgressView({ reviewId, events, isLive = false, c
- {/* Resiliency events */} + {/* Resiliency events - grouped: repeated occurrences of the + same underlying failure render as one pill with a count, + expandable to see each individual occurrence. */} {substage.resiliencyEvents.length > 0 && (
- {substage.resiliencyEvents.map((resEvent, idx) => ( -
- {getResiliencyIcon(resEvent.type)} - - {resEvent.type.replace('_', ' ')} - {resEvent.attempt && ` (${resEvent.attempt}x)`} - - {resEvent.resolved && } -
- ))} + {substage.resiliencyEvents.map((resEvent, idx) => { + const pillKey = `${stage.id}-${substage.id}-${idx}`; + const isExpanded = expandedResiliency.has(pillKey); + const count = resEvent.occurrences.length; + return ( +
+ + {isExpanded && ( +
+ {resEvent.occurrences.map((occ, occIdx) => ( +
+

+ {occ.timestamp ? new Date(occ.timestamp).toLocaleTimeString() : `#${occIdx + 1}`} + {occ.attempt !== undefined && ` Ā· attempt ${occ.attempt}`} +

+

{occ.details}

+
+ ))} +
+ )} +
+ ); + })}
)} diff --git a/ui/src/components/reviews/RiskAssessment.tsx b/ui/src/components/reviews/RiskAssessment.tsx index bf92bd068..529b9e712 100644 --- a/ui/src/components/reviews/RiskAssessment.tsx +++ b/ui/src/components/reviews/RiskAssessment.tsx @@ -52,7 +52,10 @@ export function riskAssessment( report: BlastRadiusReport | BlastRadiusSkipped | null, poll: RiskPoll, isPR: boolean, - badgeScores?: RiskScore[], // same numbers as the Findings badges, once that panel has loaded + // Same numbers as the Findings badges (they blend in finding severity on + // top of the structural blast-radius score). `null` means Findings hasn't + // loaded them yet -- distinct from `[]`, a confirmed zero-hunk result. + badgeScores?: RiskScore[] | null, ): RiskAssessment { const none: RiskScore[] = []; if (report && isBlastRadiusSkipped(report)) { @@ -63,13 +66,19 @@ export function riskAssessment( } const ready = report as BlastRadiusReport | null; // skipped returned above if (ready) { - // Until Findings loads, fall back to the structural score (badges add 10% finding severity). - const scores = badgeScores?.length - ? badgeScores - : ready.Files.flatMap((f) => (f.Hunks || []).map((h) => ({ path: f.Path, score: h.Combined || 0 }))); - const top = Math.max(0, ...scores.map((h) => h.score)); + if (badgeScores == null) { + // Blast radius landed, but the Findings panel (which blends in finding + // severity) hasn't computed its scores yet -- previously this fell back + // to the structural-only score instead, which could land in a + // different risk tier and visibly flash/correct the moment Findings + // finished loading. Showing a neutral, honest "still calculating" state + // instead of a number that might be wrong is worth more than instant + // (but possibly misleading) feedback here. + return { kind: 'calculating', value: 'Calculating...', tone: '!text-sky-400', text: 'Waiting for findings to finish loading before computing the full risk score.', scores: none, top: 0 }; + } + const top = Math.max(0, ...badgeScores.map((h) => h.score)); const text = 'Highest risk score (0-100) across the changed code.'; - return { kind: 'ready', value: blastRadiusTierLabel(top), tone: TIER_TONE[blastRadiusTier(top)], text, report: ready, scores, top }; + return { kind: 'ready', value: blastRadiusTierLabel(top), tone: TIER_TONE[blastRadiusTier(top)], text, report: ready, scores: badgeScores, top }; } if (poll === 'gave_up') { return { kind: 'unavailable', value: 'Not available', tone: '!text-slate-400', text: 'No result arrived within 15 minutes.', scores: none, top: 0 }; diff --git a/ui/src/components/reviews/diffviewer/CommentThread.tsx b/ui/src/components/reviews/diffviewer/CommentThread.tsx index 61591a381..556557564 100644 --- a/ui/src/components/reviews/diffviewer/CommentThread.tsx +++ b/ui/src/components/reviews/diffviewer/CommentThread.tsx @@ -7,10 +7,11 @@ // comment card; the styles below mirror git-lrc's actual .badge-info/.badge-warning/ // .badge-critical rgba values (Comment.js/styles.css), which were designed for a // dark surface. -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { DiffReviewComment } from '../../../types/reviews'; import { BlastRadiusHunkReport } from '../../../types/reviews'; import { commentDomId, severityBadgeStyle } from './diffUtils'; +import { getCommentRenderLabel } from './reviewPerformance'; import VoteButtons from './VoteButtons'; import RiskBadge from './RiskBadge'; @@ -27,6 +28,12 @@ interface CommentThreadProps { // score and comment are always assessed together" (RiskBadge.js), and both // copies drive the one shared panel, not independent ones. onOpenBreakdown?: () => void; + // Time-to-first-comment tracking (ported from git-lrc - see reviewPerformance.ts / + // DiffViewerPanel.tsx). baselineMs is the review's start time; onCommentRendered fires + // once per comment id the first time it mounts, recording when it actually appeared. + baselineMs?: number; + commentRenderTimes?: Record; + onCommentRendered?: (commentKey: string) => void; } function buildMetaItems(comment: DiffReviewComment): { label: string; value: string }[] { @@ -64,11 +71,22 @@ const CommentCard: React.FC<{ hunkRiskScore?: number; codeExcerpt?: string; onOpenBreakdown?: () => void; -}> = ({ id, reviewId, filePath, comment, hunkBlastDetail, hunkRiskScore, codeExcerpt, onOpenBreakdown }) => { + renderLabel?: string; + onRendered?: () => void; +}> = ({ id, reviewId, filePath, comment, hunkBlastDetail, hunkRiskScore, codeExcerpt, onOpenBreakdown, renderLabel, onRendered }) => { const [hidden, setHidden] = useState(false); const [copyLabel, setCopyLabel] = useState(null); + // Mount-only: records the first moment this specific comment actually + // rendered, for the "Appeared in Xs" meta item below (ported from + // git-lrc's handleCommentRendered - app.js). + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { onRendered?.(); }, []); + const metaItems = buildMetaItems(comment); + if (renderLabel) { + metaItems.push({ label: 'Timing', value: renderLabel }); + } const handleCopy = useCallback(() => { navigator.clipboard.writeText(buildCopyText(filePath, comment, codeExcerpt)).then(() => { @@ -179,23 +197,28 @@ const CommentCard: React.FC<{ ); }; -const CommentThread: React.FC = ({ reviewId, filePath, comments, hunkBlastDetail, hunkRiskScore, codeExcerpt, onOpenBreakdown }) => { +const CommentThread: React.FC = ({ reviewId, filePath, comments, hunkBlastDetail, hunkRiskScore, codeExcerpt, onOpenBreakdown, baselineMs, commentRenderTimes, onCommentRendered }) => { if (!comments.length) return null; return (
- {comments.map(({ comment, idx }) => ( - - ))} + {comments.map(({ comment, idx }) => { + const domId = commentDomId(filePath, comment, idx); + return ( + onCommentRendered(domId) : undefined} + /> + ); + })}
); }; diff --git a/ui/src/components/reviews/diffviewer/DiffViewerPanel.test.tsx b/ui/src/components/reviews/diffviewer/DiffViewerPanel.test.tsx new file mode 100644 index 000000000..a7d083e4f --- /dev/null +++ b/ui/src/components/reviews/diffviewer/DiffViewerPanel.test.tsx @@ -0,0 +1,251 @@ +// Component-level regression test for the "findings disappear mid-review" bug +// (see reviewStreamState.ts's shouldClearStreamedComments doc comment for the +// full root-cause writeup). Exercises the real DiffViewerPanel end-to-end +// against mocked API responses, with fake timers driving the 1s event-poll +// loop exactly as it runs in production. +import React from 'react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter } from 'react-router-dom'; +import DiffViewerPanel from './DiffViewerPanel'; +import { getDiffReview, getReviewEvents } from '../../../api/reviews'; +import { BlastRadiusReport, DiffReviewStatusResponse, ReviewEvent, ReviewEventsResponse } from '../../../types/reviews'; + +jest.mock('../../../api/reviews'); +jest.mock('../../../api/githubApp', () => ({ + getGitHubAppInstallCheck: jest.fn(), + startGitHubAppConnect: jest.fn(), +})); +jest.mock('../../../utils/redirectOverlay', () => ({ showRedirectOverlay: jest.fn() })); +jest.mock('../../../utils/notify', () => ({ notify: { success: jest.fn(), error: jest.fn() } })); + +const mockGetDiffReview = getDiffReview as jest.Mock; +const mockGetReviewEvents = getReviewEvents as jest.Mock; + +function renderPanel(reviewId = 1, blastReport?: BlastRadiusReport) { + return render( + + + + ); +} + +function inProgressResponse(): DiffReviewStatusResponse { + return { + status: 'in_progress', + review_id: '1', + files: [ + { file_path: 'a.go', hunks: [], comments: [] }, + { file_path: 'b.go', hunks: [], comments: [] }, + ], + }; +} + +function batchEvent(id: number, filePath: string, count: number, startLine = 1): ReviewEvent { + const comments = Array.from({ length: count }, (_, i) => ({ + FilePath: filePath, + Line: startLine + i, + Content: `finding ${filePath}:${startLine + i}`, + Severity: 'warning', + })); + return { + id, + reviewId: 1, + orgId: 1, + time: new Date(2026, 0, 1, 0, 0, id).toISOString(), + type: 'batch', + batchId: `batch-${id}`, + data: { status: 'completed', comments } as any, + }; +} + +function eventsResponse(events: ReviewEvent[]): ReviewEventsResponse { + return { events, meta: { reviewId: 1, count: events.length, limit: 1000 } }; +} + +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('DiffViewerPanel - findings streaming / disappearing-comments regression', () => { + beforeEach(() => { + jest.useFakeTimers({ legacyFakeTimers: false }); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('keeps all 8 streamed comments visible when the review ends "failed" with an empty result (the exact reported bug)', async () => { + mockGetDiffReview.mockResolvedValueOnce(inProgressResponse()); + // First poll: 5 comments from a.go's batch. Second poll: 3 more from + // b.go's batch, plus the terminal "failed" status - mirroring one bad + // Gemini batch wiping review_result after other batches already streamed. + mockGetReviewEvents + .mockResolvedValueOnce(eventsResponse([batchEvent(1, 'a.go', 5)])) + .mockResolvedValueOnce( + eventsResponse([ + batchEvent(2, 'b.go', 3), + { id: 3, reviewId: 1, orgId: 1, time: new Date(2026, 0, 1, 0, 0, 3).toISOString(), type: 'completion', data: { errorSummary: 'one batch failed' } }, + ]) + ) + .mockResolvedValue(eventsResponse([])); + mockGetDiffReview.mockResolvedValueOnce({ + status: 'failed', + review_id: '1', + message: 'one batch failed', + files: [ + { file_path: 'a.go', hunks: [], comments: [] }, + { file_path: 'b.go', hunks: [], comments: [] }, + ], + }); + + renderPanel(); + await flush(); + + for (let i = 0; i < 3; i++) { + await act(async () => { jest.advanceTimersByTime(1000); }); + await flush(); + } + + // The bug: all 8 comments must still be visible, not wiped to 0. + await waitFor(() => expect(screen.getByText('— / 8')).toBeInTheDocument()); + expect(screen.getByText(/This review failed before finishing/)).toBeInTheDocument(); + }); + + it('trusts the smaller final set on "completed" (legitimate backend dedup/cap is not padded back out by the stream)', async () => { + mockGetDiffReview.mockResolvedValueOnce(inProgressResponse()); + mockGetReviewEvents + .mockResolvedValueOnce(eventsResponse([batchEvent(1, 'a.go', 4)])) + .mockResolvedValueOnce( + eventsResponse([ + { id: 2, reviewId: 1, orgId: 1, time: new Date(2026, 0, 1, 0, 0, 2).toISOString(), type: 'completion', data: {} }, + ]) + ) + .mockResolvedValue(eventsResponse([])); + // Final, authoritative result: AggregateAndCombineOutputs deduped down to 2. + mockGetDiffReview.mockResolvedValueOnce({ + status: 'completed', + review_id: '1', + files: [ + { file_path: 'a.go', hunks: [], comments: [ + { file_path: 'a.go', line: 1, content: 'deduped finding 1' }, + { file_path: 'a.go', line: 2, content: 'deduped finding 2' }, + ] }, + { file_path: 'b.go', hunks: [], comments: [] }, + ], + }); + + renderPanel(); + await flush(); + + for (let i = 0; i < 3; i++) { + await act(async () => { jest.advanceTimersByTime(1000); }); + await flush(); + } + + await waitFor(() => expect(screen.getByText('— / 2')).toBeInTheDocument()); + }); + + it('keeps showing streamed comments through a network error on every poll, instead of clearing or crashing', async () => { + mockGetDiffReview.mockResolvedValueOnce(inProgressResponse()); + mockGetReviewEvents + .mockResolvedValueOnce(eventsResponse([batchEvent(1, 'a.go', 3)])) + .mockRejectedValue(new Error('network down')); + + renderPanel(); + await flush(); + await act(async () => { jest.advanceTimersByTime(1000); }); + await flush(); + expect(screen.getByText('— / 3')).toBeInTheDocument(); + + for (let i = 0; i < 3; i++) { + await act(async () => { jest.advanceTimersByTime(1000); }); + await flush(); + } + // still 3 - the repeated poll failures neither cleared the stream nor crashed the component + expect(screen.getByText('— / 3')).toBeInTheDocument(); + }); + + it('does not duplicate comments when the same event page is effectively re-delivered (overlap window)', async () => { + mockGetDiffReview.mockResolvedValueOnce(inProgressResponse()); + const firstBatch = batchEvent(1, 'a.go', 2); + mockGetReviewEvents + .mockResolvedValueOnce(eventsResponse([firstBatch])) + // Same event id reappears (overlap window), nothing genuinely new. + .mockResolvedValueOnce(eventsResponse([firstBatch])) + .mockResolvedValue(eventsResponse([])); + + renderPanel(); + await flush(); + await act(async () => { jest.advanceTimersByTime(1000); }); + await flush(); + expect(screen.getByText('— / 2')).toBeInTheDocument(); + + await act(async () => { jest.advanceTimersByTime(1000); }); + await flush(); + // Still 2, not 4 - extractNewEvents' seen-id set deduped the replay. + expect(screen.getByText('— / 2')).toBeInTheDocument(); + }); + + it('keeps a comment visible after blast-radius data arrives late and flips the view to the default risk-flat sort', async () => { + // Two hunks on the same file with a gap between their line ranges + // ([5,8) and [20,23)), and a comment sitting in that gap (line 12) - + // this is the exact flattenFilesByRisk shape that used to drop a + // comment the instant canSortByRisk flipped true (see blastRadius.ts). + const hunkA = { old_start_line: 0, old_line_count: 0, new_start_line: 5, new_line_count: 3, content: '' }; + const hunkB = { old_start_line: 0, old_line_count: 0, new_start_line: 20, new_line_count: 3, content: '' }; + mockGetDiffReview.mockResolvedValueOnce({ + status: 'completed', + review_id: '1', + files: [ + { + file_path: 'a.go', + hunks: [hunkA, hunkB], + comments: [{ file_path: 'a.go', line: 12, content: 'gap finding' }], + }, + ], + }); + mockGetReviewEvents.mockResolvedValue(eventsResponse([])); + + const { rerender } = renderPanel(1, undefined); + await flush(); + + // Before blast data arrives: canSortByRisk is false, plain per-file view. + expect(screen.getByText('— / 1')).toBeInTheDocument(); + + // Blast radius data lands late (server-side River job finishing after + // the AI review), matching a hunk somewhere in the diff - flips + // canSortByRisk true and switches to flattenFilesByRisk. + const blastReport: BlastRadiusReport = { + Project: 'p', + GeneratedAt: 'now', + Files: [ + { + Path: 'a.go', + Hunks: [ + { + FilePath: 'a.go', Header: '@@', NewStart: 5, NewLines: 3, + BlastRadiusRaw: 10, BlastRadiusNorm: 10, MaxBlastRadiusRaw: 100, + ReviewPriorityRaw: 5, ReviewPriorityNorm: 5, MaxReviewPriorityRaw: 100, + Combined: 20, HygieneMultiplier: 1, Weights: { BlastRadius: 0.6, ReviewPriority: 0.4 }, + }, + ], + }, + ], + }; + rerender( + + + + ); + await flush(); + + // The gap comment must still be visible after the flatten transition. + expect(screen.getByText('— / 1')).toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx b/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx index 41ed7f68a..f63172847 100644 --- a/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx +++ b/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx @@ -10,15 +10,24 @@ // (real deep-linking — a step beyond git-lrc's own behavior, which doesn't // read location.hash on initial mount either), and works with the browser // Back/Forward buttons via the router's normal history subscription. -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; import { useSearchParams } from 'react-router-dom'; import { Alert, Button, EmptyState, Icons, Spinner } from '../../UIPrimitives'; -import { getDiffReview } from '../../../api/reviews'; +import { getDiffReview, getReviewEvents } from '../../../api/reviews'; import { getGitHubAppInstallCheck, startGitHubAppConnect } from '../../../api/githubApp'; import { showRedirectOverlay } from '../../../utils/redirectOverlay'; import { notify } from '../../../utils/notify'; -import { BlastRadiusReport, DiffReviewFile, DiffReviewStatusResponse } from '../../../types/reviews'; +import { BlastRadiusReport, DiffReviewComment, DiffReviewFile, DiffReviewStatusResponse } from '../../../types/reviews'; +import { + appendStreamedCommentsToFiles, + EVENTS_POLL_LIMIT, + extractExternalCommentsFromEvents, + extractNewEvents, + inferReviewStatusFromEvents, + shouldClearStreamedComments, + sinceWithOverlap, +} from './reviewStreamState'; import type { RiskScore } from '../RiskAssessment'; import { attachBlastData, buildBlastLookup, flattenFilesByRisk, hasBlastRadiusData, sortFilesByBlastRadius } from '../../../lib/blastRadius'; import { commentDomId, fileNavId, scrollElementIntoViewBelowStickyBars } from './diffUtils'; @@ -39,6 +48,7 @@ import Sidebar, { HunkNav } from './Sidebar'; import IssueFilterBar from './IssueFilterBar'; import CommentNav, { NavComment } from './CommentNav'; import SummaryPanel from './SummaryPanel'; +import { buildPerformanceSnapshot, getFirstRenderTime, getPerformanceNow, recordFirstRenderTime } from './reviewPerformance'; interface DiffViewerPanelProps { reviewId: number; @@ -46,6 +56,26 @@ interface DiffViewerPanelProps { blastReport?: BlastRadiusReport; // Receives each scored change's badge score (blended with its findings) for the page header. onRiskScores?: (scores: RiskScore[]) => void; + // Receives the live (filter-applied) total comment count, so the page + // header can show it instead of this panel rendering its own line. + onCommentCount?: (count: number) => void; + // The review's real wall-clock start time (ISO string), used to seed the + // "Elapsed"/"time to first comment" baseline so it reflects when the review + // actually started rather than resetting to "now" on every page refresh. + reviewStartedAt?: string; +} + +// Translates a wall-clock timestamp into the same domain as getPerformanceNow() +// (which is performance.now() - monotonic since page load, not wall-clock), so +// the elapsed-time baseline survives a refresh instead of resetting to "now". +function baselineFromStartedAt(reviewStartedAt: string | undefined): number { + if (reviewStartedAt) { + const startedMs = new Date(reviewStartedAt).getTime(); + if (Number.isFinite(startedMs)) { + return startedMs - Date.now() + getPerformanceNow(); + } + } + return getPerformanceNow(); } // Mirrors git-lrc's SORT_MODE_RISK_FLAT / SORT_MODE_RISK_FILE / SORT_MODE_DIFF @@ -112,10 +142,37 @@ interface DiffNavTarget { file?: string; } -const DiffViewerPanel: React.FC = ({ reviewId, blastReport, onRiskScores }) => { +const DiffViewerPanel: React.FC = ({ reviewId, blastReport, onRiskScores, onCommentCount, reviewStartedAt }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Comments streamed in live via batch events while the review is still + // running (see reviewStreamState.ts). Cleared once the final, authoritative + // getDiffReview fetch lands on completion -- that response already carries + // the real (possibly helper-polished) comments, so there's nothing left to + // merge on top of it. + const [streamedComments, setStreamedComments] = useState<(DiffReviewComment & { file_path: string })[]>([]); + const seenEventIdsRef = React.useRef>(new Set()); + const lastEventTimeRef = React.useRef(null); + // Time-to-first-comment tracking (ported from git-lrc's review_performance_state.mjs - + // see reviewPerformance.ts). baselineMs is set once, when this panel first mounts for + // this review; commentRenderTimes records the first moment each individual comment + // (by its stable DOM id) actually renders, streamed or final. + const baselineMsRef = useRef(baselineFromStartedAt(reviewStartedAt)); + const [commentRenderTimes, setCommentRenderTimes] = useState>({}); + const recordCommentRendered = useCallback((commentKey: string) => { + setCommentRenderTimes((prev) => recordFirstRenderTime(prev, commentKey, getPerformanceNow())); + }, []); + // Ticks the "Elapsed Xs" banner text forward once a second while waiting + // for the first comment - without this, it would only update whenever a + // poll happens to bring new events, and otherwise sit frozen. + const [, forcePerfTick] = useState(0); + const hasFirstComment = getFirstRenderTime(commentRenderTimes) !== null; + useEffect(() => { + if (hasFirstComment || data?.status === 'completed' || data?.status === 'failed') return; + const tick = setInterval(() => forcePerfTick((n) => n + 1), 1000); + return () => clearInterval(tick); + }, [hasFirstComment, data?.status]); const blastLookup = useMemo(() => (blastReport ? buildBlastLookup(blastReport) : undefined), [blastReport]); const [sortMode, setSortMode] = useState('risk-flat'); const [expandedFiles, setExpandedFiles] = useState>({}); @@ -155,6 +212,11 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport let cancelled = false; setLoading(true); setError(null); + setStreamedComments([]); + seenEventIdsRef.current = new Set(); + lastEventTimeRef.current = null; + baselineMsRef.current = baselineFromStartedAt(reviewStartedAt); + setCommentRenderTimes({}); getDiffReview(reviewId) .then((res) => { if (cancelled) return; @@ -182,18 +244,87 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport }; }, [reviewId]); - const rawFiles = data?.files || []; + // While the review is still running, poll the same events endpoint + // git-lrc's local review UI polls, merge each batch's streamed comments + // into the file list as they land (see reviewStreamState.ts), and do one + // final authoritative getDiffReview fetch once events report completion — + // this is the only point where a finding's text can change (the helper + // "polish" stage only rewords, it never adds/drops/moves a finding; see + // the incremental-findings plan for why this is a safe reconciliation). + useEffect(() => { + if (!data || data.status === 'completed' || data.status === 'failed') { + return; + } + let cancelled = false; + + const poll = () => { + getReviewEvents(reviewId, sinceWithOverlap(lastEventTimeRef.current), EVENTS_POLL_LIMIT) + .then((res) => { + if (cancelled) return; + const { newEvents, nextSeenEventIds, lastSeenAt } = extractNewEvents(res.events, seenEventIdsRef.current); + seenEventIdsRef.current = nextSeenEventIds; + if (lastSeenAt) lastEventTimeRef.current = lastSeenAt; + + const newComments = extractExternalCommentsFromEvents(newEvents); + if (newComments.length > 0) { + setStreamedComments((prev) => [...prev, ...newComments]); + } + + const inferredStatus = inferReviewStatusFromEvents(newEvents); + if (inferredStatus === 'completed' || inferredStatus === 'failed') { + getDiffReview(reviewId) + .then((finalRes) => { + if (cancelled) return; + setData(finalRes); + // See shouldClearStreamedComments's doc comment: a 'failed' + // review's final fetch can legitimately have fewer comments + // than what already streamed (one bad batch wipes the whole + // result) - keep the stream in that case instead of wiping + // already-shown, real findings. + if (shouldClearStreamedComments(inferredStatus)) { + setStreamedComments([]); + } + }) + .catch(() => {}); + } + }) + .catch(() => {}); + }; + + poll(); + const interval = setInterval(poll, 1000); + return () => { + cancelled = true; + clearInterval(interval); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reviewId, data?.status]); + + // Merges in-flight streamedComments on top of whatever getDiffReview last + // returned. Once the review completes, streamedComments is cleared (see + // the polling effect above) and this is just the final response's files. + const rawFiles = useMemo( + () => appendStreamedCommentsToFiles(data?.files, streamedComments), + [data?.files, streamedComments] + ); // Attached once here (not per-render inside FileBlock/HunkBlock) so // sortFilesByBlastRadius/flattenFilesByRisk/hasBlastRadiusData all see // hunk.BlastRadius already in place — see attachBlastData's doc comment. const enrichedFiles = useMemo(() => attachBlastData(rawFiles, blastLookup || new Map()), [rawFiles, blastLookup]); const canSortByRisk = useMemo(() => hasBlastRadiusData(enrichedFiles), [enrichedFiles]); useEffect(() => { - if (!blastReport || !onRiskScores) return; + // Also gated on `data` (not just blastReport): before the diff itself has + // loaded, enrichedFiles is `[]` purely because rawFiles is still empty, + // not because there are genuinely zero scored hunks. Reporting that + // premature `[]` up would show a confident-but-wrong "Minimal risk" in + // the page header for a moment before correcting to the real score once + // data arrives -- the same class of flicker this callback exists to + // avoid downstream (see ReviewDetail's riskScores state comment). + if (!blastReport || !onRiskScores || !data) return; onRiskScores(enrichedFiles.flatMap((f) => (f.hunks || []) .filter((h) => typeof h.BlastRadius === 'number') .map((h) => ({ path: f.file_path, score: h.BlastRadius as number })))); - }, [enrichedFiles, blastReport, onRiskScores]); + }, [enrichedFiles, blastReport, onRiskScores, data]); const files = useMemo(() => { if (!canSortByRisk) return enrichedFiles; if (sortMode === 'risk-flat') return flattenFilesByRisk(enrichedFiles); @@ -203,6 +334,9 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport const facets = useMemo(() => buildFilterFacets(enrichedFiles, filters), [enrichedFiles, filters]); const navComments = useMemo(() => buildVisibleCommentNav(files, filters), [files, filters]); + useEffect(() => { + onCommentCount?.(navComments.length); + }, [navComments, onCommentCount]); const allExpanded = files.length > 0 && files.every((f) => expandedFiles[f.file_path]); // Sidebar always shows the real, unflattened file list (git-lrc's // Sidebar.js receives `filesInDiffOrder`, never the sort-mode-dependent @@ -300,33 +434,43 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport ); } - if (data && data.status === 'failed') { + if (!data) { return ( } - title="This review failed" - description={data.message || 'The review did not complete, so there are no findings to show. The Events tab has the details.'} + icon={} + title="No diff data for this review" + description="This review has no structured diff/findings data — most likely it wasn’t triggered via the diff-review flow." /> ); } - if (!data || data.status !== 'completed' || !data.files) { + // A failure with nothing streamed in yet has nothing to show; a failure + // partway through a batch still has real findings worth keeping visible + // (handled below, alongside the in-progress case) rather than discarding + // them behind this empty state. + if (data.status === 'failed' && files.length === 0) { return ( } - title={data && data.status !== 'completed' ? 'Diff not ready yet' : 'No diff data for this review'} - description={ - data && data.status !== 'completed' - ? 'This review is still in progress. Findings will appear here once it completes.' - : 'This review has no structured diff/findings data — most likely it wasn’t triggered via the diff-review flow.' - } + icon={} + title="This review failed" + description={data.message || 'The review did not complete, so there are no findings to show. The Events tab has the details.'} /> ); } const quiz = data.quiz || []; + const isRunning = data.status === 'created' || data.status === 'in_progress'; if (files.length === 0) { + if (isRunning) { + return ( + } + title="Preparing the diff…" + description="This review is still starting up. The file list and findings will appear here as soon as they're ready — no need to switch tabs." + /> + ); + } return } title="No files changed" />; } @@ -341,6 +485,32 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport onHunkClick={jumpToHunk} />
+ {isRunning && (() => { + const firstCommentMs = getFirstRenderTime(commentRenderTimes); + const snapshot = buildPerformanceSnapshot({ + baselineMs: baselineMsRef.current, + nowMs: getPerformanceNow(), + firstCommentMs, + totalComments: navComments.length, + }); + return ( + }> +

+ {firstCommentMs === null + ? `Elapsed ${snapshot.elapsedLabel} • waiting for first result` + : `First result in ${snapshot.firstCommentLabel} • ${navComments.length} so far`} + {' '}Findings are streaming in as each batch of files finishes and will keep growing until the review completes. +

+
+ ); + })()} + {data.status === 'failed' && ( + }> +

+ {data.message || 'The review did not complete.'} Showing the {navComments.length} finding(s) streamed in before it failed — the Events tab has the full detail. +

+
+ )} {data.comments_not_posted && ( }>

{data.comments_not_posted.message}

@@ -406,6 +576,9 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport expanded={!!expandedFiles[file.file_path]} onToggle={() => toggleFile(file.file_path)} filters={filters} + baselineMs={baselineMsRef.current} + commentRenderTimes={commentRenderTimes} + onCommentRendered={recordCommentRendered} /> ))}
diff --git a/ui/src/components/reviews/diffviewer/FileBlock.tsx b/ui/src/components/reviews/diffviewer/FileBlock.tsx index 498e4837e..42075299b 100644 --- a/ui/src/components/reviews/diffviewer/FileBlock.tsx +++ b/ui/src/components/reviews/diffviewer/FileBlock.tsx @@ -16,9 +16,14 @@ interface FileBlockProps { expanded: boolean; onToggle: () => void; filters: IssueFilters; + // Time-to-first-comment tracking (see DiffViewerPanel.tsx / reviewPerformance.ts) - + // threaded straight through to CommentThread, which renders the "Appeared in Xs" label. + baselineMs?: number; + commentRenderTimes?: Record; + onCommentRendered?: (commentKey: string) => void; } -const FileBlock: React.FC = ({ reviewId, file, expanded, onToggle, filters }) => { +const FileBlock: React.FC = ({ reviewId, file, expanded, onToggle, filters, baselineMs, commentRenderTimes, onCommentRendered }) => { const visibleCount = countFileVisibleComments(file, filters); const indexedComments = (file.comments || []).map((comment, idx) => ({ comment, idx })); const navId = fileNavId(file); @@ -61,6 +66,9 @@ const FileBlock: React.FC = ({ reviewId, file, expanded, onToggl comments={indexedComments} hunkIndex={idx} filters={filters} + baselineMs={baselineMs} + commentRenderTimes={commentRenderTimes} + onCommentRendered={onCommentRendered} /> )) )} diff --git a/ui/src/components/reviews/diffviewer/HunkBlock.tsx b/ui/src/components/reviews/diffviewer/HunkBlock.tsx index 21c672753..beb8f124c 100644 --- a/ui/src/components/reviews/diffviewer/HunkBlock.tsx +++ b/ui/src/components/reviews/diffviewer/HunkBlock.tsx @@ -29,6 +29,9 @@ interface HunkBlockProps { comments: { comment: DiffReviewComment; idx: number }[]; hunkIndex: number; filters: IssueFilters; + baselineMs?: number; + commentRenderTimes?: Record; + onCommentRendered?: (commentKey: string) => void; } const lineClass = (type: DiffLine['type']): string => { @@ -51,7 +54,7 @@ const linePrefix = (type: DiffLine['type']): string => { return ' '; }; -const HunkBlock: React.FC = ({ reviewId, filePath, navId, hunk, comments, hunkIndex, filters }) => { +const HunkBlock: React.FC = ({ reviewId, filePath, navId, hunk, comments, hunkIndex, filters, baselineMs, commentRenderTimes, onCommentRendered }) => { const lines = parseHunkLines(hunk); const [panelOpen, setPanelOpen] = useState(false); const blastDetail = hunk.BlastDetail; @@ -125,6 +128,9 @@ const HunkBlock: React.FC = ({ reviewId, filePath, navId, hunk, hunkRiskScore={hunk.BlastRadius} codeExcerpt={buildIssueCodeExcerpt(lines, idx, 1)} onOpenBreakdown={openBreakdown} + baselineMs={baselineMs} + commentRenderTimes={commentRenderTimes} + onCommentRendered={onCommentRendered} /> diff --git a/ui/src/components/reviews/diffviewer/reviewPerformance.ts b/ui/src/components/reviews/diffviewer/reviewPerformance.ts new file mode 100644 index 000000000..e9d269fb8 --- /dev/null +++ b/ui/src/components/reviews/diffviewer/reviewPerformance.ts @@ -0,0 +1,147 @@ +// Ported from git-lrc:internal/staticserve/static/components/review_performance_state.mjs#L1-L114 +// (as of a1c3b3c). Pure, framework-agnostic timing helpers - no React - ported ~1:1 per +// this repo's porting convention. Tracks "time to first comment" (git-lrc's rough target +// is ~30s) both at the review level and per individual comment, so a user can see which +// findings streamed in late instead of just a single aggregate number. + +const FALLBACK_ACTIVITY_LINES = [ + 'Preparing review workspace', + 'Scanning changed files', + 'Analyzing diff structure', + 'Streaming review activity', +]; + +export function getPerformanceNow(): number { + if (typeof performance !== 'undefined' && typeof performance.now === 'function') { + return performance.now(); + } + return Date.now(); +} + +export function formatDurationShort(durationMs: number): string { + if (!Number.isFinite(durationMs) || durationMs < 0) { + return ''; + } + + const totalSeconds = Math.max(0, Math.round(durationMs / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } + if (minutes > 0) { + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + } + return `${seconds}s`; +} + +export function recordFirstRenderTime( + existingTimings: Record | undefined, + commentKey: string | undefined | null, + renderMs: number +): Record { + const current = existingTimings || {}; + if (!commentKey || !Number.isFinite(renderMs)) { + return current; + } + if (Object.prototype.hasOwnProperty.call(current, commentKey)) { + return current; + } + return { + ...current, + [commentKey]: renderMs, + }; +} + +export function getFirstRenderTime(renderTimings: Record | undefined): number | null { + const values = Object.values(renderTimings || {}).filter((value) => Number.isFinite(value)); + if (values.length === 0) { + return null; + } + return Math.min(...values); +} + +export interface PerformanceSnapshotInput { + baselineMs?: number; + nowMs?: number; + firstCommentMs?: number | null; + totalComments?: number; + completedMs?: number; +} + +export interface PerformanceSummaryItem { + key: string; + label: string; + value: string; +} + +export interface PerformanceSnapshot { + elapsedMs: number; + elapsedLabel: string; + firstCommentElapsedMs: number | null; + firstCommentLabel: string; + totalComments: number; + summaryItems: PerformanceSummaryItem[]; +} + +export function buildPerformanceSnapshot({ + baselineMs, + nowMs, + firstCommentMs, + totalComments, + completedMs, +}: PerformanceSnapshotInput): PerformanceSnapshot { + const effectiveNow = Number.isFinite(completedMs) ? (completedMs as number) : nowMs; + const elapsedMs = + Number.isFinite(baselineMs) && Number.isFinite(effectiveNow) + ? Math.max(0, (effectiveNow as number) - (baselineMs as number)) + : 0; + const firstCommentElapsedMs = + Number.isFinite(firstCommentMs) && Number.isFinite(baselineMs) + ? Math.max(0, (firstCommentMs as number) - (baselineMs as number)) + : null; + const normalizedComments = Number.isFinite(totalComments) ? Math.max(0, totalComments as number) : 0; + const firstCommentValue = + firstCommentElapsedMs === null + ? Number.isFinite(completedMs) && normalizedComments === 0 + ? 'No comments' + : 'Waiting' + : formatDurationShort(firstCommentElapsedMs); + + return { + elapsedMs, + elapsedLabel: formatDurationShort(elapsedMs), + firstCommentElapsedMs, + firstCommentLabel: firstCommentValue, + totalComments: normalizedComments, + summaryItems: [ + { key: 'first-comment', label: 'First comment', value: firstCommentValue }, + { key: 'elapsed', label: Number.isFinite(completedMs) ? 'Final time' : 'Elapsed', value: formatDurationShort(elapsedMs) }, + { key: 'comments', label: 'Comments', value: String(normalizedComments) }, + ], + }; +} + +export function getCommentRenderLabel(baselineMs: number | undefined, renderedAtMs: number | undefined): string { + if (!Number.isFinite(baselineMs) || !Number.isFinite(renderedAtMs)) { + return ''; + } + return `Appeared in ${formatDurationShort(Math.max(0, (renderedAtMs as number) - (baselineMs as number)))}`; +} + +export function getLoadingActivityMessage(events: { message?: string }[] | undefined, elapsedMs: number): string { + const latestMessage = [...(events || [])] + .reverse() + .map((event) => String(event?.message || '').trim()) + .find(Boolean); + + if (latestMessage) { + return latestMessage; + } + + const safeElapsedMs = Number.isFinite(elapsedMs) ? Math.max(0, elapsedMs) : 0; + const index = Math.floor(safeElapsedMs / 3000) % FALLBACK_ACTIVITY_LINES.length; + return FALLBACK_ACTIVITY_LINES[index]; +} diff --git a/ui/src/components/reviews/diffviewer/reviewStreamState.test.ts b/ui/src/components/reviews/diffviewer/reviewStreamState.test.ts new file mode 100644 index 000000000..654df2377 --- /dev/null +++ b/ui/src/components/reviews/diffviewer/reviewStreamState.test.ts @@ -0,0 +1,303 @@ +import fc from 'fast-check'; +import { + appendStreamedCommentsToFiles, + extractExternalCommentsFromEvents, + extractNewEvents, + inferReviewStatusFromEvents, + normalizeStreamedComment, + shouldClearStreamedComments, + sinceWithOverlap, +} from './reviewStreamState'; +import { DiffReviewComment, DiffReviewFile, ReviewEvent } from '../../../types/reviews'; + +function batchEvent(id: number, comments: unknown[], overrides: Partial = {}): ReviewEvent { + return { + id, + reviewId: 1, + orgId: 1, + time: `2026-01-01T00:00:0${id}Z`, + type: 'batch', + batchId: `batch-${id}`, + data: { status: 'completed', comments } as any, + ...overrides, + }; +} + +describe('extractNewEvents', () => { + it('dedupes against already-seen event ids and tracks the latest time', () => { + const events = [batchEvent(1, []), batchEvent(2, [])]; + const first = extractNewEvents(events, undefined); + expect(first.newEvents).toHaveLength(2); + expect(first.lastSeenAt).toBe('2026-01-01T00:00:02Z'); + + const second = extractNewEvents([...events, batchEvent(3, [])], first.nextSeenEventIds); + expect(second.newEvents.map((e) => e.id)).toEqual([3]); + }); +}); + +describe('inferReviewStatusFromEvents', () => { + it('reads the most recent status event', () => { + const events: ReviewEvent[] = [ + { id: 1, reviewId: 1, orgId: 1, time: 't1', type: 'status', data: { status: 'in_progress' } }, + { id: 2, reviewId: 1, orgId: 1, time: 't2', type: 'status', data: { status: 'completed' } }, + ]; + expect(inferReviewStatusFromEvents(events)).toBe('completed'); + }); + + it('treats a completion event with an errorSummary as failed', () => { + const events: ReviewEvent[] = [ + { id: 1, reviewId: 1, orgId: 1, time: 't1', type: 'completion', data: { errorSummary: 'boom' } }, + ]; + expect(inferReviewStatusFromEvents(events)).toBe('failed'); + }); + + it('returns empty string when no status/completion event exists', () => { + expect(inferReviewStatusFromEvents([])).toBe(''); + }); +}); + +describe('normalizeStreamedComment', () => { + it('normalizes a PascalCase comment off the wire and drops internal-only comments', () => { + const normal = normalizeStreamedComment({ + FilePath: 'a.go', + Line: 10, + Content: 'looks off', + Severity: 'CRITICAL', + Category: 'bug', + }); + expect(normal).toEqual({ + file_path: 'a.go', + line: 10, + content: 'looks off', + severity: 'critical', + confidence: '', + type: '', + category: 'bug', + subcategory: '', + }); + + expect(normalizeStreamedComment({ FilePath: 'a.go', Line: 1, Content: 'x', IsInternal: true })).toBeNull(); + expect(normalizeStreamedComment({ FilePath: '', Line: 1, Content: 'x' })).toBeNull(); + expect(normalizeStreamedComment(null)).toBeNull(); + }); +}); + +describe('extractExternalCommentsFromEvents', () => { + it('only reads comments off completed batch events, ignoring other event types/statuses', () => { + const events: ReviewEvent[] = [ + batchEvent(1, [{ FilePath: 'a.go', Line: 1, Content: 'c1' }]), + { id: 2, reviewId: 1, orgId: 1, time: 't2', type: 'batch', data: { status: 'processing' } as any }, + { id: 3, reviewId: 1, orgId: 1, time: 't3', type: 'log', data: { message: 'hi' } }, + ]; + const comments = extractExternalCommentsFromEvents(events); + expect(comments).toEqual([{ file_path: 'a.go', line: 1, content: 'c1', severity: 'info', confidence: '', type: '', category: 'review', subcategory: '' }]); + }); +}); + +describe('appendStreamedCommentsToFiles', () => { + const baseFiles: DiffReviewFile[] = [ + { file_path: 'a.go', hunks: [], comments: [] }, + { file_path: 'b.go', hunks: [], comments: [{ line: 1, content: 'existing' }] }, + ]; + + it('merges incoming comments into the matching file by path, leaving others untouched', () => { + const result = appendStreamedCommentsToFiles(baseFiles, [ + { file_path: 'a.go', line: 5, content: 'new finding' }, + ]); + expect(result[0].comments).toEqual([{ file_path: 'a.go', line: 5, content: 'new finding' }]); + expect(result[1].comments).toEqual([{ line: 1, content: 'existing' }]); + // original untouched + expect(baseFiles[0].comments).toEqual([]); + }); + + it('drops a comment whose file_path matches nothing in the file list', () => { + const result = appendStreamedCommentsToFiles(baseFiles, [ + { file_path: 'missing.go', line: 1, content: 'orphan' }, + ]); + expect(result[0].comments).toEqual([]); + expect(result[1].comments).toEqual([{ line: 1, content: 'existing' }]); + }); + + it('returns the input files unchanged when there is nothing to merge', () => { + expect(appendStreamedCommentsToFiles(baseFiles, [])).toBe(baseFiles); + expect(appendStreamedCommentsToFiles([], [{ file_path: 'a.go', line: 1, content: 'x' }])).toEqual([]); + }); +}); + +describe('sinceWithOverlap', () => { + it('subtracts the overlap window from the last-seen timestamp', () => { + const result = sinceWithOverlap('2026-01-01T00:00:02.000Z'); + expect(result).toBe('2026-01-01T00:00:01.000Z'); + }); + + it('returns undefined for null/invalid input', () => { + expect(sinceWithOverlap(null)).toBeUndefined(); + expect(sinceWithOverlap('not-a-date')).toBeUndefined(); + }); +}); + +// Regression coverage for the "findings disappear mid-review" bug: a single +// failed batch wipes review_result entirely (internal/ai/gemini/batch.go / +// internal/jobqueue/review_worker.go), so the final getDiffReview fetch on a +// 'failed' review can legitimately have fewer comments than already +// streamed. See DiffViewerPanel.tsx's polling effect and this function's own +// doc comment for the full root-cause writeup. +describe('shouldClearStreamedComments', () => { + it('clears on completed - the final result is authoritative (dedup/cap already ran correctly)', () => { + expect(shouldClearStreamedComments('completed')).toBe(true); + }); + + it('does NOT clear on failed - a single bad batch can wipe review_result despite other batches having already streamed real comments', () => { + expect(shouldClearStreamedComments('failed')).toBe(false); + }); +}); + +describe('appendStreamedCommentsToFiles - adversarial/edge cases', () => { + it('with empty streamedComments and a failed review, produces no comments (not a false positive)', () => { + const files: DiffReviewFile[] = [{ file_path: 'a.go', hunks: [], comments: [] }]; + expect(appendStreamedCommentsToFiles(files, [])).toEqual(files); + }); + + it('re-attaches streamed comments onto the failed-path shape (buildDiffFiles(preloaded, nil): files present, every comments[] empty)', () => { + // internal/api/diff_review.go:197-223 - any non-'completed' status, including + // 'failed', returns this exact shape: the correct file/hunk structure with + // zero comments per file (nil was passed as the comments arg). + const failedPathFiles: DiffReviewFile[] = [ + { file_path: 'a.go', hunks: [{} as any], comments: [] }, + { file_path: 'b.go', hunks: [{} as any], comments: [] }, + ]; + const streamed = [ + { file_path: 'a.go', line: 3, content: 'already streamed before the batch that failed' }, + { file_path: 'b.go', line: 9, content: 'also streamed, different file' }, + ]; + const result = appendStreamedCommentsToFiles(failedPathFiles, streamed); + expect(result[0].comments).toEqual([streamed[0]]); + expect(result[1].comments).toEqual([streamed[1]]); + }); + + it('does not double-append the same streamed comment across overlapping polls (dedup happens upstream in extractNewEvents, this just verifies append is additive, not idempotent on its own, so callers must only pass genuinely-new comments)', () => { + const files: DiffReviewFile[] = [{ file_path: 'a.go', hunks: [], comments: [] }]; + const comment = { file_path: 'a.go', line: 1, content: 'dup' }; + // Simulates what WOULD happen if a caller accidentally passed the same + // comment twice - appendStreamedCommentsToFiles itself has no dedup, so + // the real protection is extractNewEvents' seenEventIds set (tested + // below). This test documents that boundary explicitly. + const result = appendStreamedCommentsToFiles(files, [comment, comment]); + expect(result[0].comments).toHaveLength(2); + }); +}); + +describe('extractNewEvents - adversarial/edge cases', () => { + it('dedupes the same event id seen across overlapping poll pages (the real since-overlap protection)', () => { + const events = [batchEvent(1, [{ FilePath: 'a.go', Line: 1, Content: 'c1' }])]; + const first = extractNewEvents(events, undefined); + // Second poll's overlap window re-returns the same event (plus a genuinely new one). + const second = extractNewEvents([events[0], batchEvent(2, [])], first.nextSeenEventIds); + expect(second.newEvents.map((e) => e.id)).toEqual([2]); + }); + + it('still dedupes correctly when an event has no id (falls back to a composite type/time/batchId/data key)', () => { + const malformed: ReviewEvent = { + reviewId: 1, orgId: 1, time: 't1', type: 'batch', batchId: 'b1', + data: { status: 'completed', comments: [{ FilePath: 'a.go', Line: 1, Content: 'x' }] } as any, + } as any; // no `id` field at all + const first = extractNewEvents([malformed], undefined); + expect(first.newEvents).toHaveLength(1); + // Same malformed event re-appears (e.g. re-fetched in the next page) - same + // composite key, so it must be recognized as already-seen, not duplicated. + const second = extractNewEvents([malformed], first.nextSeenEventIds); + expect(second.newEvents).toHaveLength(0); + }); + + it('treats a completion event immediately followed by a straggler batch event in the same page as two distinct new events (both surfaced, nothing silently dropped)', () => { + const events: ReviewEvent[] = [ + { id: 1, reviewId: 1, orgId: 1, time: 't1', type: 'completion', data: { errorSummary: 'boom' } }, + batchEvent(2, [{ FilePath: 'a.go', Line: 1, Content: 'late straggler' }]), + ]; + const { newEvents } = extractNewEvents(events, undefined); + expect(newEvents).toHaveLength(2); + // Both the failure and the straggler's comment are extractable from this page - + // DiffViewerPanel's poll handler processes extractExternalCommentsFromEvents + // and inferReviewStatusFromEvents from the SAME newEvents array, so the + // straggler's comment is captured before/alongside the terminal-status handling, + // not lost to it. + expect(extractExternalCommentsFromEvents(newEvents)).toHaveLength(1); + expect(inferReviewStatusFromEvents(newEvents)).toBe('failed'); + }); +}); + +// Property-based test: generates hundreds of randomized streaming/completion +// scenarios and asserts the core invariant that fixes the disappearing- +// comments bug - a 'failed' review's rendered comments are never fewer than +// what had already streamed, by (file_path, line, content) identity. +describe('property: findings never shrink on a failed review (fast-check)', () => { + const commentArb = fc.record({ + file_path: fc.constantFrom('a.go', 'b.go', 'c.go'), + line: fc.integer({ min: 1, max: 50 }), + content: fc.string({ minLength: 1, maxLength: 20 }), + }) as fc.Arbitrary; + + function identity(c: DiffReviewComment & { file_path: string }): string { + return `${c.file_path}|${c.line}|${c.content}`; + } + + it('never loses a streamed comment once the review ends in "failed"', () => { + fc.assert( + fc.property( + fc.array(commentArb, { minLength: 0, maxLength: 12 }), // everything that streamed in live + fc.array(commentArb, { minLength: 0, maxLength: 12 }), // the final fetch's comment set (arbitrary - may be a subset, superset, or disjoint) + (streamed, finalComments) => { + const files: DiffReviewFile[] = [ + { file_path: 'a.go', hunks: [], comments: [] }, + { file_path: 'b.go', hunks: [], comments: [] }, + { file_path: 'c.go', hunks: [], comments: [] }, + ]; + const byFile = new Map(files.map((f) => [f.file_path, f])); + finalComments.forEach((c) => byFile.get(c.file_path)?.comments.push(c)); + + const shouldClear = shouldClearStreamedComments('failed'); + const effectiveStreamed = shouldClear ? [] : streamed; + const rendered = appendStreamedCommentsToFiles(files, effectiveStreamed); + const renderedIdentities = new Set(rendered.flatMap((f) => f.comments.map((c) => identity(c as any)))); + + for (const s of streamed) { + if (byFile.has(s.file_path)) { + // every streamed comment (for a file that exists in the final + // response) must still be visible after reconciliation + expect(renderedIdentities.has(identity(s))).toBe(true); + } + } + } + ), + { numRuns: 250 } + ); + }); + + it('on "completed", trusts the final set as-is (no streamed-only comments survive, no duplicates invented)', () => { + fc.assert( + fc.property( + fc.array(commentArb, { minLength: 0, maxLength: 12 }), + fc.array(commentArb, { minLength: 0, maxLength: 12 }), + (streamed, finalComments) => { + const files: DiffReviewFile[] = [ + { file_path: 'a.go', hunks: [], comments: [] }, + { file_path: 'b.go', hunks: [], comments: [] }, + { file_path: 'c.go', hunks: [], comments: [] }, + ]; + const byFile = new Map(files.map((f) => [f.file_path, f])); + finalComments.forEach((c) => byFile.get(c.file_path)?.comments.push(c)); + + const shouldClear = shouldClearStreamedComments('completed'); + expect(shouldClear).toBe(true); + const effectiveStreamed = shouldClear ? [] : streamed; + const rendered = appendStreamedCommentsToFiles(files, effectiveStreamed); + const renderedIdentities = rendered.flatMap((f) => f.comments.map((c) => identity(c as any))); + + const finalIdentities = finalComments.map(identity); + expect(renderedIdentities.sort()).toEqual(finalIdentities.sort()); + } + ), + { numRuns: 250 } + ); + }); +}); diff --git a/ui/src/components/reviews/diffviewer/reviewStreamState.ts b/ui/src/components/reviews/diffviewer/reviewStreamState.ts new file mode 100644 index 000000000..af46df34b --- /dev/null +++ b/ui/src/components/reviews/diffviewer/reviewStreamState.ts @@ -0,0 +1,250 @@ +// Ported from git-lrc:internal/staticserve/static/components/review_stream_state.mjs#L1-L175 +// (as of the git-lrc HEAD current when this port was written). +// +// Lets the Findings tab show results incrementally while a review is still +// in_progress: every time a batch of files finishes its LLM call, the +// backend emits a "batch"/"completed" event carrying that batch's raw +// comments (internal/ai/langchain/provider.go's EmitBatchComplete). This +// module extracts those comments from a page of GET .../events results and +// merges them into the file/hunk structure GetDiffReviewStatus returns, the +// same way git-lrc's local review UI has always done it. +import { DiffReviewComment, DiffReviewCommentSeverity, DiffReviewFile, ReviewEvent } from '../../../types/reviews'; + +const EVENT_POLL_LIMIT = 1000; +const EVENT_TIME_OVERLAP_MS = 1000; + +function stableStringify(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + if (typeof value === 'object') { + const obj = value as Record; + return `{${Object.keys(obj).sort().map((key) => `${key}:${stableStringify(obj[key])}`).join(',')}}`; + } + return String(value); +} + +function normalizeEventId(event: ReviewEvent | undefined, index: number): string { + if (event?.id !== undefined && event?.id !== null) { + return String(event.id); + } + return [ + event?.type || 'event', + event?.time || 'unknown', + event?.batchId || '', + stableStringify(event?.data), + index, + ].join(':'); +} + +function normalizeSeverity(rawSeverity: unknown): DiffReviewCommentSeverity { + const severity = String(rawSeverity || 'info').trim().toLowerCase(); + if (severity === 'critical' || severity === 'warning') { + return severity as DiffReviewCommentSeverity; + } + return 'info'; +} + +function normalizeFilePath(file: Pick | undefined): string { + return file?.file_path || ''; +} + +// Builds the `since` param GetReviewEvents polling should pass for the next +// page, with the same small overlap window git-lrc's buildEventsURL applies +// (events?since=...), to avoid missing an event that landed exactly at the +// previous poll's cutoff. Call signature differs from the original .mjs +// (which returned a full URL) because LiveReview's getReviewEvents api +// client already builds the URL from (reviewId, since, limit) — this just +// supplies the `since` value. +export function sinceWithOverlap(lastSeenAt: string | null): string | undefined { + if (!lastSeenAt) { + return undefined; + } + const parsed = new Date(lastSeenAt); + if (Number.isNaN(parsed.getTime())) { + return undefined; + } + return new Date(parsed.getTime() - EVENT_TIME_OVERLAP_MS).toISOString(); +} + +export const EVENTS_POLL_LIMIT = EVENT_POLL_LIMIT; + +export interface ExtractNewEventsResult { + newEvents: ReviewEvent[]; + nextSeenEventIds: Set; + lastSeenAt: string | null; +} + +export function extractNewEvents(events: ReviewEvent[] | undefined, seenEventIds: Set | undefined): ExtractNewEventsResult { + const nextSeenEventIds = new Set(seenEventIds || []); + const newEvents: ReviewEvent[] = []; + let lastSeenAt: string | null = null; + + (events || []).forEach((event, index) => { + const eventId = normalizeEventId(event, index); + const eventTime = event?.time || null; + + if (eventTime) { + if (!lastSeenAt || new Date(eventTime).getTime() > new Date(lastSeenAt).getTime()) { + lastSeenAt = eventTime; + } + } + + if (nextSeenEventIds.has(eventId)) { + return; + } + + nextSeenEventIds.add(eventId); + newEvents.push(event); + }); + + return { newEvents, nextSeenEventIds, lastSeenAt }; +} + +export function inferReviewStatusFromEvents(events: ReviewEvent[] | undefined): string { + const list = events || []; + for (let index = list.length - 1; index >= 0; index--) { + const event = list[index] || ({} as ReviewEvent); + const eventData = event.data || {}; + if (event.type === 'status' && eventData.status) { + return eventData.status; + } + if (event.type === 'completion') { + return eventData.errorSummary ? 'failed' : 'completed'; + } + } + return ''; +} + +// Raw shape of a comment riding on a "batch" event's data.comments[] — +// marshaled straight from pkg/models.ReviewComment, which has no json tags, +// so every field comes over PascalCase (unlike DiffReviewComment's lower +// camelCase, which is GetDiffReviewStatus's own, separately-marshaled shape). +interface RawStreamedComment { + FilePath?: string; + filePath?: string; + file_path?: string; + Line?: number; + line?: number; + lineNumber?: number; + line_number?: number; + Content?: string; + content?: string; + Severity?: string; + severity?: string; + Confidence?: string; + confidence?: string; + Type?: string; + type?: string; + Category?: string; + category?: string; + Subcategory?: string; + subcategory?: string; + IsInternal?: boolean; + isInternal?: boolean; +} + +export function normalizeStreamedComment(rawComment: RawStreamedComment | null | undefined): DiffReviewComment & { file_path: string } | null { + if (!rawComment) { + return null; + } + + const isInternal = Boolean(rawComment.IsInternal ?? rawComment.isInternal); + if (isInternal) { + return null; + } + + const filePath = rawComment.FilePath || rawComment.filePath || rawComment.file_path || ''; + const line = Number(rawComment.Line ?? rawComment.line ?? rawComment.lineNumber ?? rawComment.line_number); + const content = String(rawComment.Content ?? rawComment.content ?? '').trim(); + + if (!filePath || !Number.isFinite(line) || line <= 0 || !content) { + return null; + } + + return { + file_path: filePath, + line, + content, + severity: normalizeSeverity(rawComment.Severity ?? rawComment.severity), + confidence: String(rawComment.Confidence ?? rawComment.confidence ?? '').trim(), + type: String(rawComment.Type ?? rawComment.type ?? '').trim(), + category: String(rawComment.Category ?? rawComment.category ?? 'review').trim() || 'review', + subcategory: String(rawComment.Subcategory ?? rawComment.subcategory ?? '').trim(), + }; +} + +export function extractExternalCommentsFromEvents(events: ReviewEvent[] | undefined): (DiffReviewComment & { file_path: string })[] { + const comments: (DiffReviewComment & { file_path: string })[] = []; + + (events || []).forEach((event) => { + const eventData = event?.data || {}; + const rawComments = (eventData as { comments?: unknown }).comments; + if (event?.type !== 'batch' || eventData.status !== 'completed' || !Array.isArray(rawComments)) { + return; + } + rawComments.forEach((comment) => { + const normalized = normalizeStreamedComment(comment as RawStreamedComment); + if (normalized) { + comments.push(normalized); + } + }); + }); + + return comments; +} + +// Whether the live-streamed comments should be discarded once the review +// reaches a terminal status and the final getDiffReview fetch lands. +// +// 'completed': the final result is authoritative - AggregateAndCombineOutputs +// (internal/batch/batch.go) already deduped exact-duplicate comments across +// batches and capped the external count as an anti-flood safety net, so the +// final set is correct and should fully replace the raw stream. +// +// 'failed': NOT authoritative in the same way. If even one batch errored, +// internal/ai/gemini/batch.go's result loop aborts the whole review with no +// partial-success handling, so review_result ends up with zero comments even +// though other batches already succeeded and their comments already streamed +// live (internal/jobqueue/review_worker.go: `status := "failed"` is the +// default, and `comments = result.Comments` only runs `if result.Success`). +// Clearing here would silently discard real findings that already rendered - +// this is the fix for the "findings load then disappear" bug. +export function shouldClearStreamedComments(status: 'completed' | 'failed'): boolean { + return status === 'completed'; +} + +export function appendStreamedCommentsToFiles( + files: DiffReviewFile[] | undefined, + incomingComments: (DiffReviewComment & { file_path: string })[] | undefined +): DiffReviewFile[] { + if (!Array.isArray(files) || files.length === 0 || !Array.isArray(incomingComments) || incomingComments.length === 0) { + return files || []; + } + + const nextFiles = files.map((file) => ({ + ...file, + comments: [...(file.comments || [])], + })); + + const fileIndexByPath = new Map(); + nextFiles.forEach((file, index) => { + const filePath = normalizeFilePath(file); + if (filePath) { + fileIndexByPath.set(filePath, index); + } + }); + + incomingComments.forEach((comment) => { + const fileIndex = fileIndexByPath.get(comment.file_path); + if (fileIndex === undefined) { + return; + } + nextFiles[fileIndex].comments.push(comment); + }); + + return nextFiles; +} diff --git a/ui/src/components/reviews/index.ts b/ui/src/components/reviews/index.ts index ac3290bd2..c1b5d066c 100644 --- a/ui/src/components/reviews/index.ts +++ b/ui/src/components/reviews/index.ts @@ -7,4 +7,5 @@ export { default as BatchSummary } from './BatchSummary'; export { default as TimingChart } from './TimingChart'; export { default as ProgressIndicators } from './ProgressIndicators'; export { default as EventFilters } from './EventFilters'; -export { default as DiffViewerPanel } from './diffviewer/DiffViewerPanel'; \ No newline at end of file +export { default as DiffViewerPanel } from './diffviewer/DiffViewerPanel'; +export { default as PushNotificationBanner } from './PushNotificationBanner'; \ No newline at end of file diff --git a/ui/src/lib/blastRadius.test.ts b/ui/src/lib/blastRadius.test.ts index 41b668935..e1f837a60 100644 --- a/ui/src/lib/blastRadius.test.ts +++ b/ui/src/lib/blastRadius.test.ts @@ -1,12 +1,14 @@ // Tests for the severity-aware blending added to the blast-radius sort // pipeline (mirrors git-lrc's blast_radius_sort_state.test.mjs cases). +import fc from 'fast-check'; import { attachBlastData, blendRiskScore, buildBlastLookup, + flattenFilesByRisk, hunkSeverityInfo, } from './blastRadius'; -import { BlastRadiusHunkReport, DiffReviewFile, DiffReviewHunk } from '../types/reviews'; +import { DiffReviewComment, BlastRadiusHunkReport, DiffReviewFile, DiffReviewHunk } from '../types/reviews'; function makeReportHunk(overrides: Partial = {}): BlastRadiusHunkReport { return { @@ -99,3 +101,93 @@ describe('attachBlastData', () => { expect(jh.BlastDetail?.Combined).toBe(24.57); }); }); + +// Regression coverage for the "comment appears then disappears" bug once +// blast-radius data arrives mid-review and the view switches to the default +// risk-flat sort: flattenFilesByRisk must never drop a comment just because +// its line doesn't fall inside the exact hunk range it gets matched to. +describe('flattenFilesByRisk - comment conservation', () => { + const hunkA: DiffReviewHunk = { old_start_line: 0, old_line_count: 0, new_start_line: 5, new_line_count: 3, content: '' }; + const hunkB: DiffReviewHunk = { old_start_line: 0, old_line_count: 0, new_start_line: 20, new_line_count: 3, content: '' }; + + it('keeps a comment whose line falls inside a hunk range exactly', () => { + const files: DiffReviewFile[] = [ + { file_path: 'a.go', hunks: [hunkA], comments: [{ line: 5, content: 'in range' }] }, + ]; + const flat = flattenFilesByRisk(files); + expect(flat.flatMap((f) => f.comments)).toEqual([{ line: 5, content: 'in range' }]); + }); + + it('does not drop a comment sitting in the gap between two hunks in the same file', () => { + const files: DiffReviewFile[] = [ + { + file_path: 'a.go', + hunks: [hunkA, hunkB], + // line 12 is in the gap between hunkA's [5,8) and hunkB's [20,23) - + // this is exactly the shape that silently vanished before the fix. + comments: [{ line: 12, content: 'gap comment' }], + }, + ]; + const flat = flattenFilesByRisk(files); + expect(flat.flatMap((f) => f.comments)).toEqual([{ line: 12, content: 'gap comment' }]); + }); + + it('does not drop a comment whose line is before the first hunk or after the last', () => { + const files: DiffReviewFile[] = [ + { + file_path: 'a.go', + hunks: [hunkA, hunkB], + comments: [ + { line: 1, content: 'before first hunk' }, + { line: 99, content: 'after last hunk' }, + ], + }, + ]; + const flat = flattenFilesByRisk(files); + const contents = flat.flatMap((f) => f.comments).map((c) => c.content); + expect(contents.sort()).toEqual(['after last hunk', 'before first hunk']); + }); + + it('keeps unclaimed comments attached to the correct file when multiple files are flattened together', () => { + const files: DiffReviewFile[] = [ + { file_path: 'a.go', hunks: [hunkA], comments: [{ line: 999, content: 'orphan in a.go' }] }, + { file_path: 'b.go', hunks: [hunkB], comments: [{ line: 5, content: 'orphan in b.go' }] }, + ]; + const flat = flattenFilesByRisk(files); + const byFile = new Map(); + flat.forEach((f) => byFile.set(f.file_path, [...(byFile.get(f.file_path) || []), ...f.comments.map((c) => c.content)])); + expect(byFile.get('a.go')).toEqual(['orphan in a.go']); + expect(byFile.get('b.go')).toEqual(['orphan in b.go']); + }); + + it('property: total comment count is conserved across any hunk/comment-line combination', () => { + const hunkArb = fc.record({ + old_start_line: fc.constant(0), + old_line_count: fc.constant(0), + new_start_line: fc.integer({ min: 1, max: 200 }), + new_line_count: fc.integer({ min: 1, max: 20 }), + content: fc.constant(''), + }); + const fileArb = fc + .record({ + file_path: fc.constantFrom('a.go', 'b.go'), + hunks: fc.array(hunkArb, { minLength: 0, maxLength: 5 }), + lines: fc.array(fc.integer({ min: 0, max: 220 }), { minLength: 0, maxLength: 10 }), + }) + .map(({ file_path, hunks, lines }): DiffReviewFile => ({ + file_path, + hunks, + comments: lines.map((line, i): DiffReviewComment => ({ line, content: `c${i}` })), + })); + + fc.assert( + fc.property(fc.array(fileArb, { minLength: 1, maxLength: 3 }), (files) => { + const totalBefore = files.reduce((sum, f) => sum + (f.comments || []).length, 0); + const flat = flattenFilesByRisk(files); + const totalAfter = flat.reduce((sum, f) => sum + (f.comments || []).length, 0); + expect(totalAfter).toBe(totalBefore); + }), + { numRuns: 300 } + ); + }); +}); diff --git a/ui/src/lib/blastRadius.ts b/ui/src/lib/blastRadius.ts index 4dba1bffb..3b38df9c2 100644 --- a/ui/src/lib/blastRadius.ts +++ b/ui/src/lib/blastRadius.ts @@ -10,6 +10,7 @@ import { BlastRadiusHunkReport, BlastRadiusReport, BlastRadiusSignal, + DiffReviewComment, DiffReviewFile, DiffReviewHunk, } from '../types/reviews'; @@ -217,8 +218,46 @@ export function sortFilesByBlastRadius(files: DiffReviewFile[]): DiffReviewFile[ export function flattenFilesByRisk(files: DiffReviewFile[]): DiffReviewFile[] { interface Entry { file: DiffReviewFile; fileIdx: number; hunk: DiffReviewHunk; hunkIdx: number; score: number | null } const entries: Entry[] = []; + // Comments are matched against a hunk's new-side line range (backend's + // lineWithinHunks — see diffUtils.ts's commentBelongsToLine doc comment). + // Partitioned per-file, in original hunk order, BEFORE the global risk + // sort below, so every comment ends up in exactly one hunk's bucket. + // A comment whose line doesn't fall inside any hunk's declared range + // (a hunk's metadata not perfectly bounding every line the model + // annotated, or a comment sitting in the gap between two hunks) is NOT + // dropped — it's queued onto the file's last hunk instead, so the total + // comment count across every synthetic entry always equals the input's + // total, regardless of how the per-hunk filter matches. + const perFileHunkComments = new Map(); files.forEach((file, fileIdx) => { - (file.hunks || []).forEach((hunk, hunkIdx) => { + // A hunkless file with comments has nothing to flatten into - give it one + // synthetic zero-width placeholder hunk so there's still an entry below + // to carry its comments, instead of silently losing them. + const hasRealHunks = (file.hunks || []).length > 0; + const needsPlaceholder = !hasRealHunks && (file.comments || []).length > 0; + const hunks = hasRealHunks + ? (file.hunks as DiffReviewHunk[]) + : needsPlaceholder + ? [{ old_start_line: 0, old_line_count: 0, new_start_line: 0, new_line_count: 0, content: '' }] + : []; + const buckets: DiffReviewComment[][] = hunks.map((): DiffReviewComment[] => []); + const unclaimed: DiffReviewComment[] = []; + (file.comments || []).forEach((comment) => { + const hunkIdx = hasRealHunks + ? hunks.findIndex((hunk) => comment.line >= hunk.new_start_line && comment.line < hunk.new_start_line + hunk.new_line_count) + : -1; + if (hunkIdx >= 0) { + buckets[hunkIdx].push(comment); + } else { + unclaimed.push(comment); + } + }); + if (unclaimed.length > 0) { + buckets[buckets.length - 1].push(...unclaimed); + } + perFileHunkComments.set(fileIdx, buckets); + + hunks.forEach((hunk, hunkIdx) => { entries.push({ file, fileIdx, hunk, hunkIdx, score: normalizedScore(hunk) }); }); }); @@ -232,15 +271,7 @@ export function flattenFilesByRisk(files: DiffReviewFile[]): DiffReviewFile[] { return entries.map(({ file, fileIdx, hunk, hunkIdx }) => ({ ...file, hunks: [hunk], - // Comments are matched against a hunk's new-side line range (backend's - // lineWithinHunks — see diffUtils.ts's commentBelongsToLine doc - // comment). Without this filter every synthetic entry inherited the - // *whole file's* comment list instead of just this hunk's, inflating - // both the per-entry comment badge and the sidebar's total file/comment - // counts by roughly the file's hunk count. - comments: (file.comments || []).filter( - (c) => c.line >= hunk.new_start_line && c.line < hunk.new_start_line + hunk.new_line_count - ), + comments: perFileHunkComments.get(fileIdx)?.[hunkIdx] || [], syntheticId: `${file.file_path}--hunk-${fileIdx}-${hunkIdx}`, sourceHunkNumber: hunkIdx + 1, })); diff --git a/ui/src/pages/AIProviders/AIProviders.tsx b/ui/src/pages/AIProviders/AIProviders.tsx index d039b4a91..23243859f 100644 --- a/ui/src/pages/AIProviders/AIProviders.tsx +++ b/ui/src/pages/AIProviders/AIProviders.tsx @@ -216,7 +216,9 @@ const AIProviders: React.FC = () => { const [activeRole, setActiveRole] = useState<'leader' | 'helper'>('leader'); const [helperSettings, setHelperSettings] = useState({ helper_enabled: true, - helper_mode: 'concise_then_expand' + helper_mode: 'concise_then_expand', + preferred_max_concurrent_batches: null, + preferred_review_timeout_seconds: null }); const [helperSettingsSaved, setHelperSettingsSaved] = useState(false); const dropdownRef = useRef(null); @@ -265,7 +267,11 @@ const AIProviders: React.FC = () => { const settings = await getReviewAISettings(); setHelperSettings({ helper_enabled: !!settings.helper_enabled, - helper_mode: (settings.helper_mode as 'concise_then_expand' | 'polish_only') || 'concise_then_expand' + helper_mode: (settings.helper_mode as 'concise_then_expand' | 'polish_only') || 'concise_then_expand', + preferred_max_concurrent_batches: settings.preferred_max_concurrent_batches ?? null, + preferred_review_timeout_seconds: settings.preferred_review_timeout_seconds ?? null, + effective_max_concurrent_batches: settings.effective_max_concurrent_batches, + effective_review_timeout_seconds: settings.effective_review_timeout_seconds, }); } catch (settingsError) { console.error('Failed to load review AI settings:', settingsError); @@ -486,10 +492,19 @@ const AIProviders: React.FC = () => { const handleSaveHelperSettings = async () => { try { - const updated = await updateReviewAISettings(helperSettings.helper_enabled, helperSettings.helper_mode); + const updated = await updateReviewAISettings( + helperSettings.helper_enabled, + helperSettings.helper_mode, + helperSettings.preferred_max_concurrent_batches ?? null, + helperSettings.preferred_review_timeout_seconds ?? null + ); setHelperSettings({ helper_enabled: !!updated.helper_enabled, - helper_mode: (updated.helper_mode as 'concise_then_expand' | 'polish_only') || 'concise_then_expand' + helper_mode: (updated.helper_mode as 'concise_then_expand' | 'polish_only') || 'concise_then_expand', + preferred_max_concurrent_batches: updated.preferred_max_concurrent_batches ?? null, + preferred_review_timeout_seconds: updated.preferred_review_timeout_seconds ?? null, + effective_max_concurrent_batches: updated.effective_max_concurrent_batches, + effective_review_timeout_seconds: updated.effective_review_timeout_seconds, }); setHelperSettingsSaved(true); setTimeout(() => setHelperSettingsSaved(false), 3000); @@ -614,6 +629,57 @@ const AIProviders: React.FC = () => { )} + +
+

+ Optional. Leave blank to use the instance default. Your value is always clamped to what the instance allows - you can turn these down, never above the instance ceiling. +

+
+
+ + setHelperSettings((prev) => ({ + ...prev, + preferred_max_concurrent_batches: e.target.value === '' ? null : (parseInt(e.target.value, 10) || null) + }))} + className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white" + /> +
+
+ + setHelperSettings((prev) => ({ + ...prev, + preferred_review_timeout_seconds: e.target.value === '' ? null : (parseInt(e.target.value, 10) || null) + }))} + className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white" + /> +
+
+
+ + {helperSettingsSaved && Saved} +
+
+
+ {/* Connector Form - Only show when actively adding/editing */} {(formData.name || formData.apiKey || isEditing) && ( <> diff --git a/ui/src/pages/AIProviders/types/index.ts b/ui/src/pages/AIProviders/types/index.ts index d1b49337e..9c5736a3a 100644 --- a/ui/src/pages/AIProviders/types/index.ts +++ b/ui/src/pages/AIProviders/types/index.ts @@ -62,6 +62,24 @@ export interface ConnectorFormData { export interface ReviewAISettings { helper_enabled: boolean; helper_mode: 'concise_then_expand' | 'polish_only'; + // Optional soft preferences - null means "use the system default". + // Always clamped server-side to the instance-wide ceiling: an org can + // turn these down, never up past what the instance allows. + preferred_max_concurrent_batches?: number | null; + preferred_review_timeout_seconds?: number | null; + // What will actually be used right now (preference resolved against + // the current instance-wide ceiling), returned by GET/PUT so the UI + // can show "you asked for X, the instance allows up to Y". + effective_max_concurrent_batches?: number; + effective_review_timeout_seconds?: number; +} + +// Instance-wide review concurrency/timeout ceiling (super-admin / self-hosted +// owner only). Bounds every org's own preference above. +export interface AIReviewPerformanceSettings { + max_concurrent_batches_ceiling: number; + min_review_timeout_floor_seconds: number; + max_review_timeout_ceiling_seconds: number; } export interface ValidationResult { diff --git a/ui/src/pages/Explore/ExploreRoutes.tsx b/ui/src/pages/Explore/ExploreRoutes.tsx index 41ab79f2b..a4ad2e124 100644 --- a/ui/src/pages/Explore/ExploreRoutes.tsx +++ b/ui/src/pages/Explore/ExploreRoutes.tsx @@ -7,11 +7,11 @@ import RepositorySettings from './RepositorySettings'; const ExploreRoutes: React.FC = () => { return ( - } /> + } /> } /> } /> } /> - } /> + } /> ); }; diff --git a/ui/src/pages/Explore/ExploreTabs.tsx b/ui/src/pages/Explore/ExploreTabs.tsx index c4870add6..84ac7b3b9 100644 --- a/ui/src/pages/Explore/ExploreTabs.tsx +++ b/ui/src/pages/Explore/ExploreTabs.tsx @@ -6,8 +6,8 @@ interface ExploreTabsProps { } const tabs: { key: ExploreTabsProps['active']; label: string; path: string }[] = [ - { key: 'repositories', label: 'Repositories', path: '/explore/repositories' }, { key: 'merge-requests', label: 'Merge Requests', path: '/explore/merge-requests' }, + { key: 'repositories', label: 'Repositories', path: '/explore/repositories' }, ]; const ExploreTabs: React.FC = ({ active }) => { diff --git a/ui/src/pages/Explore/MergeRequests.tsx b/ui/src/pages/Explore/MergeRequests.tsx index e96927d1f..b8e06dd13 100644 --- a/ui/src/pages/Explore/MergeRequests.tsx +++ b/ui/src/pages/Explore/MergeRequests.tsx @@ -151,6 +151,7 @@ const MergeRequests: React.FC = () => { const columns = useMemo[]>(() => [ { id: 'title', + size: 300, // Has its own link to the PR on the provider, so the row link stays out of the cell's content. meta: { rowLink: false }, // Searches title + author + repository together, same combined @@ -210,6 +211,7 @@ const MergeRequests: React.FC = () => { }, { id: 'provider', + size: 120, accessorFn: (pr) => normalizeProvider(pr.provider), filterFn: multiSelectFilterFn, header: ({ column }) => ( @@ -243,6 +245,7 @@ const MergeRequests: React.FC = () => { }, { id: 'state', + size: 120, accessorKey: 'state', filterFn: multiSelectFilterFn, header: ({ column }) => ( @@ -281,6 +284,7 @@ const MergeRequests: React.FC = () => { }, { id: 'author', + size: 150, accessorFn: (pr) => pr.author_name || pr.author_username || '', enableColumnFilter: false, header: ({ column }) => ( @@ -295,6 +299,7 @@ const MergeRequests: React.FC = () => { }, { id: 'updated', + size: 150, accessorFn: (pr) => pr.provider_updated_at || pr.last_synced_at || '', enableColumnFilter: false, header: ({ column }) => ( @@ -311,6 +316,7 @@ const MergeRequests: React.FC = () => { }, { id: 'actions', + size: 160, meta: { rowLink: false }, enableSorting: false, enableColumnFilter: false, @@ -343,6 +349,8 @@ const MergeRequests: React.FC = () => { initialState: { pagination: { pageSize: pageSizeOptions[0] }, }, + columnResizeMode: 'onChange', + enableColumnResizing: true, }); return ( @@ -367,6 +375,7 @@ const MergeRequests: React.FC = () => { `/reviews?pull_request_id=${pr.id}`} loading={loading} loadingLabel="Loading merge requests..." diff --git a/ui/src/pages/Explore/Repositories.tsx b/ui/src/pages/Explore/Repositories.tsx index 69040f66a..b7a01217a 100644 --- a/ui/src/pages/Explore/Repositories.tsx +++ b/ui/src/pages/Explore/Repositories.tsx @@ -247,6 +247,7 @@ const Repositories: React.FC = () => { const columns = useMemo[]>(() => [ { id: 'repository', + size: 320, // Has its own link to the repo on the provider, so the row link stays out of the cell's content. meta: { rowLink: false }, accessorKey: 'full_name', @@ -318,6 +319,7 @@ const Repositories: React.FC = () => { }, { id: 'provider', + size: 160, accessorFn: (repo) => normalizeProvider(repo.provider), filterFn: multiSelectFilterFn, header: ({ column }) => ( @@ -351,6 +353,7 @@ const Repositories: React.FC = () => { }, { id: 'default_branch', + size: 160, accessorKey: 'default_branch', enableColumnFilter: false, header: ({ column }) => ( @@ -365,6 +368,7 @@ const Repositories: React.FC = () => { }, { id: 'sync_status', + size: 160, accessorKey: 'last_sync_status', filterFn: multiSelectFilterFn, header: ({ column }) => ( @@ -408,6 +412,7 @@ const Repositories: React.FC = () => { }, { id: 'actions', + size: 220, meta: { rowLink: false }, enableSorting: false, enableColumnFilter: false, @@ -473,6 +478,8 @@ const Repositories: React.FC = () => { manualPagination: true, pageCount: totalPages, state: { sorting, columnFilters, pagination }, + columnResizeMode: 'onChange', + enableColumnResizing: true, onSortingChange: handleSortingChange, onColumnFiltersChange: handleColumnFiltersChange, onPaginationChange: handlePaginationChange, @@ -503,6 +510,7 @@ const Repositories: React.FC = () => { `/explore/merge-requests?repository_id=${repo.id}`} loading={loading} loadingLabel="Loading repositories..." diff --git a/ui/src/pages/Reviews/ReviewDetail.tsx b/ui/src/pages/Reviews/ReviewDetail.tsx index 655c33273..61afbc81b 100644 --- a/ui/src/pages/Reviews/ReviewDetail.tsx +++ b/ui/src/pages/Reviews/ReviewDetail.tsx @@ -1,11 +1,11 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { useParams, Link, useNavigate } from 'react-router-dom'; +import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'; import { LuExternalLink, LuGitBranch } from 'react-icons/lu'; import ProviderIcon from '../../components/ProviderIcon'; import { riskAssessment, RiskAssessmentDetails, RiskScore } from '../../components/reviews/RiskAssessment'; import { reviewStatusBadge } from '../../utils/reviewDisplay'; -import { Button, Icons, Tabs } from '../../components/UIPrimitives'; -import { ReviewEventsPage, DiffViewerPanel } from '../../components/reviews'; +import { Button, Icons, Tabs, Tooltip } from '../../components/UIPrimitives'; +import { ReviewEventsPage, DiffViewerPanel, PushNotificationBanner } from '../../components/reviews'; import AutoApprovePanel from '../../components/reviews/AutoApprovePanel'; import { getReviewPolicyLog } from '../../api/riskPolicy'; import { @@ -83,10 +83,37 @@ const ReviewDetail: React.FC = () => { const [levelFilter, setLevelFilter] = useState(''); const [typeFilter, setTypeFilter] = useState(''); const [lastEventTime, setLastEventTime] = useState(null); - const [activeTab, setActiveTab] = useState<'findings' | 'accounting' | 'events' | 'auto-approve'>('findings'); + const [nowTick, setNowTick] = useState(() => Date.now()); + type TabId = 'findings' | 'accounting' | 'events' | 'auto-approve'; + const TAB_IDS: TabId[] = ['findings', 'accounting', 'events', 'auto-approve']; + const [searchParams, setSearchParams] = useSearchParams(); + // The active tab lives in the URL (?tab=...), not plain useState, so a + // refresh or a deep link restores the tab instead of always landing on + // Findings, and the browser Back/Forward buttons step through tab + // switches like any other navigation - both were reported as broken. + const tabParam = searchParams.get('tab'); + const activeTab: TabId = (TAB_IDS as string[]).includes(tabParam || '') ? (tabParam as TabId) : 'findings'; + const setActiveTab = useCallback((tab: TabId, opts?: { replace?: boolean }) => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + if (tab === 'findings') { + next.delete('tab'); + } else { + next.set('tab', tab); + } + return next; + }, { replace: opts?.replace }); + }, [setSearchParams]); const [blastReport, setBlastReport] = useState(null); const [blastPoll, setBlastPoll] = useState<'checking' | 'waiting' | 'gave_up'>('checking'); - const [riskScores, setRiskScores] = useState([]); + // null means "not loaded yet" (Findings hasn't computed its real, finding- + // aware scores) -- distinct from [] (loaded, confirmed zero scored hunks). + // riskAssessment() shows a neutral "Calculating..." state for null instead + // of guessing from the structural-only blast radius score, which used to + // flash a different (sometimes higher) risk tier the moment Findings + // finished loading and handed over the real numbers. + const [riskScores, setRiskScores] = useState(null); + const [commentCount, setCommentCount] = useState(undefined); useEffect(() => { // Blast radius comes from `git lrc review` or the server-side job, which runs alongside the @@ -96,7 +123,8 @@ const ReviewDetail: React.FC = () => { let attempts = 0; setBlastReport(null); setBlastPoll('checking'); - setRiskScores([]); + setRiskScores(null); + setCommentCount(undefined); const load = () => { getBlastRadiusReport(reviewId) .then((report) => { @@ -119,7 +147,6 @@ const ReviewDetail: React.FC = () => { }; }, [reviewId]); const pollingIntervalRef = useRef(null); - const defaultedTabForReviewRef = useRef(null); const getEventIcon = (type: string, level?: string) => { @@ -268,18 +295,10 @@ const ReviewDetail: React.FC = () => { setAllCommitsShown(false); }, [id]); - // Findings only exist once the review has finished. While it is still - // running -- or has failed, e.g. an expired git connector -- the Events - // tab is what tells the user what is actually happening, so open there - // instead of on an empty Findings panel. Applied once per review so a - // user who deliberately clicks Findings is not bounced back. - useEffect(() => { - if (!review || defaultedTabForReviewRef.current === review.id) return; - defaultedTabForReviewRef.current = review.id; - if (review.status !== 'completed') { - setActiveTab('events'); - } - }, [review]); + // Findings now stream in incrementally (DiffViewerPanel polls batch + // events while the review runs — see reviewStreamState.ts), so Findings + // stays the default tab even while a review is still in progress instead + // of bouncing the user to Events until completion. // When a review finishes while the page is open, jump to Findings and flag the Auto Approve tab. const lastStatusRef = useRef(undefined); @@ -288,7 +307,7 @@ const ReviewDetail: React.FC = () => { const prev = lastStatusRef.current; lastStatusRef.current = review?.status; if ((prev === 'created' || prev === 'in_progress') && review?.status === 'completed') { - setActiveTab('findings'); + setActiveTab('findings', { replace: true }); if (review.metadata?.pr_link_policy) setAutoApproveAttention(true); } }, [review?.status, review?.metadata?.pr_link_policy]); @@ -350,6 +369,15 @@ const ReviewDetail: React.FC = () => { fetchReviewDetails(); }, [fetchReviewDetails]); + // Drives the live "Elapsed" stat while the review is in progress. + useEffect(() => { + if (review?.status !== 'created' && review?.status !== 'in_progress') { + return; + } + const tick = setInterval(() => setNowTick(Date.now()), 1000); + return () => clearInterval(tick); + }, [review?.status]); + // Poll accounting so the panel auto-updates once usage records land. useEffect(() => { if (!id || !pollingEnabled || accountingRouteUnavailable) { @@ -387,6 +415,10 @@ const ReviewDetail: React.FC = () => { // Refresh the review itself too, so the status badge flips once the review finishes. if (review?.status === 'created' || review?.status === 'in_progress') { getReview(currentReviewId).then(setReview).catch(() => {}); + // Summary (batch count, event counts) is otherwise only fetched once on + // mount - without this it stays frozen at whatever it was when the page + // first loaded, e.g. "Batches: 0" forever if opened before any batch ran. + getReviewSummary(currentReviewId).then(setSummary).catch(() => {}); } }, ACCOUNTING_REFRESH_INTERVAL_MS); @@ -469,6 +501,13 @@ const ReviewDetail: React.FC = () => { return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; }; + // "Elapsed" ticks live while a review is running (unlike formatDuration, which + // only has an answer once completedAt is set) - uses nowTick so it re-renders + // every second instead of freezing at the value from the last fetch. + const elapsed = review.startedAt + ? formatDuration(review.startedAt, review.completedAt || new Date(nowTick).toISOString()) + : null; + const leaderAIExecutionMode = typeof review.metadata?.leader_ai_execution_mode === 'string' ? review.metadata.leader_ai_execution_mode : ''; const leaderAIExecutionSource = typeof review.metadata?.leader_ai_execution_source === 'string' ? review.metadata.leader_ai_execution_source : ''; const leaderAIExecutionProvider = typeof review.metadata?.leader_ai_provider_name === 'string' ? review.metadata.leader_ai_provider_name : ''; @@ -519,6 +558,8 @@ const ReviewDetail: React.FC = () => { return (
+ + {/* Header */}
diff --git a/ui/src/pages/Reviews/Reviews.tsx b/ui/src/pages/Reviews/Reviews.tsx index 959bec9fb..9dc430dd5 100644 --- a/ui/src/pages/Reviews/Reviews.tsx +++ b/ui/src/pages/Reviews/Reviews.tsx @@ -291,6 +291,7 @@ const Reviews: React.FC = () => { () => [ { id: 'review', + size: 180, accessorFn: (review) => getPrimaryTitle(review), // Links its own title/PR line so "Open PR/MR" isn't nested in a link. meta: { rowLink: false }, @@ -406,6 +407,7 @@ const Reviews: React.FC = () => { }, { id: 'branch', + size: 120, accessorFn: (review) => review.branch?.trim() || '', enableColumnFilter: false, header: ({ column }) => ( @@ -433,6 +435,7 @@ const Reviews: React.FC = () => { }, { id: 'repository', + size: 140, accessorFn: (review) => getCleanRepository(review), // Free-text search lives on the "Review" column only (one combined // `search` query param on the backend) - this column is sort-only. @@ -465,6 +468,7 @@ const Reviews: React.FC = () => { }, { id: 'source', + size: 120, accessorFn: (review) => normalizeSource(review.provider, review.triggerType), filterFn: multiSelectFilterFn, header: ({ column }) => ( @@ -517,6 +521,7 @@ const Reviews: React.FC = () => { }, { id: 'status', + size: 120, accessorKey: 'status', filterFn: multiSelectFilterFn, header: ({ column }) => ( @@ -591,6 +596,7 @@ const Reviews: React.FC = () => { }, { id: 'author', + size: 100, accessorFn: (review) => { const fallbackAuthorFromUrl = review.prMrUrl ? extractAuthorFromUrl(review.prMrUrl) @@ -629,6 +635,7 @@ const Reviews: React.FC = () => { }, { id: 'last_activity', + size: 120, accessorFn: (review) => review.completedAt || review.startedAt || review.createdAt, enableColumnFilter: false, @@ -652,6 +659,7 @@ const Reviews: React.FC = () => { }, { id: 'details', + size: 100, enableSorting: false, enableColumnFilter: false, meta: { rowLink: false }, @@ -693,6 +701,8 @@ const Reviews: React.FC = () => { onSortingChange: handleSortingChange, onColumnFiltersChange: handleColumnFiltersChange, onPaginationChange: handlePaginationChange, + columnResizeMode: 'onChange', + enableColumnResizing: true, }); return ( @@ -733,6 +743,7 @@ const Reviews: React.FC = () => { { + const [settings, setSettings] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const load = async () => { + try { + setSettings(await apiClient.get(ENDPOINT)); + } catch (error: any) { + notify.error(error?.message || 'Failed to load AI review performance settings'); + } + }; + + useEffect(() => { + load(); + }, []); + + const handleSave = async () => { + if (!settings) return; + setIsSaving(true); + try { + await apiClient.put(ENDPOINT, { + max_concurrent_batches_ceiling: Number(settings.max_concurrent_batches_ceiling), + min_review_timeout_floor_seconds: Number(settings.min_review_timeout_floor_seconds), + max_review_timeout_ceiling_seconds: Number(settings.max_review_timeout_ceiling_seconds), + }); + notify.success('Settings saved successfully!'); + await load(); + } catch (error: any) { + notify.error(error?.message || 'Failed to save AI review performance settings'); + } finally { + setIsSaving(false); + } + }; + + if (!settings) { + return ( +
+
+
+ ); + } + + const timeoutRangeInvalid = settings.max_review_timeout_ceiling_seconds < settings.min_review_timeout_floor_seconds; + const canSubmit = settings.max_concurrent_batches_ceiling > 0 && settings.min_review_timeout_floor_seconds > 0 && !timeoutRangeInvalid; + + return ( +
+
+
+ + + +
+
+

AI Review Performance

+

+ Instance-wide safety bounds for review concurrency and timeout. Any organization's own preference (AI Providers → Review AI Settings) is always clamped within these. +

+
+
+ +
+
+
+

Max concurrent batches ceiling

+

+ Highest number of a review's batches allowed to run at once. A self-tuning limiter discovers the actual safe number for your AI provider at runtime - this is just the safety cap it can never exceed. +

+
+
+ setSettings({ ...settings, max_concurrent_batches_ceiling: parseInt(e.target.value, 10) || 0 })} + aria-label="Max concurrent batches ceiling" + className="w-20 bg-slate-800 border border-slate-600 rounded px-2 py-1 text-white font-bold text-sm text-center focus:outline-none focus:border-indigo-500" + /> + batches +
+
+ +
+
+

Min review timeout

+

+ Floor on how short a review's overall timeout can be set, instance-wide or per-org. +

+
+
+ setSettings({ ...settings, min_review_timeout_floor_seconds: parseInt(e.target.value, 10) || 0 })} + aria-label="Min review timeout in seconds" + className="w-20 bg-slate-800 border border-slate-600 rounded px-2 py-1 text-white font-bold text-sm text-center focus:outline-none focus:border-indigo-500" + /> + seconds +
+
+ +
+
+

Max review timeout ceiling

+

+ Highest the overall review timeout can be set to, instance-wide or per-org. +

+
+
+ setSettings({ ...settings, max_review_timeout_ceiling_seconds: parseInt(e.target.value, 10) || 0 })} + aria-label="Max review timeout ceiling in seconds" + className="w-20 bg-slate-800 border border-slate-600 rounded px-2 py-1 text-white font-bold text-sm text-center focus:outline-none focus:border-indigo-500" + /> + seconds +
+
+ {timeoutRangeInvalid && ( +

Max review timeout ceiling must be at or above the min review timeout.

+ )} +
+ +
+ +
+
+ ); +}; + +export default AIReviewPerformanceSettingsTab; diff --git a/ui/src/pages/Settings/AutoApprovePolicyTab.tsx b/ui/src/pages/Settings/AutoApprovePolicyTab.tsx index bb042d763..5dac31bbc 100644 --- a/ui/src/pages/Settings/AutoApprovePolicyTab.tsx +++ b/ui/src/pages/Settings/AutoApprovePolicyTab.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import { useOrgContext } from '../../hooks/useOrgContext'; import { Alert, Button, Card, Spinner, Tabs } from '../../components/UIPrimitives'; import { @@ -14,6 +14,7 @@ import { retrainRiskModel, simulatePolicy, SimulateRow, + SimulateSummary, listReviewOutcomes, OutcomeRow, } from '../../api/riskPolicy'; @@ -70,8 +71,18 @@ const AutoApprovePolicyTab: React.FC = () => { const [overrideDraft, setOverrideDraft] = useState>({}); const saveTimers = useRef>>({}); const [outcomes, setOutcomes] = useState([]); + const location = useLocation(); const [subtab, setSubtab] = useState(() => new URLSearchParams(window.location.search).get('subtab') || 'general'); + // Re-sync whenever ?subtab= changes, not just on first mount: the "Configure" + // links on the review detail page's Auto-Approve tab all route to this same + // /settings#auto-approve URL with a different ?subtab=, so React Router updates + // location.search without remounting this component — a useState initializer + // alone would miss every navigation after the first. + useEffect(() => { + setSubtab(new URLSearchParams(location.search).get('subtab') || 'general'); + }, [location.search]); const [simRows, setSimRows] = useState(null); + const [simSummary, setSimSummary] = useState(null); const orgId = currentOrgId ?? 0; const readOnly = !canManageCurrentOrg && currentOrg?.role !== 'owner'; @@ -176,6 +187,7 @@ const AutoApprovePolicyTab: React.FC = () => { try { const res = await simulatePolicy({ policy: draft, limit: 20 }); setSimRows(res.rows); + setSimSummary(res.summary); } catch (e: any) { setError(String(e?.message || e)); } @@ -269,6 +281,28 @@ const AutoApprovePolicyTab: React.FC = () => { + {simSummary && (() => { + const denom = simSummary.scored_count || 1; + const saturatedFraction = (simSummary.saturated_high + simSummary.saturated_low) / denom; + const saturationWarning = simSummary.scored_count > 0 && saturatedFraction > 0.3; + const text = simSummary.scored_count === 0 + ? `${simSummary.skipped_count} review(s) skipped (couldn't be scored) — nothing to show.` + : `${simSummary.scored_count}/${simSummary.scored_count + simSummary.skipped_count} scored` + + (simSummary.skipped_count > 0 ? ` (${simSummary.skipped_count} skipped)` : '') + + `. ${simSummary.saturated_high} pinned at the ceiling (>0.99), ${simSummary.saturated_low} at the floor (<0.01).` + + ` Range ${simSummary.score_min?.toFixed(2)}-${simSummary.score_max?.toFixed(2)}, mean ${simSummary.score_mean?.toFixed(2)}.`; + return ( + + {text} + {saturationWarning && ( + + Over 30% of these reviews scored at the extreme ends — this slider configuration may not be + differentiating risk well. Consider easing the categories driving the highest scores. + + )} + + ); + })()} {simRows && (

Simulation (last {simRows.length} completed reviews):

diff --git a/ui/src/pages/Settings/NotificationsTab.tsx b/ui/src/pages/Settings/NotificationsTab.tsx new file mode 100644 index 000000000..529910026 --- /dev/null +++ b/ui/src/pages/Settings/NotificationsTab.tsx @@ -0,0 +1,156 @@ +import React, { useEffect, useState } from 'react'; +import { Card, Toggle, Button, Spinner } from '../../components/UIPrimitives'; +import { notify } from '../../utils/notify'; +import { + getNotificationPreferences, + updateNotificationPreferences, + NotificationPreferences, +} from '../../api/push'; +import { getExistingSubscription, isPushSupported, subscribe, unsubscribe } from '../../utils/pushNotifications'; + +const DEFAULT_PREFS: NotificationPreferences = { notify_first_finding: true, notify_completion: true }; + +// Self-service: every authenticated user manages their own notification +// settings, mirroring AccountTab's gating (no org-role check needed). +const NotificationsTab: React.FC = () => { + const [loading, setLoading] = useState(true); + const [prefs, setPrefs] = useState(DEFAULT_PREFS); + const [saving, setSaving] = useState(false); + const [subscribed, setSubscribed] = useState(false); + const [subscribing, setSubscribing] = useState(false); + const [permission, setPermission] = useState( + typeof Notification !== 'undefined' ? Notification.permission : null + ); + const supported = isPushSupported(); + const blocked = permission === 'denied'; + + useEffect(() => { + let cancelled = false; + Promise.all([getNotificationPreferences(), getExistingSubscription()]) + .then(([p, sub]) => { + if (cancelled) return; + setPrefs(p); + setSubscribed(!!sub); + }) + .catch((err) => { + console.error('Failed to load notification settings:', err); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const savePrefs = async (next: NotificationPreferences) => { + setPrefs(next); + setSaving(true); + try { + await updateNotificationPreferences(next); + } catch (err) { + notify.error('Failed to save notification preferences.'); + } finally { + setSaving(false); + } + }; + + const handleToggleBrowser = async () => { + setSubscribing(true); + try { + if (subscribed) { + await unsubscribe(); + setSubscribed(false); + } else { + if (Notification.permission === 'denied') { + setPermission('denied'); + return; + } + const result = await Notification.requestPermission(); + setPermission(result); + if (result !== 'granted') return; + await subscribe(); + setSubscribed(true); + notify.success("You're set — we'll notify you about your reviews."); + } + } catch (err) { + console.error('Failed to toggle push notifications:', err); + notify.error('Could not update browser notification settings.'); + } finally { + setSubscribing(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ +

Browser Notifications

+

+ Get a notification on this device when your reviews have news — even if LiveReview isn't open in a tab. +

+ {!supported ? ( +

+ Browser notifications aren't available in this browser or over an insecure connection (HTTPS is required). +

+ ) : blocked ? ( +

+ Notifications are blocked for this site. Click the lock or info icon next to your browser's address bar, + allow notifications for this site, then reload the page. +

+ ) : ( + <> + + {!subscribed && ( +

+ Your browser may show a permission request near its address bar - allow it there to finish. +

+ )} + + )} +
+ + +

What to notify me about

+

Both are on by default. Applies to reviews you trigger yourself.

+
+
+
+

First finding is ready

+

As soon as the first batch of results streams in.

+
+ savePrefs({ ...prefs, notify_first_finding: checked })} + aria-label="Notify on first finding" + /> +
+
+
+

Review completed or failed

+

When the whole review finishes.

+
+ savePrefs({ ...prefs, notify_completion: checked })} + aria-label="Notify on completion" + /> +
+
+
+
+ ); +}; + +export default NotificationsTab; diff --git a/ui/src/pages/Settings/Settings.tsx b/ui/src/pages/Settings/Settings.tsx index 2af4192ce..07b1aa3f2 100644 --- a/ui/src/pages/Settings/Settings.tsx +++ b/ui/src/pages/Settings/Settings.tsx @@ -15,6 +15,7 @@ import ManagedAITab from './ManagedAITab'; import CompactionSettingsTab from './CompactionSettingsTab'; import AutoApprovePolicyTab from './AutoApprovePolicyTab'; import AccountTab from './AccountTab'; +import NotificationsTab from './NotificationsTab'; import { UserManagement } from '../../components/UserManagement'; import LicenseManagement from '../Licenses/LicenseManagement'; import { useOrgContext } from '../../hooks/useOrgContext'; @@ -296,6 +297,8 @@ const Settings = () => { const tabs = [ // Self-service: every authenticated user can manage their own profile and password, regardless of org role { id: 'account', name: 'Account', icon: }, + // Self-service: browser push notifications for the user's own reviews, regardless of org role + { id: 'notifications', name: 'Notifications', icon: }, // Instance-wide config: self-hosted owners may manage it, but in cloud it's shared across every tenant, so it stays super-admin-only there ...(canManageInstanceConfig ? [{ id: 'instance', name: 'Instance', icon: }] : []), ...(canManageCurrentOrg && !isCloudMode() ? [{ @@ -651,6 +654,8 @@ const Settings = () => { )} + {activeTab === 'notifications' && } + {activeTab === 'prompts' && canAccessPrompts && ( diff --git a/ui/src/pages/Settings/StorageSettingsTab.tsx b/ui/src/pages/Settings/StorageSettingsTab.tsx index 6c080b3cc..b79f58b79 100644 --- a/ui/src/pages/Settings/StorageSettingsTab.tsx +++ b/ui/src/pages/Settings/StorageSettingsTab.tsx @@ -6,6 +6,7 @@ import { notify } from '../../utils/notify'; import CompactionSettingsTab from './CompactionSettingsTab'; import PreloadedChangesArchivalSettingsTab from './PreloadedChangesArchivalSettingsTab'; import BlastRadiusCacheSettingsTab from './BlastRadiusCacheSettingsTab'; +import AIReviewPerformanceSettingsTab from './AIReviewPerformanceSettingsTab'; import { isCloudMode } from '../../utils/deploymentMode'; import { useOrgContext } from '../../hooks/useOrgContext'; @@ -58,6 +59,7 @@ const StorageSettingsTab: React.FC = () => { { id: 'log-compaction', label: 'Log Compaction' }, { id: 'archival', label: 'Preloaded Changes Archival' }, { id: 'repo-cache', label: 'Repo Cache' }, + { id: 'ai-review-performance', label: 'AI Review Performance' }, ] : []), ]; const requested = new URLSearchParams(location.search).get('section'); @@ -334,6 +336,7 @@ const StorageSettingsTab: React.FC = () => { {section === 'log-compaction' && } {section === 'archival' && } {section === 'repo-cache' && } + {section === 'ai-review-performance' && }
); }; diff --git a/ui/src/types/reviews.ts b/ui/src/types/reviews.ts index 0a848fe25..55baef75b 100644 --- a/ui/src/types/reviews.ts +++ b/ui/src/types/reviews.ts @@ -140,6 +140,7 @@ export interface ReviewCommit { ref: string; refType: 'commit' | 'range'; createdAt: string; + message?: string; } export interface ReviewCommitsResponse { diff --git a/ui/src/utils/notify.ts b/ui/src/utils/notify.ts index 953e13ce7..113ab0d09 100644 --- a/ui/src/utils/notify.ts +++ b/ui/src/utils/notify.ts @@ -22,6 +22,9 @@ const dispatchNotification = (input: AddNotificationInput): void => { }; export const notify = { + info(message: string, opts?: NotifyOptions): void { + dispatchNotification({ severity: 'info', message, source: 'toast-migrated', toast: true, ...opts }); + }, success(message: string, opts?: NotifyOptions): void { dispatchNotification({ severity: 'success', message, source: 'toast-migrated', toast: true, ...opts }); }, diff --git a/ui/src/utils/pushNotifications.test.ts b/ui/src/utils/pushNotifications.test.ts new file mode 100644 index 000000000..f807cc62d --- /dev/null +++ b/ui/src/utils/pushNotifications.test.ts @@ -0,0 +1,104 @@ +import { isPushSupported, subscribe, unsubscribe, getExistingSubscription } from './pushNotifications'; +import * as pushApi from '../api/push'; + +jest.mock('../api/push', () => ({ + getVapidPublicKey: jest.fn(), + createPushSubscription: jest.fn(), + deletePushSubscription: jest.fn(), +})); + +describe('pushNotifications', () => { + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + delete (navigator as any).serviceWorker; + delete (window as any).PushManager; + }); + + describe('isPushSupported', () => { + it('returns false when serviceWorker/PushManager are missing', () => { + expect(isPushSupported()).toBe(false); + }); + + it('returns true when serviceWorker, PushManager, and a secure context are present', () => { + Object.defineProperty(navigator, 'serviceWorker', { value: {}, configurable: true }); + (window as any).PushManager = function () {}; + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + expect(isPushSupported()).toBe(true); + }); + }); + + describe('subscribe', () => { + it('registers the service worker, subscribes pushManager, and posts the subscription to the backend', async () => { + const mockSubscription = { + endpoint: 'https://push.example.com/abc', + toJSON: () => ({ keys: { p256dh: 'p256dh-value', auth: 'auth-value' } }), + }; + const mockRegistration = { + pushManager: { subscribe: jest.fn().mockResolvedValue(mockSubscription) }, + }; + + Object.defineProperty(navigator, 'serviceWorker', { + value: { register: jest.fn().mockResolvedValue(mockRegistration) }, + configurable: true, + }); + (window as any).PushManager = function () {}; + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + + (pushApi.getVapidPublicKey as jest.Mock).mockResolvedValue('AAAA'); + (pushApi.createPushSubscription as jest.Mock).mockResolvedValue(undefined); + + await subscribe(); + + expect(navigator.serviceWorker.register).toHaveBeenCalledWith('/sw.js'); + expect(mockRegistration.pushManager.subscribe).toHaveBeenCalledWith( + expect.objectContaining({ userVisibleOnly: true }) + ); + expect(pushApi.createPushSubscription).toHaveBeenCalledWith({ + endpoint: 'https://push.example.com/abc', + keys: { p256dh: 'p256dh-value', auth: 'auth-value' }, + }); + }); + }); + + describe('unsubscribe', () => { + it('does nothing when there is no existing subscription', async () => { + Object.defineProperty(navigator, 'serviceWorker', { + value: { getRegistration: jest.fn().mockResolvedValue(undefined) }, + configurable: true, + }); + (window as any).PushManager = function () {}; + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + + await unsubscribe(); + expect(pushApi.deletePushSubscription).not.toHaveBeenCalled(); + }); + + it('unsubscribes locally and removes the subscription from the backend when one exists', async () => { + const mockSubscription = { + endpoint: 'https://push.example.com/abc', + unsubscribe: jest.fn().mockResolvedValue(true), + }; + const mockRegistration = { + pushManager: { getSubscription: jest.fn().mockResolvedValue(mockSubscription) }, + }; + Object.defineProperty(navigator, 'serviceWorker', { + value: { getRegistration: jest.fn().mockResolvedValue(mockRegistration) }, + configurable: true, + }); + (window as any).PushManager = function () {}; + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + + await unsubscribe(); + + expect(mockSubscription.unsubscribe).toHaveBeenCalled(); + expect(pushApi.deletePushSubscription).toHaveBeenCalledWith('https://push.example.com/abc'); + }); + }); + + describe('getExistingSubscription', () => { + it('returns null when push is unsupported', async () => { + await expect(getExistingSubscription()).resolves.toBeNull(); + }); + }); +}); diff --git a/ui/src/utils/pushNotifications.ts b/ui/src/utils/pushNotifications.ts new file mode 100644 index 000000000..3ed695b89 --- /dev/null +++ b/ui/src/utils/pushNotifications.ts @@ -0,0 +1,74 @@ +// Web Push subscribe/unsubscribe plumbing for review-progress notifications. +// See the Browser Push Notifications plan for the full design. +import { createPushSubscription, deletePushSubscription, getVapidPublicKey } from '../api/push'; + +export function isPushSupported(): boolean { + return ( + typeof window !== 'undefined' && + 'serviceWorker' in navigator && + 'PushManager' in window && + window.isSecureContext + ); +} + +// The VAPID public key comes back base64url-encoded (SherClockHolmes/webpush-go's +// GenerateVAPIDKeys output); pushManager.subscribe wants it as a raw Uint8Array. +function base64UrlToUint8Array(base64Url: string): Uint8Array { + const padding = '='.repeat((4 - (base64Url.length % 4)) % 4); + const base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const output = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; i++) { + output[i] = rawData.charCodeAt(i); + } + return output; +} + +let swRegistrationPromise: Promise | null = null; + +export function registerServiceWorker(): Promise { + if (!isPushSupported()) { + return Promise.reject(new Error('Push notifications are not supported in this browser/context.')); + } + if (!swRegistrationPromise) { + swRegistrationPromise = navigator.serviceWorker.register('/sw.js'); + } + return swRegistrationPromise; +} + +export async function getExistingSubscription(): Promise { + if (!isPushSupported()) return null; + const registration = await navigator.serviceWorker.getRegistration('/sw.js'); + if (!registration) return null; + return registration.pushManager.getSubscription(); +} + +function subscriptionToPayload(subscription: PushSubscription) { + const keys = subscription.toJSON().keys; + return { + endpoint: subscription.endpoint, + keys: { p256dh: keys?.p256dh || '', auth: keys?.auth || '' }, + }; +} + +// Subscribes this browser and registers it with the backend. Caller is +// responsible for requesting Notification permission first (the toast +// action handler does this, since it's the one user gesture the browser +// requires permission to be tied to). +export async function subscribe(): Promise { + const registration = await registerServiceWorker(); + const vapidPublicKey = await getVapidPublicKey(); + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: base64UrlToUint8Array(vapidPublicKey) as BufferSource, + }); + await createPushSubscription(subscriptionToPayload(subscription)); +} + +export async function unsubscribe(): Promise { + const subscription = await getExistingSubscription(); + if (!subscription) return; + const endpoint = subscription.endpoint; + await subscription.unsubscribe(); + await deletePushSubscription(endpoint); +}
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + {enableColumnResizing && header.column.getCanResize() && ( +
+ )}