Skip to content

Stream findings live, and notify the triggering user via browser push - #150

Merged
shrsv merged 25 commits into
masterfrom
incremental-findings-webui
Oct 31, 2026
Merged

shrsv merged 25 commits into
masterfrom
incremental-findings-webui

Conversation

@shrsv

@shrsv shrsv commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

🔍 LiveReview

⚙️ Customize risk-based approval policy for this repository or your organization.


This PR bundles two related review-visibility improvements, a dev-tooling fix found along the way, and a round of production bug fixes found while verifying the push-notification feature end to end.


Part 1: Stream findings into the Findings tab while a review is running

Use case

Today, when you hit Trigger Review or Start a Review, the webui bounces you to the Events tab (a log/timeline view) and keeps the Findings tab empty until the entire review finishes. For a large diff spanning many files, that means staring at a scrolling log with no sense of which files have problems until everything is done.

This PR makes the Findings tab usable the whole time a review is running:

  • Findings is now the default tab from the moment a review starts — no more forced redirect to Events.
  • As soon as the diff is parsed, the real file/hunk list appears (even before any AI output exists).
  • As each batch of files finishes its LLM pass, that batch's comments appear inline in the matching file, live — no need to refresh or wait.
  • A banner ("Findings are streaming in... N so far") makes clear the review is still in progress and the list will keep growing.
  • If a review fails partway through, whatever was already streamed in stays visible instead of being thrown away behind a generic error screen.
  • On completion, one final authoritative fetch reconciles everything (see below) so nothing streamed-in is ever stale.

Out of scope for this PR: posting comments to GitHub/GitLab/etc. PR threads is untouched and still happens as a single batch at the end. Making that incremental too is a separate, larger change (it would need to handle the helper "polish" stage potentially rewording a comment after it's already been posted) and is intentionally left for a future PR.

Algorithm / data structures

The backend has quietly been capable of this for a while: every time a batch of files finishes its LLM call, internal/ai/langchain/provider.go emits a batch/completed event carrying that batch's raw []*models.ReviewComment into the review_events table (EmitBatchComplete → DatabaseEventSink.EmitBatchEvent → CreateBatchEvent). The webui simply never read that payload — the Events tab only ever rendered it as a log line. git-lrc's local review UI already solved exactly this by polling the same events endpoint and merging streamed comments into its file view; this PR ports that logic into LiveReview's hosted webui.

New module: ui/src/components/reviews/diffviewer/reviewStreamState.ts (ported ~1:1 from git-lrc:internal/staticserve/static/components/review_stream_state.mjs, unit-tested in reviewStreamState.test.ts):

  • Event dedup (extractNewEvents): maintains a Set<string> of seen event ids across polls (falling back to a stable composite key — type/time/batchId/stringified-data — when the server omits an id), so re-polling with overlap never double-applies a batch's comments.
  • Status inference (inferReviewStatusFromEvents): scans events backwards for the latest status event or a completion event, to detect completed/failed from the event stream itself without waiting on a separate GET /reviews/:id poll.
  • Comment extraction + normalization (extractExternalCommentsFromEvents / normalizeStreamedComment): filters for type === 'batch' && data.status === 'completed', reads data.comments[], and normalizes field casing — models.ReviewComment has no JSON tags so it marshals PascalCase (FilePath, Line, ...) over the wire, while DiffReviewComment (the shape GetDiffReviewStatus returns) is lower camelCase. Drops IsInternal comments, which are synthesis-only and never meant to reach a user.
  • Merge (appendStreamedCommentsToFiles): an O(files + comments) merge — builds a Map<file_path, index> once, then appends each incoming comment to its file's comments[] by path lookup, immutably (returns new file objects, doesn't mutate the input array react state depends on).

DiffViewerPanel.tsx wires this into two effects:

  1. The existing one-shot getDiffReview fetch on mount/reviewId change (unchanged).
  2. A new polling effect, active only while data.status is created/in_progress: polls GetReviewEvents(reviewId, since, limit=1000) every 1s (matching git-lrc's cadence), runs the response through dedup → extract → merge, and accumulates streamed comments in state. The moment inferred status flips to completed/failed, it fires one final getDiffReview call and clears the streamed-comment accumulator — that response already carries the real (possibly helper-polished) comments, so there's nothing left to merge on top of it. Rendered files = appendStreamedCommentsToFiles(data.files, streamedComments), memoized.

Deeper technical details

Backend change was required, not just a frontend port. GetDiffReviewStatus (internal/api/diff_review.go) used to early-return {status, review_id} with no files key at all whenever reviewRecord.Status != "completed" — the diff/hunk structure (fetchPreloadedChanges + buildDiffFiles) was only ever computed in the completed-path below. That meant there was nothing for the frontend to merge streamed comments into before completion. Fixed by extracting the trigger-type-aware diff-resolution logic (blob storage lookup, falling back to a live re-fetch from the PR/MR or from scheduled-trigger metadata depending on TriggerType) into a new helper, resolvePreloadedChanges, called unconditionally before branching on completion status. The non-completed response path now also returns files: buildDiffFiles(preloaded, nil) — hunks with empty comments, to be filled in by the frontend's event-driven merge. The completed path is otherwise unchanged (still calls buildDiffFiles(preloaded, result.Comments) with the real, final comments from review_result metadata).

Why the final reconciliation fetch is safe, not just convenient. The helper "polish" stage (internal/review/service.go lines ~259-264) only ever rewords findings the leader model already produced — per the code's own comment, it never adds, drops, or relocates one. So a comment shown early via the batch-event stream is already correct in substance and position; the only thing the final getDiffReview fetch can change is wording. That's why clearing streamedComments and trusting the final fetch wholesale, rather than trying to diff/patch it against what was already shown, is the right trade — verified by reading git-lrc's own reason for being "fast": its local CLI-diff reviews skip PR-posting entirely (Provider.Type == "cli") and normally run without the helper stage at all, so there was nothing to reconcile there; LiveReview's hosted reviews can have the helper stage, hence the explicit reconciliation step this PR adds that git-lrc's UI never needed.

Tab-switching changes in ReviewDetail.tsx. Removed the one-time-per-review effect that force-switched activeTab to 'events' whenever review.status !== 'completed' — Findings is now reachable (and useful) the entire time a review runs. The existing effect that jumps back to Findings on completion is kept as a courtesy. The existing key={review?.status} on <DiffViewerPanel> (forcing a clean remount on each status transition) is left untouched and composes fine with the new polling — each remount just starts a fresh poll cycle for whatever status it mounts under.

Gating/empty-states in DiffViewerPanel.tsx were restructured around files.length instead of data.status === 'completed': no diff data at all → unchanged "no diff data" empty state; a failure with zero streamed findings → unchanged failure empty state; a failure with some findings already streamed in → now shown with a warning banner instead of being discarded; still-running with no files yet (diff not resolved server-side yet) → new "Preparing the diff…" empty state; still-running or failed with files present → full diff/findings view, with an info or warning banner noting the in-progress/failed state and the running finding count.


Part 2: Browser push notifications for review progress

Use case

Once you trigger a review, the only way to know it's progressing or done used to be keeping the tab open (helped by Part 1 above). There was no way to walk away and get pinged. This adds real OS-level browser notifications — via the Web Push API + a service worker, so they arrive even if the tab or browser is closed — for two moments per review: the first finding becoming available, and the review completing (or failing).

  • Scope: only reviews triggered by a logged-in session — scheduled/webhook-triggered reviews have no identifiable human to notify and simply don't send one; that's correct behavior, not a gap.
  • Opt-in UX: a closeable banner (PushNotificationBanner) on the review detail page — not a toast, which turned out to never render action buttons at all (see Part 4) — with an inline "Enable notifications" button, a "Settings" link, and guidance if the browser has already blocked notifications for the site.
  • A new Settings → Notifications tab lets you toggle either notification moment independently, both on by default, and enable/disable push for the current browser.
  • Each notification shows the review number, repo, and PR/MR title (not just a generic "your review"), and a review that finished with errors is titled "Review completed with errors" rather than the blunter "Review failed".

Algorithm / data structures

Two different trigger points, for a good reason:

  1. First finding available. Batches run concurrently (internal/batch/task_queue.go worker pool), so "first" needs an atomic claim, not an in-memory flag, or a goroutine race would double-notify. pushnotify.ClaimFirstFinding runs UPDATE reviews SET first_finding_notified_at = now() WHERE id = $1 AND first_finding_notified_at IS NULL — only the caller that gets a row back (verified under 20-goroutine concurrency in pushnotify_test.go) proceeds to notify. This is wired into both of the codebase's independent event-sink implementations (internal/review_processor/events.go's CreateBatchEvent, used by the async job-queue path, and internal/api/polling_event_service.go's CreateBatchEvent, used by the synchronous CLI-diff API path) — missing either one would silently drop notifications for half of LiveReview's review-triggering paths.

  2. Completion/failure. Terminal-status writes are scattered across internal/jobqueue/review_worker.go, scheduled_review_worker.go, and internal/review_processor/manual.go — too many call sites to safely hook individually. Centralized instead inside ReviewManager.UpdateReviewStatus (internal/review_processor/reviews.go): after the status row update succeeds for a terminal status, it calls a package-level notifier hook (review_processor.SetNotifier(n) / a nil-safe package var), set once at server startup in internal/api/server.go. A package-level hook, not a constructor param, because NewReviewManager(db) is called fresh at many unrelated call sites — threading a notifier through all of them would recreate the same "touch everything" problem this avoids.

Delivery (internal/pushnotify): a VAPID keypair is generated once at first use and stored in system_settings (same pattern as SMTP credentials). NotifyReviewEvent resolves reviews.user_email → a users row (skips silently if empty — the "no identifiable human" case), checks that user's user_notification_preferences row (missing row = both enabled, matching the "on by default" decision with no backfill needed), loads their push_subscriptions rows (one per browser/device), and sends via webpush-go; a 410/404 response prunes that subscription inline.

Deeper technical details

New schema (db/migrations/20261030134721_create_push_notifications.sql): push_subscriptions(id, user_id FK, endpoint UNIQUE, p256dh, auth_key, user_agent, created_at, last_used_at), user_notification_preferences(user_id PK FK, notify_first_finding, notify_completion, updated_at), and reviews.first_finding_notified_at TIMESTAMPTZ (the claim-once marker above).

New endpoints, session-only (authMiddleware.RequireAuth(), never API-key — a push subscription is tied to one specific browser's real endpoint, not something automation should manage on a user's behalf): GET /push/vapid-public-key, POST/DELETE /push/subscriptions, GET/PUT /users/me/notification-preferences. The three mutating routes are wired through s.audited(...) with new auditlog.Spec entries/action constants, documented in docs/audit-events.md per this repo's audit-logging rules. user_id is always read from the authenticated session context, never the request body.

Frontend: a minimal push-only service worker (ui/public/sw.js — just push/notificationclick, not a full PWA/offline cache, using notification-icon.png — favicon.svg rasterized at 192x192 via vl-convert svg2png, since .ico isn't reliably rendered as a notification icon), pushNotifications.ts wrapping pushManager.subscribe/getSubscription, PushNotificationBanner on the review detail page, and a new Settings → Notifications tab — plus the required mega-menu entry and routes_guide doc per this repo's navigation/RAG-doc conventions.


Part 3: Fix db/schema.sql drift and the dump tooling behind it

While working on Part 2, found db/schema.sql had silently drifted from the actual migration history — dbmate dump was failing locally because the host's pg_dump (16.13, from Ubuntu's own repo) was older than the dev Postgres server (18.6), and pg_dump refuses to dump a server newer than itself. This wasn't new or caused by this PR; it meant at least one prior migration (users.time_zone, already merged in an earlier PR) had also never made it into schema.sql.

Fixed properly rather than routed around:

  • scripts/install-pgdg-client.sh installs a matching-major postgresql-client from the official PGDG apt repo (one-time, needs sudo).
  • scripts/dbmate-dump.sh (wired to make db-dump) now checks the local pg_dump major version against the live server's server_version_num before dumping, and fails fast with the exact fix command instead of a confusing dbmate error or a silent workaround.
  • db/schema.sql regenerated and verified byte-identical to a known-good Docker-container-produced dump except for the dumper-package label — confirming the catch-up is accurate, not just "a diff happened."

Part 4: Bugs found verifying Part 2 against a real deployment

  • The push notifier was never wired in the worker process. reviewprocessor.SetNotifier(...) was only called inside NewServer() (the api process). The background worker process (ecosystem.config.js's livereview-worker/-2, cmd/worker.go → api.WorkerContext()) is where queued reviews actually run and where batch-completion/status events fire — so in that process the notifier stayed nil, and every NotifyReviewEvent call silently no-op'd with no error or log line. A subscribed user's completed review with findings never set first_finding_notified_at and never sent a push. Fixed by moving SetNotifier into the shared appContext() both NewServer and WorkerContext call.
  • GET /reviews/:id/accounting 500'd on every freshly-triggered review. GetReviewAccountingTotals's token_tracked_ops column is a bare SUM(CASE WHEN ... THEN 1 ELSE 0 END), unlike total_billable_loc's COALESCE(SUM(...), 0) right next to it — SUM() over zero matching loc_usage_ledger rows (every review, until its first LLM call is accounted) is NULL, and scanning that into a non-nullable int64 failed the whole query. Fixed with a matching COALESCE; regression test confirmed to fail against the old query and pass against the fix.
  • Notification polish: real review specifics (#<id>, owner/repo, PR/MR title) in the body instead of a bare repository URL, a proper PNG icon instead of .ico, and "Review completed with errors" instead of the blunter "Review failed" (most failures here are one erroring batch, not total loss).
  • Defensive: trimmed whitespace before validating the browser-reported IANA time zone (PUT /users/time-zone) — couldn't reproduce a bad zone name server-side, but this guards against stray whitespace some browser/OS combinations are known to append.
  • Confirmed GET /github-app/demo 404s are expected on boxes without GITHUB_APP_ID configured, not a bug — useGitHubAppConnected already documents and handles a 404 there as "not connected."

Part 5: Risk model saturation fix, found while reading the auto-approval table above

While verifying Part 4 end to end, the auto-approval table this PR's own bot comment renders (above) surfaced something off: review #13543's risk message read "Risk score 1.00 (P100) is above the cutoff of 1.00 (P20)" — a score pinned at exactly 1.00 against a cutoff also exactly 1.00. Traced it back to a real scoring bug, not a display bug.

Root cause

internal/riskscore/features.go's br_sum (a raw, unbounded SUM(combined) across every blast-radius hunk in a diff) was scored by internal/riskscore/model.go's Prior() with a weight calibrated for an assumed 0-100 range. Querying production (read-only) showed real br_sum values running 1700-4840 — at the default blast-radius override (2.0x), that pushes the sigmoid's input z to roughly 190, and in float64, 1 + exp(-z) rounds to exactly 1.0 for any z past ~37, well before it would ever literally overflow. Org 3's entire 10-review history scored exactly 1.0; org 677 showed a clean before/after (normal varying scores while br_sum = 0, pinned to 1.0 the instant it crossed ~150).

Checked whether this was br_sum-specific via the category-override sliders (Settings → Risk Model, 0-10x per category): every other raw count feature (critical_count, warning_count, security_count, br_high_count, and the 9 finding-taxonomy counts) shared the identical structural flaw — real production data already shows warning_count up to 37 and critical_count up to 12, both alone enough to saturate even without any override.

Fix

  • internal/riskscore/features.go: every structurally-unbounded count/sum feature is now math.Log1p-compressed before scoring (matching the convention already used for files_changed/lines_added), not just the two that happened to be compressed before.
  • internal/riskscore/model.go: Prior()'s weights retuned for the new log1p'd scale (new_weight = intended_contribution_at_reference / log1p(reference), preserving the existing priority ordering: critical > security > correctness/reliability > performance > the rest). Added a hard zClampAbs safety net (25 — comfortably inside float64's real precision-collapse zone around |z| > ~37, but with enough headroom that genuinely different high-risk reviews stay distinguishable rather than all collapsing to the clamp boundary) around the sigmoid input in both Score and scoreRaw, so no future weight/override/feature-scale mistake can reproduce this class of bug.
  • internal/riskscore/saturation_test.go (new): regression fixtures built from the real production feature vectors found above (org 3 and org 677), confirming they no longer score bit-identical or pinned at the ceiling; a sweep of every category override 0.1x-10x against a realistic battery, asserting the score always moves monotonically and never jumps straight to the clamp; a fuzz test confirming the score is always strictly inside (0, 1). Also fixed internal/riskscore/referencemix/referencemix.go (shared test fixture data, built by hand rather than through ExtractFeatures) and two existing tests (TestScoreOverrideEffect, TestPriorScoreMonotonicInEachFeature) that had silently drifted out of sync with the new encoding or sampled unrealistically small feature ranges — exactly why they never caught this.
  • db/migrations/20261031140000_reset_risk_model_seed_for_feature_rescale.sql (new): deletes the single stale shared seed row so EnsureSeedModel's existing idempotent bootstrap reinserts it from the corrected Prior() on next use. Safe: the entire production DB has exactly one risk_model_versions row and zero per-org trained models, so there's no fitted-model data at risk.
  • internal/api/auto_approve_policy_handlers.go / Settings UI: the Simulate endpoint now returns a summary (scored/skipped counts, how many scores are pinned at either extreme, score min/max/mean), rendered as a warning banner in Settings → Risk Model when a slider configuration is producing non-differentiating scores — the same signal that previously required hand-querying production to notice.

Part 6: Risk assessment header flickering between risk tiers on tab switch

Found while testing Part 5 in the browser: the always-visible "Risk assessment" header stat would briefly show one risk tier (e.g. "High Risk", red) then correct to a different one (e.g. "Moderate risk", amber) a moment after switching into the Findings tab.

Cause: the header stat is driven by a score that starts empty until the Findings tab's diff data loads. In between, it silently fell back to a structural-only blast-radius score (no finding severity folded in) to show something immediately, then recomputed once Findings handed over the real, finding-aware scores — which can land in a different tier. DiffViewerPanel had a smaller version of the same issue, reporting an empty score list before its own diff fetch had even resolved.

Fixed by distinguishing "not loaded yet" (null) from "loaded, confirmed zero" ([]) for the risk-scores state: the header now shows a neutral "Calculating..." state while the real score isn't ready yet, instead of guessing and correcting later.

Testing

  • go build ./... and go test ./internal/riskscore/... ./internal/policyengine/... ./internal/api/... (and the full repo suite) pass clean, including the new saturation/regression tests built from real production feature vectors.
  • npx tsc --noEmit passes clean after the Simulate-summary and risk-assessment-flicker UI changes.
  • The new migration was applied and verified locally (data-only, no schema change) — not run against production as part of this change.
  • Local security checks matching CI all pass clean: make security-osv, make security-govulncheck, make security-semgrep, make security-gitleaks. (OSV required two new ignore-list entries, config/osv-scanner.toml, for glob/minimatch CVEs disclosed in the last day with no patched release in the version lines this repo's dev tooling uses — confirmed dev-only via npm ls --omit=dev in both ui/ and extension/livereview/, so neither ever ships.)
  • Fixed two more test fixtures that built feature vectors by hand (bypassing ExtractFeatures) using pre-rescaling raw values: internal/riskscore/learning_cycle_test.go's br_sum component, and internal/policyengine/testdata/scenarios.json's two threshold-dial scenarios (their fixed blast_hunks.combined value scored just above the new model's recalibrated P80 reference cutoff instead of below it — lowered it so both land back in the "moderately risky" band their own rationale already describes).
  • Dispatched all four GitHub-required checks (govulncheck, gitleaks, osv-scan, semgrep/ci) against this branch via gh workflow run and confirmed green before merging.
  • Known pre-existing issue, not introduced by this PR and not a required check: TestAIMD_ConvergesToSecretCapacity (internal/llm/adaptivelimiter, added earlier in this same PR for unrelated LLM-call concurrency control) is flaky under go test ./... — converges to capacity 16 against a [2, 15] band in most local and CI runs, passing occasionally. Left as-is since fixing an unrelated concurrency-limiter's convergence tolerance is out of scope here; worth a follow-up.

Testing (Parts 1-4)

  • go build ./... and make testall (full repo Go test suite, matching CI's livereview-tests.yml) pass clean, including internal/pushnotify (claim-once race under concurrency, preference-filtering, empty-user-email skip, the new notification-content/label tests) and the new storage/license accounting regression test.
  • npx tsc --noEmit passes; full jest suite passes, including PushNotificationBanner's tests (eligibility/blocked-state/subscribe-failure-retry).
  • Local security checks matching CI all pass clean: make security-osv, make security-govulncheck, make security-semgrep, make security-gitleaks.
  • Verified end to end against a real deployment (livereview.hexmos.com): triggered a review, confirmed the notification arrives with the tab closed, confirmed the icon/body/click-through fixes in Part 4, confirmed the accounting-poll 500 is gone.

LiveReview auto-approval decisions

Review Time Decision Reason
#13545 31 Oct 2026, 15:14 UTC ✅ Approved All policy checks passed
#13543 31 Oct 2026, 10:12 UTC ❌ Not approved Risk score 1.00 (P100) is above the cutoff of 1.00 (P20)
#13542 31 Oct 2026, 09:57 UTC ❌ Not approved Risk score 1.00 (P100) is above the cutoff of 1.00 (P20); No diff classification available
#13539 31 Oct 2026, 08:35 UTC ❌ Not approved Risk score 1.00 (P100) is above the cutoff of 1.00 (P20)
#13528 30 Oct 2026, 19:23 UTC ❌ Not approved Risk score 1.00 (P100) is above the cutoff of 1.00 (P20)
#13527 30 Oct 2026, 18:30 UTC ❌ Not approved Risk score 1.00 (P100) is above the cutoff of 1.00 (P20)
#13526 30 Oct 2026, 18:07 UTC ❌ Not approved Risk score 1.00 (P100) is above the cutoff of 1.00 (P20)
#13523 30 Oct 2026, 17:10 UTC ❌ Not approved Risk score 1.00 (P100) is above the cutoff of 1.00 (P20)
#13522 30 Oct 2026, 15:13 UTC ❌ Not approved Risk score 1.00 (P100) is above the cutoff of 1.00 (P20)
#13521 30 Oct 2026, 14:52 UTC ❌ Not approved Risk score 1.00 (P100) is above the cutoff of 1.00 (P20)

shrsv added 5 commits October 30, 2026 12:56
…unning

GetDiffReviewStatus now resolves the diff/hunk structure even before a
review completes, and the webui polls batch events to merge in streamed
comments live, instead of hiding Findings behind the Events tab until the
whole review finishes.

LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%)
Notify the person who triggered a review, even with the tab or browser
closed, when the first finding is ready and again when the review
completes or fails - via Web Push + a service worker, opt-in from a toast
shown right after triggering a review.

LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%)
Catches up schema.sql with every migration already applied to the dev
database but never dumped (push notifications, plus the pre-existing
user time_zone migration), after fixing the local pg_dump/server version
mismatch by running pg_dump inside the matching-version Postgres container.

LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%)
dbmate dump refuses to run when the host's pg_dump is older than the
Postgres server (ours runs Postgres 18 in Docker, hosts often still have
an older postgresql-client). scripts/dbmate-dump.sh shims pg_dump to run
inside the livereview_pg container instead, so the dump always matches
the server without requiring a matching client install on every machine.

LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%)
…ia Docker

dbmate-dump.sh previously forwarded pg_dump into the livereview_pg
container to sidestep a host/server version mismatch. That made schema
regeneration depend on Docker being up. Replace it with a version check
that fails fast with install instructions (scripts/install-pgdg-client.sh,
installing the matching major from the official PGDG apt repo) when the
host's pg_dump is older than the server, so the fix is a one-time local
client upgrade rather than a per-dump workaround.

LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%)
@shrsv shrsv changed the title Stream findings into the Findings tab while a review is still running Stream findings live, and notify the triggering user via browser push Oct 30, 2026
@livereviewbot

Copy link
Copy Markdown

Add Push Notifications and Stream Review Findings

Overview

This change introduces browser push notifications for review events. It also enables real-time streaming of findings into the UI. New database tables, API endpoints, and a service worker support these features.

Technical Highlights

  • db/migrations: Adds push_subscriptions and user_notification_preferences tables.
  • internal/pushnotify: Implements Web Push API integration and VAPID key management.
  • internal/review_processor: Integrates ReviewNotifier for event-driven notifications.
  • internal/api/diff_review.go: Returns diff structure for in-progress reviews via GetDiffReviewStatus.
  • ui/public/sw.js: Implements a Service Worker for handling push notifications.
  • ui/src/components/reviews/diffviewer: Polls for and displays streamed review findings in real-time.
  • go.mod: Adds webpush-go library for Web Push Protocol.
  • Makefile, scripts: Adds db-dump target and PostgreSQL client version scripts.

Impact

  • Functionality: Users receive browser notifications and see findings incrementally in the UI.
  • Risk: GetDiffReviewStatus API behavior changed for non-completed reviews. Database schema update requires careful migration. New external dependency webpush-go is introduced.

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Ensure endpoint, p256dh, and auth_key are handled securely. While standard for web push, review overall data-at-rest encryption policies.

Suggestions:

  1. Confirm if these fields are encrypted at rest or if the push service provides sufficient security guarantees.
  2. Ensure these values are not logged excessively.

Comment thread db/schema.sql
default_org_id bigint,
review_level integer DEFAULT 1 NOT NULL
review_level integer DEFAULT 1 NOT NULL,
time_zone text

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

The time_zone column was added to the users table. This is a good addition for internationalization and personalized time display.

Suggestions:

  1. Consider how existing users will be migrated or assigned a default time zone.
  2. Ensure application logic correctly uses this time zone for display and operations.

return JSONErrorWithEnvelope(c, http.StatusNotFound, "review not found")
}

if reviewRecord.Status != "completed" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: critical

Removing this block changes the API contract for GetDiffReviewStatus. Previously, it returned a 200 OK with status and metadata for non-completed reviews.

Suggestions:

  1. Reintroduce the status check and early return for non-completed reviews if previous behavior is desired.
  2. Update API documentation to reflect this breaking change if the new behavior is intentional.
  3. Consider returning an appropriate HTTP error (e.g., 404 Not Found or 400 Bad Request) for non-completed reviews if the endpoint is now strictly for completed reviews.

return JSONErrorWithEnvelope(c, http.StatusNotFound, "review not found")
}

if reviewRecord.Status != "completed" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: critical

Without the early return, the code will now attempt to load diffFileRecords and findingRecords for non-completed reviews, which might be empty or incomplete.

Suggestions:

  1. Add a check for reviewRecord.Status == "completed" before attempting to load full review data.
  2. Ensure subsequent logic handles incomplete data gracefully if this new behavior is intended.

return JSONErrorWithEnvelope(c, http.StatusNotFound, "review not found")
}

if reviewRecord.Status != "completed" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

The logic for applying envelope usage, operation ID, and idempotency key from metadata for non-completed reviews is lost.

Suggestions:

  1. Re-evaluate if billing/accounting metadata should still be applied for non-completed review statuses.
  2. Restore the metadata application logic if it is still required for auditing or billing purposes.

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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

The UpdateReviewConnector call is a side effect within a function primarily designed for reading/resolving diffs. Consider if this backfill logic could be triggered elsewhere, or if resolvePreloadedChanges should be renamed to reflect its mutation aspect.

Suggestions:

  1. Extract the UpdateReviewConnector logic into a separate, explicit backfill or migration function that runs once per review, perhaps triggered by the initial review creation or a background job.
  2. Rename resolvePreloadedChanges to indicate it performs data mutations, e.g., ResolveAndBackfillReviewChanges.

}

userAgent := c.Request().UserAgent()
_, err = h.db.ExecContext(c.Request().Context(), `

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

The ON CONFLICT (endpoint) DO UPDATE SET user_id = EXCLUDED.user_id clause is a critical security and correctness feature. It ensures that if an endpoint is re-registered, it's correctly associated with the currently authenticated user, preventing a stale subscription from being tied to the wrong user.

return JSONErrorWithEnvelope(c, http.StatusBadRequest, "endpoint query param is required")
}

if _, err := h.db.ExecContext(c.Request().Context(), `

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Deleting by endpoint AND user_id is crucial for security, preventing one user from deleting another's push subscription.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Consider adding the reviewID and kind to the log message for better traceability if the context times out.

Suggestions:

  1. Modify the log message to include reviewID and kind from the outer scope.

}

type reviewInfo struct {
userEmail string

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

The orgID parameter is passed to NotifyReviewEvent but never used in dispatch or any other function within this package. Remove it.

Suggestions:

  1. Remove the orgID parameter from NotifyReviewEvent signature and its calls.

return "Your review"
}

func buildPayload(kind Kind, reviewID int64, info *reviewInfo, prodURL string) pushPayload {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

The prodURL parameter is retrieved from the database in dispatch then passed here. Consider passing n *Notifier directly or caching prodURL if it's static.

Suggestions:

  1. Pass the Notifier instance to buildPayload instead of prodURL to allow internal access to getProductionURL.
  2. Alternatively, consider caching prodURL in the Notifier struct if it's expected to be static.


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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

This function queries livereview_prod_url on every dispatch. If this URL is static, cache it in the Notifier struct to avoid repeated DB lookups.

Suggestions:

  1. Cache the livereview_prod_url in the Notifier struct after the first successful retrieval.

return nil
}

vapidPublic, vapidPrivate, err := loadOrCreateVAPIDKeys(ctx, n.db)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: critical

The loadOrCreateVAPIDKeys function is not defined in this file. Ensure it's part of the overall change and properly handles secure storage of the private key.

Suggestions:

  1. Ensure loadOrCreateVAPIDKeys is implemented in a separate, secure utility file and that the VAPID private key is stored encrypted or with appropriate access controls.

}

options := &webpush.Options{
Subscriber: "mailto:support@hexmos.com",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

The Subscriber email mailto:REDACTED_EMAIL is hardcoded. Consider making this configurable or deriving it from system settings.

Suggestions:

  1. Move the subscriber email to a configuration setting or retrieve it from instance_details table.

log.Printf("[WARN] push send failed for subscription %d: %v", sub.id, sendErr)
continue
}
resp.Body.Close()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: critical

The resp.Body.Close() call is inside the if sendErr != nil block. It should be deferred right after webpush.SendNotificationWithContext to ensure it's always closed.

Suggestions:

  1. Move defer resp.Body.Close() immediately after the webpush.SendNotificationWithContext call to ensure the response body is always closed, preventing potential resource leaks.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: critical

The GetVAPIDPublicKey function is not defined in the provided pushnotify.go file. This test implies its existence and functionality.

Suggestions:

  1. Ensure GetVAPIDPublicKey is defined and available, likely in a related VAPID utility file. If it's part of loadOrCreateVAPIDKeys, consider exposing it properly or testing loadOrCreateVAPIDKeys directly.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

Clarify that system_settings is for system-wide settings. VAPID keys are global, not user-specific.

Suggestions:

  1. Add 'system-wide' to the comment for clarity.


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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Consider adding a FOR UPDATE clause to the SELECT query if strong consistency is needed to prevent another transaction from inserting keys between the SELECT and a potential INSERT. However, given the ON CONFLICT DO NOTHING and re-read pattern, this might be overkill and could lead to deadlocks if not carefully managed.

Suggestions:

  1. Evaluate if FOR UPDATE is necessary for stronger consistency, or if the current ON CONFLICT and re-read is sufficient given the push_vapid key is immutable once set.

}
var keys vapidKeys
if err := json.Unmarshal(data, &keys); err != nil {
return nil, err

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Add context to the error message for JSON unmarshalling failures.

Suggestions:

  1. Change to fmt.Errorf("unmarshal VAPID keys: %w", err).

}
data, err := json.Marshal(vapidKeys{PublicKey: newPublic, PrivateKey: newPrivate})
if err != nil {
return "", "", err

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Add context to the error message for JSON marshalling failures.

Suggestions:

  1. Change to fmt.Errorf("marshal VAPID keys: %w", err).

// 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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

If comments is always []*models.ReviewComment, consider passing a len(comments) > 0 boolean directly to CreateBatchEvent instead of using reflection. This improves type safety and performance.

Suggestions:

  1. Refactor CreateBatchEvent to accept a hasComments boolean directly, avoiding reflection.
  2. Alternatively, if the concrete type is always []*models.ReviewComment, pass the slice directly and perform a type assertion inside hasComments.

if comments == nil {
return false
}
v := reflect.ValueOf(comments)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Using reflect.ValueOf(comments) on an interface{} can be slower and less type-safe than direct type checks. Ensure comments is consistently a slice type.

Suggestions:

  1. Add a comment explaining the expected types for comments to prevent misuse.
  2. Consider if a more specific interface or generic type could avoid reflection if comments is always a known slice type.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

Consider adding ctx to NotifyReviewEvent signature to allow for context-aware logging or tracing within the notifier implementation.

Suggestions:

  1. Update ReviewNotifier interface and NotifyReviewEvent to accept context.Context.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Perf settings are loaded after Upsert. If Load fails, the handler returns 500 after the preference was already persisted, so the client sees failure for a successful mutation. Load perf settings before Upsert, or make the write conditional on a successful load.

Suggestions:

  1. Move reviewperf.Load before store.Upsert.
  2. If a Load error occurs after Upsert, still return the saved state or explicitly indicate the save succeeded.

return JSONErrorWithEnvelope(c, http.StatusNotFound, "review not found")
}

if reviewRecord.Status != "completed" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Removing this branch sends non-completed reviews through the completed-review path. Ensure the remaining code branches on reviewRecord.Status and preserves failure_reason/message; otherwise pending/failed reviews are returned as completed-shaped responses. Keep an explicit status branch or add a regression test for queued/running/failed states.

Suggestions:

  1. Add an explicit status switch in GetDiffReviewStatus for non-completed and completed reviews.
  2. Add handler tests for pending, running, and failed reviews to verify response shape.

switch {
case err == nil:
t.limiter.ReportSuccess()
case errclass.Classify(t.providerType, err) == errclass.RateLimited:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Rate-limit classification depends on providerType, but SetLimiter does not enforce SetProviderType. An empty value silently skews limiter self-tuning.

Suggestions:

  1. Validate in Execute that providerType is non-empty whenever limiter is non-nil, or default to a safe classification.
  2. Document SetProviderType as required before SetLimiter is used.

// 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

The cancel goroutine is independent of the retry loop. If the scheduler delays it past 10ms, even fixed code can exceed 6 attempts and fail on a correct implementation.

Suggestions:

  1. Trigger cancellation deterministically, e.g. from a watcher that cancels once started reaches 3.
  2. Or make retry delay and attempts injectable with a fake clock so no real time is needed.

// 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

The 400ms wall-clock bound is tight for a shared CI runner. A scheduling pause can push elapsed time over the limit without a code regression.

Suggestions:

  1. Assert concurrency deterministically, e.g. record max simultaneous Execute calls and require it to reach 7.
  2. Or use a larger margin combined with a minimum-concurrency check.

@livereviewbot livereviewbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ LiveReview policy: Not approved (blocked at Risk score) · Review #13539

Stage Result Detail
Eligibility ✅ author level 1 >= required 1
Static rules ✅ no blocking rulesets configured
Risk score ❌ Risk score 1.00 (P100) is above the cutoff of 1.00 (P20)
LLM classification ✅ class "logic-change", confidence 9
Final decision ❌ risk: Risk score 1.00 (P100) is above the cutoff of 1.00 (P20)

…tale push-worker icon

Review-detail page:
- Elapsed timer no longer resets on page refresh (seed from review.startedAt
  instead of mount time).
- Add a live "Elapsed" header stat alongside "Last activity".
- Capture and show commit messages (new review_commits.message column,
  GitHub/GitLab providers now keep the message instead of discarding it).
- Events tab label shows a live event-count badge.
- Group duplicate circuit-breaker pills into one expandable pill per cause.
- Raw Events tab gets a "Tail Log" auto-scroll toggle.
- Findings tab shows total comment count above the summary panel.
- Fix "Batches: 0" getting stuck - the review summary now refreshes on the
  existing 15s poll instead of only fetching once on mount.

Reviews/PR tables:
- Add opt-in TanStack column resizing to ClientTable, used by the Reviews
  and Merge Requests tables, without changing existing visuals/layout.

Push notifications:
- Service worker now calls skipWaiting()/clients.claim() on install/activate
  so existing subscribers pick up sw.js changes (like the notification icon
  fix) without needing to fully close every open tab first.

LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%)
@livereviewbot

Copy link
Copy Markdown

Error generating summary: request timeout: API call exceeded deadline


⚠️ Partial Review Results

This review completed with partial results: 5 out of 7 batches processed successfully.

Failed batches: [batch-3 batch-6]

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

@livereviewbot livereviewbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ LiveReview policy: Not approved (blocked at Risk score) · Review #13542

Stage Result Detail
Eligibility ✅ author level 1 >= required 1
Static rules ✅ no blocking rulesets configured
Risk score ❌ Risk score 1.00 (P100) is above the cutoff of 1.00 (P20)
LLM classification ❌ no diff classification available
Final decision ❌ risk: Risk score 1.00 (P100) is above the cutoff of 1.00 (P20); llm: no diff classification available

@livereviewbot

Copy link
Copy Markdown

Error generating summary: request timeout: API call exceeded deadline

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

This behavior mirrors the langchain provider instead of reusing shared code; the two implementations can drift.

Suggestions:

  1. Extract partial-failure batch aggregation into a shared helper used by both gemini and langchain providers.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Per-batch error text is printed to stdout then dropped; the final warning/error only knows batch IDs. Callers cannot see why a batch failed.

Suggestions:

  1. Store per-batch failures as a slice of {ID, Err} and include the error text in the partial-failure warning and all-failed error.
  2. If a batch failed due to context cancellation, wrap/return ctx.Err so callers can distinguish cancellation from other failures.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

If batchInput.Batches is empty, this returns "all 0 batches failed" when there was nothing to process. Empty input should not be reported as a failure.

Suggestions:

  1. Handle len(batchInput.Batches) == 0 before this check, e.g., return an empty result or a clear no-op result.

switch {
case err == nil:
t.limiter.ReportSuccess()
case errclass.Classify(t.providerType, err) == errclass.RateLimited:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

An empty providerType silently reaches this classification when SetLimiter is used without SetProviderType. That can mislabel rate limits and skew limiter adaptation.

Suggestions:

  1. Validate in Execute that limiter != nil implies providerType is set, or log a warning once.
  2. Make providerType a required parameter of NewBatchTask or SetLimiter instead of a separate optional setter.

t.logger.Info("Processing batch task %s with %d files", t.id, len(t.batch))
}

if t.limiter != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

No test covers the new limiter path. Add a fake limiter covering Acquire failure, deferred release, and all three report branches.

Suggestions:

  1. Add a fake limiter capturing Acquire/Release/Report calls.
  2. Assert release happens on processor success and error, and that the correct report branch is chosen.

// 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

A 400ms wall-clock ceiling for 300ms of work is timing-flaky on loaded CI. Prefer deterministic concurrency assertions.

Suggestions:

  1. Assert that all tasks started before any slow task finishes, using sync primitives rather than elapsed time.
  2. If wall-clock timing is kept, increase the margin substantially or use a controllable fake clock.

// 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

This upper-bound check passes vacuously if cancellation happens before ProcessAll starts the task. Require evidence the task actually ran.

Suggestions:

  1. Assert started.Load() >= 1 to ensure the task began before cancellation.
  2. Use a channel to signal the first Execute, then cancel only after receiving it.

@livereviewbot livereviewbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ LiveReview policy: Not approved (blocked at Risk score) · Review #13543

Stage Result Detail
Eligibility ✅ author level 1 >= required 1
Static rules ✅ no blocking rulesets configured
Risk score ❌ Risk score 1.00 (P100) is above the cutoff of 1.00 (P20)
LLM classification ✅ class "logic-change", confidence 9
Final decision ❌ risk: Risk score 1.00 (P100) is above the cutoff of 1.00 (P20)

shrsv added 2 commits October 31, 2026 13:27
…r, and risk engine bugs

- Table resize/width/tooltip/header-centering fixes across Reviews, PR list,
  and Repositories (all on the TanStack resizable table now), plus
  search-icon focus on click.
- Move total comment count from the diff viewer panel up into the review
  header stats.
- Collapse duplicate circuit-breaker pills in the Events tab (group by
  failure type instead of exact message text, which never matched across
  Go's wrapped-error/cause logging).
- Make Merge Requests the primary/default Explore destination over
  Repositories (nav links, mega menu, in-page tabs, default route).
- Force-resolve stuck pending/in-progress stages in the review progress bar
  once the review's own status is terminal, so a completed review whose
  event history exceeds the frontend's fetch window doesn't show a stale
  in-progress stage.
- Fix the Auto-Approve risk score message: a tied score/cutoff no longer
  reads as "above", and the cutoff's percentile label is computed the same
  way as the score's (both via PercentileOf) instead of echoing the
  configured threshold, so the two numbers are directly comparable. Add a
  minimum-sample guard to the percentile cutoff calculation so a handful of
  historical scores can't produce a degenerate cutoff that collapses onto
  the distribution's max.
- Fix the Auto-Approve tab's "Configure" links not switching the target
  settings sub-tab when Settings is already mounted (stale one-shot
  useState instead of reacting to URL changes).

LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%)
…gmoid

br_sum (a raw SUM across all blast-radius hunks, real production values
1700-4840) was scored against a weight calibrated for an assumed 0-100
range, pushing the sigmoid input past the point float64 can represent
1+exp(-z) as anything but exactly 1.0. Every other unbounded count
feature (critical/warning/security counts, taxonomy counts) shared the
same structural flaw. Log1p-compress every unbounded feature, retune
Prior() weights to match, add a z-clamp safety net, add regression tests
built from real production feature vectors, add a migration to reset the
stale seed model, and surface saturation stats in the Simulate endpoint
and Settings UI so this is visible going forward instead of only
discoverable by hand-querying production.

LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%)
@livereviewbot

Copy link
Copy Markdown

Error generating summary: request timeout: API call exceeded deadline


⚠️ Partial Review Results

This review completed with partial results: 6 out of 7 batches processed successfully.

Failed batches: [batch-4]

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

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

A missing result from ProcessAll can mean the context was canceled or the queue stopped early, not that the batch failed. This branch can turn cancellation into a partial success when at least one batch completes.

Suggestions:

  1. Check ctx.Err() in the !ok branch and return it before treating the missing batch as a partial failure.
  2. Consider having ProcessAll return the cancellation cause so callers can distinguish abort from per-batch failure.

batchResult, ok := taskResult.Result.(*batch.BatchResult)
if !ok {
return nil, fmt.Errorf("invalid result type for batch %s", batchID)
failedBatches = append(failedBatches, batchID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Invalid result type is an internal invariant violation, not a transient LLM or parse failure. Skipping it can hide a programming bug and produce an incomplete review with only a warning.

Suggestions:

  1. Keep returning an error when taskResult.Result is not *batch.BatchResult; only taskResult.Error should be treated as a partial failure.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

An empty batch list also reaches this path and reports 'all 0 batches failed: []', turning a no-op review into an error.

Suggestions:

  1. If len(batchInput.Batches) == 0, return an empty result before this all-failed check.
  2. If ctx.Err() != nil, return ctx.Err() before returning this generic all-batches-failed error.

@livereviewbot livereviewbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LiveReview policy: Approved · Review #13545

Stage Result Detail
Eligibility ✅ author level 1 >= required 1
Static rules ✅ no blocking rulesets configured
Risk score ✅ Risk score 1.00 (P0) is below the cutoff of 1.00 (P100), configured for P20
LLM classification ✅ class "logic-change", confidence 9
Final decision ✅ approved

shrsv added 3 commits October 31, 2026 15:33
The "Risk assessment" header stat is visible on every tab, but its value
started from an empty/default score list until the Findings tab was
opened and its diff data finished loading. In between, riskAssessment()
silently fell back to a different, structural-only blast-radius score to
compute a confident tier label, then recomputed once Findings handed
over the real (finding-aware) scores -- visibly flashing e.g. "High Risk"
to "Moderate risk". DiffViewerPanel had a second, smaller version of the
same issue: it could report an empty score list before its own diff
fetch even resolved.

Distinguish "not loaded yet" (null) from "loaded, confirmed zero" ([])
for the risk-scores state, show a neutral "Calculating..." state while
the findings-aware score isn't ready instead of guessing, and don't
report scores out of DiffViewerPanel until its diff data has actually
loaded.

LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%)
… scan

GHSA-3g3m-2fjr-6pw4 (glob) and GHSA-g3hv-75wg-m249 (minimatch) were
disclosed in the last day and have no patched release in the version
lines this repo's overrides pin (glob 9.x-13.x, minimatch 3.x/9.x/10.x)
- only glob 14.0.2+/minimatch 11.0.1+ fix them. Every occurrence in
ui/package-lock.json and extension/livereview/package-lock.json is
dev-only build/test tooling (webpack plugins, eslint, jest, mocha,
@vscode/test-cli); `npm ls glob/minimatch --omit=dev` is empty in both,
so neither ever ships. Bumping either across its major version would
mean bumping every dependent toolchain package too, which is a separate
change from what this PR is about.

LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%)
internal/riskscore/learning_cycle_test.go built its br_sum feature
value by hand (bypassing ExtractFeatures), using the same raw-scale
convention ExtractFeatures used before the log1p rescaling fix. Fixed
the same way as the earlier referencemix.go/TestScoreOverrideEffect
fixes: apply math.Log1p to the br_sum component while leaving
br_max/br_weighted_avg raw (they were never log1p'd).

internal/policyengine/testdata/scenarios.json's two threshold-dial
scenarios used a blast_hunks combined value (8) that, under the
corrected model, now scores above their fixed P80 reference cutoff
instead of below it -- a real, intended effect of the recalibration
(log1p compression is milder at small-to-moderate magnitudes than it
is at the extreme values the retuned weights were calibrated against,
so a small-but-present blast radius now registers somewhat more than
before). Lowered combined to 3 so both scenarios land back in the
"moderately risky" ~0.6 score band their rationale already describes,
preserving the actual thing they test (same score, different
percentile dial, different outcome) rather than its incidental exact
number.

LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%)
@shrsv
shrsv merged commit f36cc8d into master Oct 31, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants