From 741aada0fde9ae132e1e17ec0b1dc58ffe19ebfd Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Fri, 30 Oct 2026 12:55:41 +0000 Subject: [PATCH 01/25] feat: stream findings into the Findings tab while a review is still running 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%) --- internal/api/diff_review.go | 159 ++++++------ .../reviews/diffviewer/DiffViewerPanel.tsx | 129 ++++++++-- .../diffviewer/reviewStreamState.test.ts | 135 ++++++++++ .../reviews/diffviewer/reviewStreamState.ts | 230 ++++++++++++++++++ ui/src/pages/Reviews/ReviewDetail.tsx | 17 +- 5 files changed, 563 insertions(+), 107 deletions(-) create mode 100644 ui/src/components/reviews/diffviewer/reviewStreamState.test.ts create mode 100644 ui/src/components/reviews/diffviewer/reviewStreamState.ts diff --git a/internal/api/diff_review.go b/internal/api/diff_review.go index dfbf9ae5..6d9dee60 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" @@ -166,43 +166,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 +186,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 +251,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/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx b/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx index 41ed7f68..f9e1b527 100644 --- a/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx +++ b/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx @@ -14,11 +14,19 @@ import React, { useEffect, useMemo, 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, + sinceWithOverlap, +} from './reviewStreamState'; import type { RiskScore } from '../RiskAssessment'; import { attachBlastData, buildBlastLookup, flattenFilesByRisk, hasBlastRadiusData, sortFilesByBlastRadius } from '../../../lib/blastRadius'; import { commentDomId, fileNavId, scrollElementIntoViewBelowStickyBars } from './diffUtils'; @@ -116,6 +124,14 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport 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); const blastLookup = useMemo(() => (blastReport ? buildBlastLookup(blastReport) : undefined), [blastReport]); const [sortMode, setSortMode] = useState('risk-flat'); const [expandedFiles, setExpandedFiles] = useState>({}); @@ -155,6 +171,9 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport let cancelled = false; setLoading(true); setError(null); + setStreamedComments([]); + seenEventIdsRef.current = new Set(); + lastEventTimeRef.current = null; getDiffReview(reviewId) .then((res) => { if (cancelled) return; @@ -182,7 +201,62 @@ 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); + 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. @@ -300,33 +374,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 +425,21 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport onHunkClick={jumpToHunk} />
+ {isRunning && ( + }> +

+ Findings are streaming in as each batch of files finishes — {navComments.length} so far. + This list 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}

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 00000000..2c262a6a --- /dev/null +++ b/ui/src/components/reviews/diffviewer/reviewStreamState.test.ts @@ -0,0 +1,135 @@ +import { + appendStreamedCommentsToFiles, + extractExternalCommentsFromEvents, + extractNewEvents, + inferReviewStatusFromEvents, + normalizeStreamedComment, + sinceWithOverlap, +} from './reviewStreamState'; +import { 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(); + }); +}); diff --git a/ui/src/components/reviews/diffviewer/reviewStreamState.ts b/ui/src/components/reviews/diffviewer/reviewStreamState.ts new file mode 100644 index 00000000..45cf6953 --- /dev/null +++ b/ui/src/components/reviews/diffviewer/reviewStreamState.ts @@ -0,0 +1,230 @@ +// 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; +} + +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/pages/Reviews/ReviewDetail.tsx b/ui/src/pages/Reviews/ReviewDetail.tsx index 655c3327..21c6857e 100644 --- a/ui/src/pages/Reviews/ReviewDetail.tsx +++ b/ui/src/pages/Reviews/ReviewDetail.tsx @@ -119,7 +119,6 @@ const ReviewDetail: React.FC = () => { }; }, [reviewId]); const pollingIntervalRef = useRef(null); - const defaultedTabForReviewRef = useRef(null); const getEventIcon = (type: string, level?: string) => { @@ -268,18 +267,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); From eb6f474bf3b34d85b7cc11fb6c5e741df7dc58e0 Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Fri, 30 Oct 2026 14:08:23 +0000 Subject: [PATCH 02/25] feat: browser push notifications for review progress 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%) --- ...261030134721_create_push_notifications.sql | 35 ++ docs/audit-events.md | 3 + go.mod | 1 + go.sum | 40 +++ internal/api/polling_event_service.go | 28 +- internal/api/push_handlers.go | 174 ++++++++++ internal/api/server.go | 35 ++ internal/auditlog/catalog.go | 7 + .../routes_guide/settings/notifications.md | 27 ++ .../settings/settings-overview.md | 2 +- internal/pushnotify/pushnotify.go | 301 ++++++++++++++++++ internal/pushnotify/pushnotify_test.go | 219 +++++++++++++ internal/pushnotify/vapid.go | 83 +++++ internal/review_processor/events.go | 26 +- internal/review_processor/notifier.go | 47 +++ internal/review_processor/reviews.go | 18 +- ui/public/sw.js | 47 +++ ui/src/App.tsx | 2 + ui/src/api/push.ts | 32 ++ ui/src/components/Navbar/megaMenuData.ts | 1 + .../PushNotificationPrompt.test.tsx | 64 ++++ ui/src/components/PushNotificationPrompt.tsx | 33 ++ ui/src/pages/Reviews/NewReview.tsx | 18 ++ ui/src/pages/Settings/NotificationsTab.tsx | 139 ++++++++ ui/src/pages/Settings/Settings.tsx | 5 + ui/src/store/Notifications/types.ts | 3 +- ui/src/utils/notify.ts | 3 + ui/src/utils/pushNotifications.test.ts | 104 ++++++ ui/src/utils/pushNotifications.ts | 74 +++++ 29 files changed, 1566 insertions(+), 5 deletions(-) create mode 100644 db/migrations/20261030134721_create_push_notifications.sql create mode 100644 internal/api/push_handlers.go create mode 100644 internal/docindex/docs/routes_guide/settings/notifications.md create mode 100644 internal/pushnotify/pushnotify.go create mode 100644 internal/pushnotify/pushnotify_test.go create mode 100644 internal/pushnotify/vapid.go create mode 100644 internal/review_processor/notifier.go create mode 100644 ui/public/sw.js create mode 100644 ui/src/api/push.ts create mode 100644 ui/src/components/PushNotificationPrompt.test.tsx create mode 100644 ui/src/components/PushNotificationPrompt.tsx create mode 100644 ui/src/pages/Settings/NotificationsTab.tsx create mode 100644 ui/src/utils/pushNotifications.test.ts create mode 100644 ui/src/utils/pushNotifications.ts diff --git a/db/migrations/20261030134721_create_push_notifications.sql b/db/migrations/20261030134721_create_push_notifications.sql new file mode 100644 index 00000000..033238ba --- /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/docs/audit-events.md b/docs/audit-events.md index 0d02adbb..55b2bf9e 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 cdf4b81b..2619cd34 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 d1382e67..a93e8817 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/api/polling_event_service.go b/internal/api/polling_event_service.go index d495ef43..ac06064c 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 00000000..d2913f19 --- /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/server.go b/internal/api/server.go index 6f381bf7..41081e9a 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" @@ -455,6 +456,13 @@ func NewServer(port int, versionInfo *VersionInfo) (*Server, error) { return nil, 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. + reviewprocessor.SetNotifier(pushnotify.New(server.db)) + e := echo.New() e.HideBanner = true @@ -1330,6 +1338,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) diff --git a/internal/auditlog/catalog.go b/internal/auditlog/catalog.go index 14a05d88..ea06487d 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/docindex/docs/routes_guide/settings/notifications.md b/internal/docindex/docs/routes_guide/settings/notifications.md new file mode 100644 index 00000000..0830e38d --- /dev/null +++ b/internal/docindex/docs/routes_guide/settings/notifications.md @@ -0,0 +1,27 @@ +# 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 after triggering a review, via + an "Enable" button on the toast that appears — no need to visit this page first. +- 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. + +## 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 add9dd3d..14c457ea 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/pushnotify/pushnotify.go b/internal/pushnotify/pushnotify.go new file mode 100644 index 00000000..0d46d749 --- /dev/null +++ b/internal/pushnotify/pushnotify.go @@ -0,0 +1,301 @@ +// 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 +} + +func (n *Notifier) loadReviewInfo(ctx context.Context, reviewID int64) (*reviewInfo, error) { + var info reviewInfo + var friendlyName, prMrURL sql.NullString + err := n.db.QueryRowContext(ctx, ` + SELECT user_email, friendly_name, repository, pr_mr_url + FROM reviews WHERE id = $1 + `, reviewID).Scan(&info.userEmail, &friendlyName, &info.repository, &prMrURL) + if err != nil { + return nil, fmt.Errorf("load review %d: %w", reviewID, err) + } + info.friendlyName = friendlyName.String + info.prMrURL = prMrURL.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"` +} + +func reviewLabel(info *reviewInfo) string { + if info.friendlyName != "" { + return info.friendlyName + } + if info.repository != "" { + return info.repository + } + return "Your review" +} + +func buildPayload(kind Kind, reviewID int64, info *reviewInfo, prodURL string) pushPayload { + label := reviewLabel(info) + var p pushPayload + switch kind { + case KindFirstFinding: + p = pushPayload{Title: "First finding ready", Body: fmt.Sprintf("%s — the first results are in.", label)} + case KindFailed: + p = pushPayload{Title: "Review failed", Body: fmt.Sprintf("%s did not complete.", label)} + default: + p = pushPayload{Title: "Review completed", Body: fmt.Sprintf("%s is ready to view.", label)} + } + 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 00000000..95b27354 --- /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 00000000..dea14c33 --- /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_processor/events.go b/internal/review_processor/events.go index 69eb4077..55984304 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 00000000..28056aa2 --- /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 c9eeb01a..26b33f83 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/ui/public/sw.js b/ui/public/sw.js new file mode 100644 index 00000000..b3c5841a --- /dev/null +++ b/ui/public/sw.js @@ -0,0 +1,47 @@ +// 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. + +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 }, + icon: '/favicon.ico', + }; + + 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/App.tsx b/ui/src/App.tsx index f6d4c8f5..a84bd7e9 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -2,6 +2,7 @@ import React, { Suspense, useEffect, useState } from 'react'; import { HashRouter as Router, Routes, Route, Navigate, useNavigate, useLocation, Link } from 'react-router-dom'; import { Navbar } from './components/Navbar/Navbar'; import { URLMismatchBanner } from './components/URLMismatchBanner'; +import { PushNotificationPrompt } from './components/PushNotificationPrompt'; import { useSyncTimeZone } from './hooks/useSyncTimeZone'; import { useAppDispatch, useAppSelector } from './store/configureStore'; import { logout, checkSetupStatus, fetchUser } from './store/Auth/reducer'; @@ -317,6 +318,7 @@ const AppContent: React.FC = () => { onLogout={handleLogout} /> + {!isCloudMode() && !location.pathname.startsWith('/chat') && dispatch(openLicenseModal())} />}
}> diff --git a/ui/src/api/push.ts b/ui/src/api/push.ts new file mode 100644 index 00000000..5fd4bf40 --- /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/components/Navbar/megaMenuData.ts b/ui/src/components/Navbar/megaMenuData.ts index 97000bea..714f3a29 100644 --- a/ui/src/components/Navbar/megaMenuData.ts +++ b/ui/src/components/Navbar/megaMenuData.ts @@ -227,6 +227,7 @@ export const buildMegaMenuSections = (): MegaMenuSection[] => [ link('Profile and Preferences', React.createElement(Icons.User), '/settings#account'), link('Change Password', React.createElement(Icons.Settings), '/settings#account'), link('My API Keys', React.createElement(Icons.Settings), '/settings#api-keys'), + link('Notifications', React.createElement(Icons.Bell), '/settings#notifications'), ], React.createElement(Icons.User)), group('Manage Team', [ link('View Users', React.createElement(Icons.List), '/settings#users', (ctx) => ctx.isSuperAdmin || ctx.hasOrg), diff --git a/ui/src/components/PushNotificationPrompt.test.tsx b/ui/src/components/PushNotificationPrompt.test.tsx new file mode 100644 index 00000000..e2e94f8f --- /dev/null +++ b/ui/src/components/PushNotificationPrompt.test.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { PushNotificationPrompt, PUSH_ENABLE_ACTION_ID } from './PushNotificationPrompt'; +import { runNotificationAction } from '../store/Notifications/actionRegistry'; +import { subscribe } from '../utils/pushNotifications'; +import { notify } from '../utils/notify'; + +jest.mock('../utils/pushNotifications', () => ({ + subscribe: jest.fn(), +})); +jest.mock('../utils/notify', () => ({ + notify: { success: jest.fn(), error: jest.fn() }, +})); + +describe('PushNotificationPrompt', () => { + const originalNotification = (global as any).Notification; + + beforeEach(() => { + (global as any).Notification = { requestPermission: jest.fn() }; + }); + + afterEach(() => { + (global as any).Notification = originalNotification; + jest.clearAllMocks(); + }); + + it('subscribes and shows a success toast when permission is granted', async () => { + (Notification.requestPermission as jest.Mock).mockResolvedValue('granted'); + (subscribe as jest.Mock).mockResolvedValue(undefined); + + render(); + await runNotificationActionAsync(); + + expect(subscribe).toHaveBeenCalled(); + expect(notify.success).toHaveBeenCalled(); + }); + + it('does not subscribe when permission is denied', async () => { + (Notification.requestPermission as jest.Mock).mockResolvedValue('denied'); + + render(); + await runNotificationActionAsync(); + + expect(subscribe).not.toHaveBeenCalled(); + expect(notify.success).not.toHaveBeenCalled(); + }); + + it('shows an error toast when subscribing fails', async () => { + (Notification.requestPermission as jest.Mock).mockResolvedValue('granted'); + (subscribe as jest.Mock).mockRejectedValue(new Error('boom')); + + render(); + await runNotificationActionAsync(); + + expect(notify.error).toHaveBeenCalled(); + }); +}); + +async function runNotificationActionAsync() { + runNotificationAction(PUSH_ENABLE_ACTION_ID); + // Flush the microtask queue so the async handler completes. + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} diff --git a/ui/src/components/PushNotificationPrompt.tsx b/ui/src/components/PushNotificationPrompt.tsx new file mode 100644 index 00000000..56639c46 --- /dev/null +++ b/ui/src/components/PushNotificationPrompt.tsx @@ -0,0 +1,33 @@ +import React, { useEffect } from 'react'; +import { registerNotificationAction } from '../store/Notifications/actionRegistry'; +import { subscribe } from '../utils/pushNotifications'; +import { notify } from '../utils/notify'; + +export const PUSH_ENABLE_ACTION_ID = 'push-notify:enable'; + +// Headless (renders nothing) — mounted once at the app root (App.tsx), same +// pattern as URLMismatchBanner/LicenseStatusBar. Registers the handler the +// "Enable" button on the post-trigger-review toast calls, so granting +// permission happens right there without navigating to Settings. +export const PushNotificationPrompt: React.FC = () => { + useEffect(() => { + const unregister = registerNotificationAction(PUSH_ENABLE_ACTION_ID, async () => { + try { + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + return; + } + await subscribe(); + notify.success("You're set — we'll notify you about your reviews."); + } catch (err) { + console.error('Failed to enable push notifications:', err); + notify.error('Could not enable notifications. Please try again from Settings.'); + } + }); + return unregister; + }, []); + + return null; +}; + +export default PushNotificationPrompt; diff --git a/ui/src/pages/Reviews/NewReview.tsx b/ui/src/pages/Reviews/NewReview.tsx index 8c82782d..4c00818e 100644 --- a/ui/src/pages/Reviews/NewReview.tsx +++ b/ui/src/pages/Reviews/NewReview.tsx @@ -25,6 +25,8 @@ import GitHubAppNotInstalledModal from '../../components/GitHubApp/GitHubAppNotI import { getGitHubAppInstallCheck, startGitHubAppConnect } from '../../api/githubApp'; import { notify } from '../../utils/notify'; import { useGitHubAppEnabled } from '../../hooks/useGitHubAppEnabled'; +import { isPushSupported } from '../../utils/pushNotifications'; +import { PUSH_ENABLE_ACTION_ID } from '../../components/PushNotificationPrompt'; type QuotaStatusResponse = { can_trigger_reviews: boolean; @@ -182,6 +184,21 @@ const NewReview: React.FC = () => { return () => clearTimeout(timer); }, [url]); + // Right after a review is successfully triggered — not before, not on an + // unrelated page — offer to enable browser push notifications, with the + // permission grant itself happening inline via the toast's action button + // (see PushNotificationPrompt), no navigation to Settings required. + const maybePromptForPushNotifications = () => { + if (!isPushSupported() || typeof Notification === 'undefined' || Notification.permission !== 'default') { + return; + } + notify.info("Want a browser notification when this review's findings are ready?", { + source: 'push-notify', + dedupeKey: 'push-notify-prompt', + actions: [{ label: 'Enable', actionId: PUSH_ENABLE_ACTION_ID }], + }); + }; + // Shared plan/quota gating and error handling for both ways of starting a review. const startReview = async (run: () => Promise<{ reviewId: string }>) => { setError(null); @@ -197,6 +214,7 @@ const NewReview: React.FC = () => { setLoading(true); try { const response = await run(); + maybePromptForPushNotifications(); navigate(`/reviews/${response.reviewId}`); } catch (err: any) { console.error('Error triggering review:', err); diff --git a/ui/src/pages/Settings/NotificationsTab.tsx b/ui/src/pages/Settings/NotificationsTab.tsx new file mode 100644 index 00000000..fbe40156 --- /dev/null +++ b/ui/src/pages/Settings/NotificationsTab.tsx @@ -0,0 +1,139 @@ +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 supported = isPushSupported(); + + 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') { + notify.error('Notifications are blocked for this site in your browser settings.'); + return; + } + const permission = await Notification.requestPermission(); + if (permission !== '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). +

+ ) : ( + + )} +
+ + +

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 2af4192c..07b1aa3f 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/store/Notifications/types.ts b/ui/src/store/Notifications/types.ts index df869682..f73a4ebc 100644 --- a/ui/src/store/Notifications/types.ts +++ b/ui/src/store/Notifications/types.ts @@ -8,7 +8,8 @@ export type NotificationSource = | 'demo' | 'url-mismatch' | 'system' - | 'toast-migrated'; + | 'toast-migrated' + | 'push-notify'; export interface NotificationAction { label: string; diff --git a/ui/src/utils/notify.ts b/ui/src/utils/notify.ts index 953e13ce..113ab0d0 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 00000000..f807cc62 --- /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 00000000..3ed695b8 --- /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); +} From c032dbfdd101c9fd882457d7807bf5f5c6fe37ca Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Fri, 30 Oct 2026 14:12:00 +0000 Subject: [PATCH 03/25] chore: regenerate db/schema.sql 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%) --- db/schema.sql | 109 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/db/schema.sql b/db/schema.sql index 294a1b7d..8aae56ae 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -2467,6 +2467,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: - -- @@ -3032,6 +3067,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 +4059,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 +4143,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 +4533,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 +5276,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 +5764,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 +7492,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 +9855,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 +10351,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 +10593,9 @@ INSERT INTO public.schema_migrations (version) VALUES ('20261025120100'), ('20261025130000'), ('20261029104047'), + ('20261029120000'), ('20261029134500'), ('20261029150000'), ('20261029150100'), - ('20261029150200'); + ('20261029150200'), + ('20261030134721'); From 1aeea98261ab23fc2d593daf5faef76dfb694953 Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Fri, 30 Oct 2026 14:13:20 +0000 Subject: [PATCH 04/25] chore: add make db-dump to fix host/server pg_dump version mismatches 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%) --- Makefile | 9 ++++++++- scripts/dbmate-dump.sh | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100755 scripts/dbmate-dump.sh diff --git a/Makefile b/Makefile index 03687556..9e11028a 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/scripts/dbmate-dump.sh b/scripts/dbmate-dump.sh new file mode 100755 index 00000000..2d7363bd --- /dev/null +++ b/scripts/dbmate-dump.sh @@ -0,0 +1,32 @@ +#!/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 version is older than the Postgres server's (pg_dump +# "aborting because of server version mismatch"). The local dev Postgres runs +# in Docker (see scripts/pgctl.sh, container "livereview_pg") on whatever +# major version PG_VERSION there is pinned to - the host's own +# postgresql-client package commonly lags behind that. Rather than requiring +# a matching postgresql-client install on every machine, this shims `pg_dump` +# for the duration of the dump to forward into the running container, whose +# pg_dump always matches its own server exactly. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +PG_CONTAINER_NAME="${PG_CONTAINER_NAME:-livereview_pg}" + +if ! docker ps --format '{{.Names}}' | grep -qx "$PG_CONTAINER_NAME"; then + echo "ERROR: Postgres container '$PG_CONTAINER_NAME' is not running (see scripts/pgctl.sh)." >&2 + exit 1 +fi + +SHIM_DIR="$(mktemp -d)" +trap 'rm -rf "$SHIM_DIR"' EXIT + +cat > "$SHIM_DIR/pg_dump" < Date: Fri, 30 Oct 2026 14:26:45 +0000 Subject: [PATCH 05/25] fix: check local pg_dump against server version instead of shimming via 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%) --- db/schema.sql | 2 +- scripts/dbmate-dump.sh | 64 ++++++++++++++++++++++++---------- scripts/install-pgdg-client.sh | 33 ++++++++++++++++++ 3 files changed, 79 insertions(+), 20 deletions(-) create mode 100755 scripts/install-pgdg-client.sh diff --git a/db/schema.sql b/db/schema.sql index 8aae56ae..0d0fd136 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; diff --git a/scripts/dbmate-dump.sh b/scripts/dbmate-dump.sh index 2d7363bd..c34038ff 100755 --- a/scripts/dbmate-dump.sh +++ b/scripts/dbmate-dump.sh @@ -1,32 +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 version is older than the Postgres server's (pg_dump -# "aborting because of server version mismatch"). The local dev Postgres runs -# in Docker (see scripts/pgctl.sh, container "livereview_pg") on whatever -# major version PG_VERSION there is pinned to - the host's own -# postgresql-client package commonly lags behind that. Rather than requiring -# a matching postgresql-client install on every machine, this shims `pg_dump` -# for the duration of the dump to forward into the running container, whose -# pg_dump always matches its own server exactly. +# 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)" -PG_CONTAINER_NAME="${PG_CONTAINER_NAME:-livereview_pg}" +if [ -f .env ]; then + export $(grep -v '^#' .env | xargs) +fi -if ! docker ps --format '{{.Names}}' | grep -qx "$PG_CONTAINER_NAME"; then - echo "ERROR: Postgres container '$PG_CONTAINER_NAME' is not running (see scripts/pgctl.sh)." >&2 +if [ -z "${DATABASE_URL:-}" ]; then + echo "ERROR: DATABASE_URL not set (expected in .env)." >&2 exit 1 fi -SHIM_DIR="$(mktemp -d)" -trap 'rm -rf "$SHIM_DIR"' EXIT +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 -cat > "$SHIM_DIR/pg_dump" </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)" From ddb43fe25c3d1adc6090deb4208a769313e6e8bd Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Fri, 30 Oct 2026 14:58:25 +0000 Subject: [PATCH 06/25] fix: show the push-notification prompt from every review-trigger path The toast was only wired into the "Create Review" form's startReview flow. The "Start a review" link posted on PRs/MRs lands on a separate component (PullRequestReviewTrigger), and the Explore > Pull/Merge Requests list triggers reviews independently too - neither showed the prompt. Extract the check into a shared maybePromptForPushNotifications() helper and call it from all three trigger paths. LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%) --- internal/docindex/docs/.synced-commits.env | 2 +- ui/src/components/PushNotificationPrompt.tsx | 4 ++-- ui/src/pages/Explore/MergeRequests.tsx | 2 ++ ui/src/pages/Reviews/NewReview.tsx | 18 +-------------- .../Reviews/PullRequestReviewTrigger.tsx | 2 ++ ui/src/utils/pushNotifications.ts | 22 +++++++++++++++++++ 6 files changed, 30 insertions(+), 20 deletions(-) diff --git a/internal/docindex/docs/.synced-commits.env b/internal/docindex/docs/.synced-commits.env index b7548fe7..eafc690f 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/ui/src/components/PushNotificationPrompt.tsx b/ui/src/components/PushNotificationPrompt.tsx index 56639c46..ac24c5ae 100644 --- a/ui/src/components/PushNotificationPrompt.tsx +++ b/ui/src/components/PushNotificationPrompt.tsx @@ -1,9 +1,9 @@ import React, { useEffect } from 'react'; import { registerNotificationAction } from '../store/Notifications/actionRegistry'; -import { subscribe } from '../utils/pushNotifications'; +import { subscribe, PUSH_ENABLE_ACTION_ID } from '../utils/pushNotifications'; import { notify } from '../utils/notify'; -export const PUSH_ENABLE_ACTION_ID = 'push-notify:enable'; +export { PUSH_ENABLE_ACTION_ID }; // Headless (renders nothing) — mounted once at the app root (App.tsx), same // pattern as URLMismatchBanner/LicenseStatusBar. Registers the handler the diff --git a/ui/src/pages/Explore/MergeRequests.tsx b/ui/src/pages/Explore/MergeRequests.tsx index e96927d1..622adcd3 100644 --- a/ui/src/pages/Explore/MergeRequests.tsx +++ b/ui/src/pages/Explore/MergeRequests.tsx @@ -3,6 +3,7 @@ import ProviderIcon from '../../components/ProviderIcon'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { ColumnDef, useReactTable, getCoreRowModel, getFilteredRowModel, getSortedRowModel, getPaginationRowModel } from '@tanstack/react-table'; import { notify } from '../../utils/notify'; +import { maybePromptForPushNotifications } from '../../utils/pushNotifications'; import { LuSearch } from 'react-icons/lu'; import { Button, Icons, Tooltip, Input, MultiSelectPanel } from '../../components/UIPrimitives'; import { ClientTable } from '../../components/DataTable/ClientTable'; @@ -139,6 +140,7 @@ const MergeRequests: React.FC = () => { setTriggeringId(pr.id); try { const response = await triggerReviewForPullRequest(pr.id); + maybePromptForPushNotifications(); navigate(`/reviews/${response.reviewId}`); } catch (err) { console.error('Failed to trigger review:', err); diff --git a/ui/src/pages/Reviews/NewReview.tsx b/ui/src/pages/Reviews/NewReview.tsx index 4c00818e..1bd88d00 100644 --- a/ui/src/pages/Reviews/NewReview.tsx +++ b/ui/src/pages/Reviews/NewReview.tsx @@ -25,8 +25,7 @@ import GitHubAppNotInstalledModal from '../../components/GitHubApp/GitHubAppNotI import { getGitHubAppInstallCheck, startGitHubAppConnect } from '../../api/githubApp'; import { notify } from '../../utils/notify'; import { useGitHubAppEnabled } from '../../hooks/useGitHubAppEnabled'; -import { isPushSupported } from '../../utils/pushNotifications'; -import { PUSH_ENABLE_ACTION_ID } from '../../components/PushNotificationPrompt'; +import { maybePromptForPushNotifications } from '../../utils/pushNotifications'; type QuotaStatusResponse = { can_trigger_reviews: boolean; @@ -184,21 +183,6 @@ const NewReview: React.FC = () => { return () => clearTimeout(timer); }, [url]); - // Right after a review is successfully triggered — not before, not on an - // unrelated page — offer to enable browser push notifications, with the - // permission grant itself happening inline via the toast's action button - // (see PushNotificationPrompt), no navigation to Settings required. - const maybePromptForPushNotifications = () => { - if (!isPushSupported() || typeof Notification === 'undefined' || Notification.permission !== 'default') { - return; - } - notify.info("Want a browser notification when this review's findings are ready?", { - source: 'push-notify', - dedupeKey: 'push-notify-prompt', - actions: [{ label: 'Enable', actionId: PUSH_ENABLE_ACTION_ID }], - }); - }; - // Shared plan/quota gating and error handling for both ways of starting a review. const startReview = async (run: () => Promise<{ reviewId: string }>) => { setError(null); diff --git a/ui/src/pages/Reviews/PullRequestReviewTrigger.tsx b/ui/src/pages/Reviews/PullRequestReviewTrigger.tsx index f40fc1ef..1832b0d6 100644 --- a/ui/src/pages/Reviews/PullRequestReviewTrigger.tsx +++ b/ui/src/pages/Reviews/PullRequestReviewTrigger.tsx @@ -6,6 +6,7 @@ import { connectGitHubInstallation } from '../../api/githubApp'; import { ConnectRequiredError, useSwitchToPullRequestOrg, PR_NOT_FOUND_MESSAGE } from '../../hooks/useSwitchToPullRequestOrg'; import { notify } from '../../utils/notify'; import { GEMINI_API_KEYS_URL, isMissingAIProviderError } from '../../utils/aiProviderErrors'; +import { maybePromptForPushNotifications } from '../../utils/pushNotifications'; // After connecting a GitHub account, how long to wait for its repositories (and so this PR) to sync. const SYNC_POLL_MS = 3000; @@ -34,6 +35,7 @@ const PullRequestReviewTrigger: React.FC = () => { try { const orgPullRequestId = await switchToPullRequestOrg(pullRequestId); const res = await startOrReuseReviewForPullRequest(orgPullRequestId); + maybePromptForPushNotifications(); navigate(`/reviews/${res.reviewId}`, { replace: true }); } catch (err: any) { // By name, not instanceof: transpiled Error subclasses don't always keep their prototype. diff --git a/ui/src/utils/pushNotifications.ts b/ui/src/utils/pushNotifications.ts index 3ed695b8..28e162f9 100644 --- a/ui/src/utils/pushNotifications.ts +++ b/ui/src/utils/pushNotifications.ts @@ -1,6 +1,9 @@ // 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'; +import { notify } from './notify'; + +export const PUSH_ENABLE_ACTION_ID = 'push-notify:enable'; export function isPushSupported(): boolean { return ( @@ -72,3 +75,22 @@ export async function unsubscribe(): Promise { await subscription.unsubscribe(); await deletePushSubscription(endpoint); } + +// Call this right after ANY code path successfully triggers a review - +// there are several (the "Create Review" form, the "Start a review" link +// posted on PRs/MRs, the Explore > Pull/Merge Requests list) and this must +// not be missed from any of them. Offers to enable browser push +// notifications via a toast with an inline "Enable" action button (handled +// by PushNotificationPrompt, mounted once at the app root) - no navigation +// to Settings required. No-ops if push isn't supported, or the browser has +// already been asked (permission is 'granted' or 'denied'). +export function maybePromptForPushNotifications(): void { + if (!isPushSupported() || typeof Notification === 'undefined' || Notification.permission !== 'default') { + return; + } + notify.info("Want a browser notification when this review's findings are ready?", { + source: 'push-notify', + dedupeKey: 'push-notify-prompt', + actions: [{ label: 'Enable', actionId: PUSH_ENABLE_ACTION_ID }], + }); +} From ef27dacef867ceb07bd14f99e2ad0bda3cb12451 Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Fri, 30 Oct 2026 15:19:21 +0000 Subject: [PATCH 07/25] fix: replace the push-notify toast with an in-page review banner The generic toast system (ToastBridge/react-hot-toast) never rendered action buttons - only the notification tray dropdown did - so the "Enable" CTA was invisible, and the toast itself auto-dismissed after 4s as a floating corner overlay with no close button. Replace it with PushNotificationBanner: a dismissible Alert rendered inline on the review detail page (above the tabs), with the Enable CTA and a close button both part of the banner itself, and no auto-dismiss timer. A close is remembered via localStorage so it doesn't nag on every visit. Removes the now-unused toast-action wiring (PushNotificationPrompt, the per-trigger-site notify.info calls, the 'push-notify' toast source). LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%) --- .../routes_guide/reviews/review-detail.md | 3 + .../routes_guide/settings/notifications.md | 5 +- ui/src/App.tsx | 2 - .../PushNotificationPrompt.test.tsx | 64 ---------------- ui/src/components/PushNotificationPrompt.tsx | 33 -------- .../reviews/PushNotificationBanner.test.tsx | 76 +++++++++++++++++++ .../reviews/PushNotificationBanner.tsx | 71 +++++++++++++++++ ui/src/components/reviews/index.ts | 3 +- ui/src/pages/Explore/MergeRequests.tsx | 2 - ui/src/pages/Reviews/NewReview.tsx | 2 - .../Reviews/PullRequestReviewTrigger.tsx | 2 - ui/src/pages/Reviews/ReviewDetail.tsx | 4 +- ui/src/store/Notifications/types.ts | 3 +- ui/src/utils/pushNotifications.ts | 22 ------ 14 files changed, 159 insertions(+), 133 deletions(-) delete mode 100644 ui/src/components/PushNotificationPrompt.test.tsx delete mode 100644 ui/src/components/PushNotificationPrompt.tsx create mode 100644 ui/src/components/reviews/PushNotificationBanner.test.tsx create mode 100644 ui/src/components/reviews/PushNotificationBanner.tsx diff --git a/internal/docindex/docs/routes_guide/reviews/review-detail.md b/internal/docindex/docs/routes_guide/reviews/review-detail.md index faeebf1c..41230495 100644 --- a/internal/docindex/docs/routes_guide/reviews/review-detail.md +++ b/internal/docindex/docs/routes_guide/reviews/review-detail.md @@ -24,6 +24,9 @@ Any authenticated org member; the review must belong to their org (`getReviewAccounting`). - View the commits included in this review (up to a preview limit, expandable). - Navigate back to the reviews list. +- 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). ## When comments couldn't be posted to the PR diff --git a/internal/docindex/docs/routes_guide/settings/notifications.md b/internal/docindex/docs/routes_guide/settings/notifications.md index 0830e38d..29fe2cee 100644 --- a/internal/docindex/docs/routes_guide/settings/notifications.md +++ b/internal/docindex/docs/routes_guide/settings/notifications.md @@ -15,8 +15,9 @@ 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 after triggering a review, via - an "Enable" button on the toast that appears — no need to visit this page first. + Granting permission can also happen inline, right on a review's detail page, via + an "Enable notifications" banner shown there (closeable, not auto-dismissed) — + no need to visit this page first. - 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. diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a84bd7e9..f6d4c8f5 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -2,7 +2,6 @@ import React, { Suspense, useEffect, useState } from 'react'; import { HashRouter as Router, Routes, Route, Navigate, useNavigate, useLocation, Link } from 'react-router-dom'; import { Navbar } from './components/Navbar/Navbar'; import { URLMismatchBanner } from './components/URLMismatchBanner'; -import { PushNotificationPrompt } from './components/PushNotificationPrompt'; import { useSyncTimeZone } from './hooks/useSyncTimeZone'; import { useAppDispatch, useAppSelector } from './store/configureStore'; import { logout, checkSetupStatus, fetchUser } from './store/Auth/reducer'; @@ -318,7 +317,6 @@ const AppContent: React.FC = () => { onLogout={handleLogout} /> - {!isCloudMode() && !location.pathname.startsWith('/chat') && dispatch(openLicenseModal())} />}
}> diff --git a/ui/src/components/PushNotificationPrompt.test.tsx b/ui/src/components/PushNotificationPrompt.test.tsx deleted file mode 100644 index e2e94f8f..00000000 --- a/ui/src/components/PushNotificationPrompt.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import { PushNotificationPrompt, PUSH_ENABLE_ACTION_ID } from './PushNotificationPrompt'; -import { runNotificationAction } from '../store/Notifications/actionRegistry'; -import { subscribe } from '../utils/pushNotifications'; -import { notify } from '../utils/notify'; - -jest.mock('../utils/pushNotifications', () => ({ - subscribe: jest.fn(), -})); -jest.mock('../utils/notify', () => ({ - notify: { success: jest.fn(), error: jest.fn() }, -})); - -describe('PushNotificationPrompt', () => { - const originalNotification = (global as any).Notification; - - beforeEach(() => { - (global as any).Notification = { requestPermission: jest.fn() }; - }); - - afterEach(() => { - (global as any).Notification = originalNotification; - jest.clearAllMocks(); - }); - - it('subscribes and shows a success toast when permission is granted', async () => { - (Notification.requestPermission as jest.Mock).mockResolvedValue('granted'); - (subscribe as jest.Mock).mockResolvedValue(undefined); - - render(); - await runNotificationActionAsync(); - - expect(subscribe).toHaveBeenCalled(); - expect(notify.success).toHaveBeenCalled(); - }); - - it('does not subscribe when permission is denied', async () => { - (Notification.requestPermission as jest.Mock).mockResolvedValue('denied'); - - render(); - await runNotificationActionAsync(); - - expect(subscribe).not.toHaveBeenCalled(); - expect(notify.success).not.toHaveBeenCalled(); - }); - - it('shows an error toast when subscribing fails', async () => { - (Notification.requestPermission as jest.Mock).mockResolvedValue('granted'); - (subscribe as jest.Mock).mockRejectedValue(new Error('boom')); - - render(); - await runNotificationActionAsync(); - - expect(notify.error).toHaveBeenCalled(); - }); -}); - -async function runNotificationActionAsync() { - runNotificationAction(PUSH_ENABLE_ACTION_ID); - // Flush the microtask queue so the async handler completes. - await new Promise((resolve) => setTimeout(resolve, 0)); - await new Promise((resolve) => setTimeout(resolve, 0)); -} diff --git a/ui/src/components/PushNotificationPrompt.tsx b/ui/src/components/PushNotificationPrompt.tsx deleted file mode 100644 index ac24c5ae..00000000 --- a/ui/src/components/PushNotificationPrompt.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React, { useEffect } from 'react'; -import { registerNotificationAction } from '../store/Notifications/actionRegistry'; -import { subscribe, PUSH_ENABLE_ACTION_ID } from '../utils/pushNotifications'; -import { notify } from '../utils/notify'; - -export { PUSH_ENABLE_ACTION_ID }; - -// Headless (renders nothing) — mounted once at the app root (App.tsx), same -// pattern as URLMismatchBanner/LicenseStatusBar. Registers the handler the -// "Enable" button on the post-trigger-review toast calls, so granting -// permission happens right there without navigating to Settings. -export const PushNotificationPrompt: React.FC = () => { - useEffect(() => { - const unregister = registerNotificationAction(PUSH_ENABLE_ACTION_ID, async () => { - try { - const permission = await Notification.requestPermission(); - if (permission !== 'granted') { - return; - } - await subscribe(); - notify.success("You're set — we'll notify you about your reviews."); - } catch (err) { - console.error('Failed to enable push notifications:', err); - notify.error('Could not enable notifications. Please try again from Settings.'); - } - }); - return unregister; - }, []); - - return null; -}; - -export default PushNotificationPrompt; diff --git a/ui/src/components/reviews/PushNotificationBanner.test.tsx b/ui/src/components/reviews/PushNotificationBanner.test.tsx new file mode 100644 index 00000000..df4486b7 --- /dev/null +++ b/ui/src/components/reviews/PushNotificationBanner.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { PushNotificationBanner } from './PushNotificationBanner'; +import * as pushNotifications from '../../utils/pushNotifications'; +import { notify } from '../../utils/notify'; + +jest.mock('../../utils/pushNotifications', () => ({ + isPushSupported: jest.fn(), + subscribe: jest.fn(), +})); +jest.mock('../../utils/notify', () => ({ + notify: { success: jest.fn(), error: jest.fn() }, +})); + +const DISMISSED_KEY = 'livereview:push-notify-banner-dismissed'; + +describe('PushNotificationBanner', () => { + const originalNotification = (global as any).Notification; + + beforeEach(() => { + localStorage.clear(); + (pushNotifications.isPushSupported as jest.Mock).mockReturnValue(true); + (global as any).Notification = { permission: 'default', requestPermission: jest.fn() }; + }); + + afterEach(() => { + (global as any).Notification = originalNotification; + jest.clearAllMocks(); + }); + + it('renders nothing when push is unsupported', () => { + (pushNotifications.isPushSupported as jest.Mock).mockReturnValue(false); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when permission is already granted or denied', () => { + (global as any).Notification.permission = 'granted'; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when previously dismissed', () => { + localStorage.setItem(DISMISSED_KEY, '1'); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows an inline Enable CTA and a close button when eligible, without auto-dismissing', () => { + render(); + expect(screen.getByText(/browser notification/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /enable notifications/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument(); + }); + + it('subscribes and hides the banner when Enable is clicked and permission is granted', async () => { + (Notification.requestPermission as jest.Mock).mockResolvedValue('granted'); + (pushNotifications.subscribe as jest.Mock).mockResolvedValue(undefined); + + render(); + fireEvent.click(screen.getByRole('button', { name: /enable notifications/i })); + + await waitFor(() => expect(pushNotifications.subscribe).toHaveBeenCalled()); + expect(notify.success).toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByText(/browser notification/i)).not.toBeInTheDocument()); + }); + + it('closing the banner persists the dismissal so it does not reappear', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); + + expect(screen.queryByText(/browser notification/i)).not.toBeInTheDocument(); + expect(localStorage.getItem(DISMISSED_KEY)).toBe('1'); + }); +}); diff --git a/ui/src/components/reviews/PushNotificationBanner.tsx b/ui/src/components/reviews/PushNotificationBanner.tsx new file mode 100644 index 00000000..c407c5e2 --- /dev/null +++ b/ui/src/components/reviews/PushNotificationBanner.tsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react'; +import { Alert, Button, Icons } from '../UIPrimitives'; +import { isPushSupported, subscribe } from '../../utils/pushNotifications'; +import { notify } from '../../utils/notify'; + +const DISMISSED_KEY = 'livereview:push-notify-banner-dismissed'; + +const readDismissed = (): boolean => { + try { + return localStorage.getItem(DISMISSED_KEY) === '1'; + } catch { + return false; + } +}; + +// Inline, in-page banner (not a toast) offering to enable browser push +// notifications for review progress. Lives on the review detail page itself +// - visible above the tabs regardless of which one is active - with the +// "Enable" action and a close button both part of the banner, per this +// repo's standard dismissible-Alert pattern. Stays up until the user acts on +// it or closes it (a close is remembered via localStorage so it doesn't +// reappear on every review page visit); it never auto-dismisses on a timer. +export const PushNotificationBanner: React.FC = () => { + const [dismissed, setDismissed] = useState(readDismissed); + const [enabling, setEnabling] = useState(false); + const [permission, setPermission] = useState( + typeof Notification !== 'undefined' ? Notification.permission : null + ); + + if (dismissed || !isPushSupported() || permission !== 'default') { + return null; + } + + const handleDismiss = () => { + try { + localStorage.setItem(DISMISSED_KEY, '1'); + } catch { + // Best-effort only (e.g. private browsing) - just hide for this page view. + } + setDismissed(true); + }; + + const handleEnable = async () => { + setEnabling(true); + try { + const result = await Notification.requestPermission(); + setPermission(result); + if (result !== 'granted') return; + await subscribe(); + notify.success("You're set — we'll notify you about your reviews."); + } catch (err) { + console.error('Failed to enable push notifications:', err); + notify.error('Could not enable notifications. Please try again from Settings.'); + } finally { + setEnabling(false); + } + }; + + return ( + } onClose={handleDismiss} className="mb-6"> +
+

Want a browser notification when this review's findings are ready?

+ +
+
+ ); +}; + +export default PushNotificationBanner; diff --git a/ui/src/components/reviews/index.ts b/ui/src/components/reviews/index.ts index ac3290bd..c1b5d066 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/pages/Explore/MergeRequests.tsx b/ui/src/pages/Explore/MergeRequests.tsx index 622adcd3..e96927d1 100644 --- a/ui/src/pages/Explore/MergeRequests.tsx +++ b/ui/src/pages/Explore/MergeRequests.tsx @@ -3,7 +3,6 @@ import ProviderIcon from '../../components/ProviderIcon'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { ColumnDef, useReactTable, getCoreRowModel, getFilteredRowModel, getSortedRowModel, getPaginationRowModel } from '@tanstack/react-table'; import { notify } from '../../utils/notify'; -import { maybePromptForPushNotifications } from '../../utils/pushNotifications'; import { LuSearch } from 'react-icons/lu'; import { Button, Icons, Tooltip, Input, MultiSelectPanel } from '../../components/UIPrimitives'; import { ClientTable } from '../../components/DataTable/ClientTable'; @@ -140,7 +139,6 @@ const MergeRequests: React.FC = () => { setTriggeringId(pr.id); try { const response = await triggerReviewForPullRequest(pr.id); - maybePromptForPushNotifications(); navigate(`/reviews/${response.reviewId}`); } catch (err) { console.error('Failed to trigger review:', err); diff --git a/ui/src/pages/Reviews/NewReview.tsx b/ui/src/pages/Reviews/NewReview.tsx index 1bd88d00..8c82782d 100644 --- a/ui/src/pages/Reviews/NewReview.tsx +++ b/ui/src/pages/Reviews/NewReview.tsx @@ -25,7 +25,6 @@ import GitHubAppNotInstalledModal from '../../components/GitHubApp/GitHubAppNotI import { getGitHubAppInstallCheck, startGitHubAppConnect } from '../../api/githubApp'; import { notify } from '../../utils/notify'; import { useGitHubAppEnabled } from '../../hooks/useGitHubAppEnabled'; -import { maybePromptForPushNotifications } from '../../utils/pushNotifications'; type QuotaStatusResponse = { can_trigger_reviews: boolean; @@ -198,7 +197,6 @@ const NewReview: React.FC = () => { setLoading(true); try { const response = await run(); - maybePromptForPushNotifications(); navigate(`/reviews/${response.reviewId}`); } catch (err: any) { console.error('Error triggering review:', err); diff --git a/ui/src/pages/Reviews/PullRequestReviewTrigger.tsx b/ui/src/pages/Reviews/PullRequestReviewTrigger.tsx index 1832b0d6..f40fc1ef 100644 --- a/ui/src/pages/Reviews/PullRequestReviewTrigger.tsx +++ b/ui/src/pages/Reviews/PullRequestReviewTrigger.tsx @@ -6,7 +6,6 @@ import { connectGitHubInstallation } from '../../api/githubApp'; import { ConnectRequiredError, useSwitchToPullRequestOrg, PR_NOT_FOUND_MESSAGE } from '../../hooks/useSwitchToPullRequestOrg'; import { notify } from '../../utils/notify'; import { GEMINI_API_KEYS_URL, isMissingAIProviderError } from '../../utils/aiProviderErrors'; -import { maybePromptForPushNotifications } from '../../utils/pushNotifications'; // After connecting a GitHub account, how long to wait for its repositories (and so this PR) to sync. const SYNC_POLL_MS = 3000; @@ -35,7 +34,6 @@ const PullRequestReviewTrigger: React.FC = () => { try { const orgPullRequestId = await switchToPullRequestOrg(pullRequestId); const res = await startOrReuseReviewForPullRequest(orgPullRequestId); - maybePromptForPushNotifications(); navigate(`/reviews/${res.reviewId}`, { replace: true }); } catch (err: any) { // By name, not instanceof: transpiled Error subclasses don't always keep their prototype. diff --git a/ui/src/pages/Reviews/ReviewDetail.tsx b/ui/src/pages/Reviews/ReviewDetail.tsx index 21c6857e..923777b9 100644 --- a/ui/src/pages/Reviews/ReviewDetail.tsx +++ b/ui/src/pages/Reviews/ReviewDetail.tsx @@ -5,7 +5,7 @@ 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 { ReviewEventsPage, DiffViewerPanel, PushNotificationBanner } from '../../components/reviews'; import AutoApprovePanel from '../../components/reviews/AutoApprovePanel'; import { getReviewPolicyLog } from '../../api/riskPolicy'; import { @@ -708,6 +708,8 @@ const ReviewDetail: React.FC = () => { )}
+ + {/* Findings / Accounting / Events */} { await subscription.unsubscribe(); await deletePushSubscription(endpoint); } - -// Call this right after ANY code path successfully triggers a review - -// there are several (the "Create Review" form, the "Start a review" link -// posted on PRs/MRs, the Explore > Pull/Merge Requests list) and this must -// not be missed from any of them. Offers to enable browser push -// notifications via a toast with an inline "Enable" action button (handled -// by PushNotificationPrompt, mounted once at the app root) - no navigation -// to Settings required. No-ops if push isn't supported, or the browser has -// already been asked (permission is 'granted' or 'denied'). -export function maybePromptForPushNotifications(): void { - if (!isPushSupported() || typeof Notification === 'undefined' || Notification.permission !== 'default') { - return; - } - notify.info("Want a browser notification when this review's findings are ready?", { - source: 'push-notify', - dedupeKey: 'push-notify-prompt', - actions: [{ label: 'Enable', actionId: PUSH_ENABLE_ACTION_ID }], - }); -} From 7e1b2771a155a0129337652ce22eefda712c842e Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Fri, 30 Oct 2026 15:25:55 +0000 Subject: [PATCH 08/25] fix: move the push-notification banner above "Back to Reviews" Was placed just above the tabs, well below the fold on longer review pages. Moves it to the very top of the review detail page, right under the navbar and before the back link, so it's immediately visible. LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%) --- ui/src/pages/Reviews/ReviewDetail.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/Reviews/ReviewDetail.tsx b/ui/src/pages/Reviews/ReviewDetail.tsx index 923777b9..426da5ee 100644 --- a/ui/src/pages/Reviews/ReviewDetail.tsx +++ b/ui/src/pages/Reviews/ReviewDetail.tsx @@ -510,6 +510,8 @@ const ReviewDetail: React.FC = () => { return (
+ + {/* Header */}
- - {/* Findings / Accounting / Events */} Date: Fri, 30 Oct 2026 15:39:39 +0000 Subject: [PATCH 09/25] fix: align bell icon with text in push-notification banner Alert's own icon slot doesn't vertically center against taller children (ours includes a button row), leaving the bell sitting above the text baseline instead of beside it. Move the icon inline into our own items-center row instead of using Alert's icon prop. LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%) --- ui/src/components/reviews/PushNotificationBanner.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ui/src/components/reviews/PushNotificationBanner.tsx b/ui/src/components/reviews/PushNotificationBanner.tsx index c407c5e2..2d10f373 100644 --- a/ui/src/components/reviews/PushNotificationBanner.tsx +++ b/ui/src/components/reviews/PushNotificationBanner.tsx @@ -57,9 +57,16 @@ export const PushNotificationBanner: React.FC = () => { }; return ( - } onClose={handleDismiss} className="mb-6"> + // Icon is placed inline inside our own items-center row rather than via + // Alert's `icon` prop - Alert's own icon slot isn't vertically centered + // against taller children (ours includes a button row), which misaligns + // the bell against the text. +
-

Want a browser notification when this review's findings are ready?

+
+ +

Want a browser notification when this review's findings are ready?

+
From 9007481ba6cf31b4b0344b49e19b4f8747b99f92 Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Fri, 30 Oct 2026 15:54:38 +0000 Subject: [PATCH 10/25] feat: add a Settings link to the push-notification banner, center the close button Adds a Settings link (to /settings#notifications) alongside the Enable CTA. Also moves the close button into our own items-center row, same fix as the bell icon's earlier alignment issue - Alert's own onClose slot isn't vertically centered against taller children either. LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%) --- .../reviews/PushNotificationBanner.test.tsx | 16 ++++++---- .../reviews/PushNotificationBanner.tsx | 31 ++++++++++++++----- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/ui/src/components/reviews/PushNotificationBanner.test.tsx b/ui/src/components/reviews/PushNotificationBanner.test.tsx index df4486b7..9d68909d 100644 --- a/ui/src/components/reviews/PushNotificationBanner.test.tsx +++ b/ui/src/components/reviews/PushNotificationBanner.test.tsx @@ -1,10 +1,13 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; import '@testing-library/jest-dom'; import { PushNotificationBanner } from './PushNotificationBanner'; import * as pushNotifications from '../../utils/pushNotifications'; import { notify } from '../../utils/notify'; +const renderBanner = () => render(, { wrapper: MemoryRouter }); + jest.mock('../../utils/pushNotifications', () => ({ isPushSupported: jest.fn(), subscribe: jest.fn(), @@ -31,34 +34,35 @@ describe('PushNotificationBanner', () => { it('renders nothing when push is unsupported', () => { (pushNotifications.isPushSupported as jest.Mock).mockReturnValue(false); - const { container } = render(); + const { container } = renderBanner(); expect(container).toBeEmptyDOMElement(); }); it('renders nothing when permission is already granted or denied', () => { (global as any).Notification.permission = 'granted'; - const { container } = render(); + const { container } = renderBanner(); expect(container).toBeEmptyDOMElement(); }); it('renders nothing when previously dismissed', () => { localStorage.setItem(DISMISSED_KEY, '1'); - const { container } = render(); + const { container } = renderBanner(); expect(container).toBeEmptyDOMElement(); }); it('shows an inline Enable CTA and a close button when eligible, without auto-dismissing', () => { - render(); + renderBanner(); expect(screen.getByText(/browser notification/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /enable notifications/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /settings/i })).toHaveAttribute('href', '/settings#notifications'); }); it('subscribes and hides the banner when Enable is clicked and permission is granted', async () => { (Notification.requestPermission as jest.Mock).mockResolvedValue('granted'); (pushNotifications.subscribe as jest.Mock).mockResolvedValue(undefined); - render(); + renderBanner(); fireEvent.click(screen.getByRole('button', { name: /enable notifications/i })); await waitFor(() => expect(pushNotifications.subscribe).toHaveBeenCalled()); @@ -67,7 +71,7 @@ describe('PushNotificationBanner', () => { }); it('closing the banner persists the dismissal so it does not reappear', () => { - render(); + renderBanner(); fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); expect(screen.queryByText(/browser notification/i)).not.toBeInTheDocument(); diff --git a/ui/src/components/reviews/PushNotificationBanner.tsx b/ui/src/components/reviews/PushNotificationBanner.tsx index 2d10f373..b229cc9a 100644 --- a/ui/src/components/reviews/PushNotificationBanner.tsx +++ b/ui/src/components/reviews/PushNotificationBanner.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; import { Alert, Button, Icons } from '../UIPrimitives'; import { isPushSupported, subscribe } from '../../utils/pushNotifications'; import { notify } from '../../utils/notify'; @@ -57,19 +58,33 @@ export const PushNotificationBanner: React.FC = () => { }; return ( - // Icon is placed inline inside our own items-center row rather than via - // Alert's `icon` prop - Alert's own icon slot isn't vertically centered - // against taller children (ours includes a button row), which misaligns - // the bell against the text. - + // Icon, Settings link, CTA and the close button are all placed inline + // inside our own items-center row rather than via Alert's `icon`/ + // `onClose` props - Alert's own icon/close slots aren't vertically + // centered against taller children (ours includes a button), which + // misaligns them against the text. +

Want a browser notification when this review's findings are ready?

- +
+ + Settings + + + +
); From 73c0e8145370d91675fb2ee6d2dc70ee1acd30bc Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Fri, 30 Oct 2026 16:13:10 +0000 Subject: [PATCH 11/25] fix: clarify push-notification banner copy isn't scoped to one review Enabling notifications is a per-user/browser setting that applies to all future reviews you trigger, not just the one whose page the banner happens to be showing on. 'this review's findings' was misleading. LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%) --- ui/src/components/reviews/PushNotificationBanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/reviews/PushNotificationBanner.tsx b/ui/src/components/reviews/PushNotificationBanner.tsx index b229cc9a..455c5295 100644 --- a/ui/src/components/reviews/PushNotificationBanner.tsx +++ b/ui/src/components/reviews/PushNotificationBanner.tsx @@ -67,7 +67,7 @@ export const PushNotificationBanner: React.FC = () => {
-

Want a browser notification when this review's findings are ready?

+

Want a browser notification when your reviews' findings are ready or complete — even with this tab closed?

From e9d699829624f3525fced9a198fd1e003e56785f Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Fri, 30 Oct 2026 16:16:34 +0000 Subject: [PATCH 12/25] fix: shorten push-notification banner copy Drop the completion/tab-closed detail from the banner's question - keep it to 'reviews' findings are ready', the Settings page already explains the rest. LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%) --- ui/src/components/reviews/PushNotificationBanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/reviews/PushNotificationBanner.tsx b/ui/src/components/reviews/PushNotificationBanner.tsx index 455c5295..2edf1dc6 100644 --- a/ui/src/components/reviews/PushNotificationBanner.tsx +++ b/ui/src/components/reviews/PushNotificationBanner.tsx @@ -67,7 +67,7 @@ export const PushNotificationBanner: React.FC = () => {
-

Want a browser notification when your reviews' findings are ready or complete — even with this tab closed?

+

Want a browser notification when your reviews' findings are ready?

From f670c59f5a90c6ffb840d221e08df2936a6d6bab Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Fri, 30 Oct 2026 17:03:41 +0000 Subject: [PATCH 13/25] fix: push-notification state mismatch between banner and Settings, add blocked-permission guidance The banner marked notification permission as granted immediately after Notification.requestPermission() resolved, before the subsequent subscribe() call (PushManager + backend POST) had actually succeeded. If subscribe() failed, the banner still hid itself permanently while Settings (which checks the real PushManager subscription) correctly kept showing 'Enable' - exactly the mismatch reported. Both the banner and Settings now track the real subscription state via getExistingSubscription() instead of trusting permission alone, and only hide/flip once subscribe() actually succeeds; a failed subscribe() leaves the CTA in place so the user can retry. Also add inline guidance (not just an easy-to-miss toast) for the case where the browser already blocked notifications for the site, since there's no API to re-trigger the native prompt once denied - and a one-line heads-up that the permission request may show up near the browser's address bar rather than as an obvious popup (Chrome's quiet UI can present it that way). LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%) --- .../routes_guide/settings/notifications.md | 8 +- .../reviews/PushNotificationBanner.test.tsx | 45 +++++++-- .../reviews/PushNotificationBanner.tsx | 96 ++++++++++++++----- ui/src/pages/Settings/NotificationsTab.tsx | 29 ++++-- 4 files changed, 135 insertions(+), 43 deletions(-) diff --git a/internal/docindex/docs/routes_guide/settings/notifications.md b/internal/docindex/docs/routes_guide/settings/notifications.md index 29fe2cee..49df3a89 100644 --- a/internal/docindex/docs/routes_guide/settings/notifications.md +++ b/internal/docindex/docs/routes_guide/settings/notifications.md @@ -16,8 +16,12 @@ tab only affects reviews started from a logged-in session. - 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) — - no need to visit this page first. + 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. diff --git a/ui/src/components/reviews/PushNotificationBanner.test.tsx b/ui/src/components/reviews/PushNotificationBanner.test.tsx index 9d68909d..a31e8d0c 100644 --- a/ui/src/components/reviews/PushNotificationBanner.test.tsx +++ b/ui/src/components/reviews/PushNotificationBanner.test.tsx @@ -11,6 +11,7 @@ const renderBanner = () => render(, { wrapper: MemoryR jest.mock('../../utils/pushNotifications', () => ({ isPushSupported: jest.fn(), subscribe: jest.fn(), + getExistingSubscription: jest.fn(), })); jest.mock('../../utils/notify', () => ({ notify: { success: jest.fn(), error: jest.fn() }, @@ -24,6 +25,7 @@ describe('PushNotificationBanner', () => { beforeEach(() => { localStorage.clear(); (pushNotifications.isPushSupported as jest.Mock).mockReturnValue(true); + (pushNotifications.getExistingSubscription as jest.Mock).mockResolvedValue(null); (global as any).Notification = { permission: 'default', requestPermission: jest.fn() }; }); @@ -32,37 +34,47 @@ describe('PushNotificationBanner', () => { jest.clearAllMocks(); }); - it('renders nothing when push is unsupported', () => { + it('renders nothing when push is unsupported', async () => { (pushNotifications.isPushSupported as jest.Mock).mockReturnValue(false); const { container } = renderBanner(); - expect(container).toBeEmptyDOMElement(); + await waitFor(() => expect(container).toBeEmptyDOMElement()); }); - it('renders nothing when permission is already granted or denied', () => { - (global as any).Notification.permission = 'granted'; + it('renders nothing when already subscribed on this browser', async () => { + (pushNotifications.getExistingSubscription as jest.Mock).mockResolvedValue({ endpoint: 'x' }); const { container } = renderBanner(); - expect(container).toBeEmptyDOMElement(); + await waitFor(() => expect(container).toBeEmptyDOMElement()); }); - it('renders nothing when previously dismissed', () => { + it('renders nothing when previously dismissed', async () => { localStorage.setItem(DISMISSED_KEY, '1'); const { container } = renderBanner(); - expect(container).toBeEmptyDOMElement(); + await waitFor(() => expect(container).toBeEmptyDOMElement()); }); - it('shows an inline Enable CTA and a close button when eligible, without auto-dismissing', () => { + it('shows an inline Enable CTA and a close button when eligible, without auto-dismissing', async () => { renderBanner(); - expect(screen.getByText(/browser notification/i)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(/browser notification/i)).toBeInTheDocument()); expect(screen.getByRole('button', { name: /enable notifications/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /settings/i })).toHaveAttribute('href', '/settings#notifications'); }); + it('shows blocked guidance instead of the CTA when permission was previously denied', async () => { + (global as any).Notification.permission = 'denied'; + renderBanner(); + await waitFor(() => expect(screen.getByText(/blocked for this site/i)).toBeInTheDocument()); + expect(screen.queryByRole('button', { name: /enable notifications/i })).not.toBeInTheDocument(); + expect(screen.getByText(/address bar/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /settings/i })).toBeInTheDocument(); + }); + it('subscribes and hides the banner when Enable is clicked and permission is granted', async () => { (Notification.requestPermission as jest.Mock).mockResolvedValue('granted'); (pushNotifications.subscribe as jest.Mock).mockResolvedValue(undefined); renderBanner(); + await waitFor(() => screen.getByRole('button', { name: /enable notifications/i })); fireEvent.click(screen.getByRole('button', { name: /enable notifications/i })); await waitFor(() => expect(pushNotifications.subscribe).toHaveBeenCalled()); @@ -70,8 +82,21 @@ describe('PushNotificationBanner', () => { await waitFor(() => expect(screen.queryByText(/browser notification/i)).not.toBeInTheDocument()); }); - it('closing the banner persists the dismissal so it does not reappear', () => { + it('keeps the banner visible and lets the user retry if subscribe() fails after permission is granted', async () => { + (Notification.requestPermission as jest.Mock).mockResolvedValue('granted'); + (pushNotifications.subscribe as jest.Mock).mockRejectedValue(new Error('network error')); + + renderBanner(); + await waitFor(() => screen.getByRole('button', { name: /enable notifications/i })); + fireEvent.click(screen.getByRole('button', { name: /enable notifications/i })); + + await waitFor(() => expect(notify.error).toHaveBeenCalled()); + expect(screen.getByRole('button', { name: /enable notifications/i })).toBeInTheDocument(); + }); + + it('closing the banner persists the dismissal so it does not reappear', async () => { renderBanner(); + await waitFor(() => screen.getByRole('button', { name: /dismiss/i })); fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); expect(screen.queryByText(/browser notification/i)).not.toBeInTheDocument(); diff --git a/ui/src/components/reviews/PushNotificationBanner.tsx b/ui/src/components/reviews/PushNotificationBanner.tsx index 2edf1dc6..2032c8c5 100644 --- a/ui/src/components/reviews/PushNotificationBanner.tsx +++ b/ui/src/components/reviews/PushNotificationBanner.tsx @@ -1,7 +1,7 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { Alert, Button, Icons } from '../UIPrimitives'; -import { isPushSupported, subscribe } from '../../utils/pushNotifications'; +import { getExistingSubscription, isPushSupported, subscribe } from '../../utils/pushNotifications'; import { notify } from '../../utils/notify'; const DISMISSED_KEY = 'livereview:push-notify-banner-dismissed'; @@ -27,11 +27,40 @@ export const PushNotificationBanner: React.FC = () => { const [permission, setPermission] = useState( typeof Notification !== 'undefined' ? Notification.permission : null ); + // Tracks whether a PushManager subscription actually exists - not the + // same thing as `permission === 'granted'`. requestPermission() can + // succeed while the subsequent subscribe() call still fails (VAPID key + // fetch, PushManager.subscribe() rejecting, the backend POST failing), + // and permission alone can't tell those apart. Checked once on mount so + // the banner doesn't show again for a browser that's already subscribed + // (e.g. enabled from Settings instead of this banner). + const [subscribed, setSubscribed] = useState(null); - if (dismissed || !isPushSupported() || permission !== 'default') { + useEffect(() => { + let cancelled = false; + if (isPushSupported()) { + getExistingSubscription().then((sub) => { + if (!cancelled) setSubscribed(!!sub); + }); + } else { + setSubscribed(false); + } + return () => { + cancelled = true; + }; + }, []); + + if (dismissed || !isPushSupported() || subscribed !== false) { return null; } + // Browsers never let a site re-trigger the native permission prompt once + // blocked, and give no way to tell "closed the prompt" apart from + // "clicked Block" - the only recovery path is the browser's own site + // settings (the icon next to the address bar), so we point there instead + // of silently hiding or re-prompting. + const blocked = permission === 'denied'; + const handleDismiss = () => { try { localStorage.setItem(DISMISSED_KEY, '1'); @@ -48,10 +77,11 @@ export const PushNotificationBanner: React.FC = () => { 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 enable push notifications:', err); - notify.error('Could not enable notifications. Please try again from Settings.'); + notify.error('Could not enable notifications. Please try again, or from Settings.'); } finally { setEnabling(false); } @@ -63,28 +93,44 @@ export const PushNotificationBanner: React.FC = () => { // `onClose` props - Alert's own icon/close slots aren't vertically // centered against taller children (ours includes a button), which // misaligns them against the text. - -
-
- -

Want a browser notification when your reviews' findings are ready?

-
-
- - Settings - - - + +
+
+
+ + {blocked ? ( +

Notifications are blocked for this site in your browser.

+ ) : ( +

Want a browser notification when your reviews' findings are ready?

+ )} +
+
+ + Settings + + {!blocked && ( + + )} + +
+

+ {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.'} +

); diff --git a/ui/src/pages/Settings/NotificationsTab.tsx b/ui/src/pages/Settings/NotificationsTab.tsx index fbe40156..52991002 100644 --- a/ui/src/pages/Settings/NotificationsTab.tsx +++ b/ui/src/pages/Settings/NotificationsTab.tsx @@ -18,7 +18,11 @@ const NotificationsTab: React.FC = () => { 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; @@ -59,11 +63,12 @@ const NotificationsTab: React.FC = () => { setSubscribed(false); } else { if (Notification.permission === 'denied') { - notify.error('Notifications are blocked for this site in your browser settings.'); + setPermission('denied'); return; } - const permission = await Notification.requestPermission(); - if (permission !== 'granted') 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."); @@ -95,10 +100,22 @@ const NotificationsTab: React.FC = () => {

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

+ )} + )} From afcbe3b12a994eaaf2a17ff8ae9ffa7a7586a355 Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Fri, 30 Oct 2026 17:34:54 +0000 Subject: [PATCH 14/25] fix: wire push-notifier into the worker process, not just the api process reviewprocessor.SetNotifier was only called inside NewServer (the api process), never inside appContext (the shared constructor WorkerContext also calls). The background 'worker' process (ecosystem.config.js's livereview-worker/-2, cmd/worker.go) is where queued reviews actually run and where batch-completion/status-update events fire - so in that process the notifier stayed nil, and every NotifyReviewEvent call silently no-op'd with no error or log line. Confirmed on production: a subscribed, enabled-everywhere user's triggered review completed with findings, but reviews.first_finding_notified_at never got set and no push-dispatch log line ever appeared, even though push_subscriptions had a valid row for them - the worker process simply never had a notifier to call. Moves the SetNotifier call into appContext so both NewServer and WorkerContext wire it. LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%) --- internal/api/server.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 41081e9a..b6b61d43 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -446,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 } @@ -456,13 +469,6 @@ func NewServer(port int, versionInfo *VersionInfo) (*Server, error) { return nil, 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. - reviewprocessor.SetNotifier(pushnotify.New(server.db)) - e := echo.New() e.HideBanner = true From 0d027e5215c9674574cddfdfe1a160015863a8da Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Fri, 30 Oct 2026 18:02:00 +0000 Subject: [PATCH 15/25] feat: richer push notifications (icon, review specifics, softer failure wording) - sw.js: use a proper PNG icon (notification-icon.png, favicon.svg rasterized at 192x192 via vl-convert svg2png) instead of favicon.ico - .ico isn't reliably rendered as a notification icon by Chrome/Windows. - pushnotify: notification bodies now include the review number, a short owner/repo label, and the PR/MR title when known, so a notification is recognizable without opening it. Click-through already opened the review's URL (ui/public/sw.js's notificationclick handler) - confirmed working, no change needed there. - Renamed the failed-review title from 'Review failed' to 'Review completed with errors' - most failures here are one erroring batch, not total loss, so the blunter wording overstated it. fix: review accounting 500 on every freshly-triggered review GetReviewAccountingTotals' 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. Postgres SUM() over zero matching rows is NULL, not 0, and scanning that into a non-nullable int64 failed with 'converting NULL to int64 is unsupported' - a review has zero loc_usage_ledger rows until its first LLM call is accounted, so the webui's accounting poll 500'd on every single freshly-triggered review. Wrapped it in COALESCE to match. Added a regression test confirmed to fail against the old query and pass against the fix. fix: trim whitespace before validating the browser-reported time zone Defensive hardening for the occasional /users/time-zone 400 seen in production - couldn't reproduce a bad zone name server-side (common IANA names and their deprecated aliases all load fine), but a few older browser/OS combinations are known to append stray whitespace to Intl.DateTimeFormat().resolvedOptions().timeZone. Confirmed separately: the /api/v1/github-app/demo 404 seen in the same console log is expected, not a bug - this box has no GITHUB_APP_ID configured, and useGitHubAppConnected already documents and handles a 404 there as 'not connected', with no error surfaced to the user. LiveReview Pre-Commit Check: skipped (iter:2, coverage:0%) --- .git-hooks-binary-allowlist | 2 + internal/api/users/profile_handlers.go | 2 + .../routes_guide/settings/notifications.md | 8 +- internal/pushnotify/payload_test.go | 67 +++++++++++++++++ internal/pushnotify/pushnotify.go | 71 +++++++++++++++--- storage/license/review_accounting_store.go | 4 +- .../license/review_accounting_store_test.go | 70 +++++++++++++++++ ui/public/notification-icon.png | Bin 0 -> 25545 bytes ui/public/sw.js | 7 +- 9 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 internal/pushnotify/payload_test.go create mode 100644 storage/license/review_accounting_store_test.go create mode 100644 ui/public/notification-icon.png diff --git a/.git-hooks-binary-allowlist b/.git-hooks-binary-allowlist index 82b1aab9..de0c5be2 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/internal/api/users/profile_handlers.go b/internal/api/users/profile_handlers.go index 2d255689..2d77eea1 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/docindex/docs/routes_guide/settings/notifications.md b/internal/docindex/docs/routes_guide/settings/notifications.md index 49df3a89..38937d76 100644 --- a/internal/docindex/docs/routes_guide/settings/notifications.md +++ b/internal/docindex/docs/routes_guide/settings/notifications.md @@ -25,7 +25,13 @@ tab only affects reviews started from a logged-in session. - 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. + 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 diff --git a/internal/pushnotify/payload_test.go b/internal/pushnotify/payload_test.go new file mode 100644 index 00000000..15c55292 --- /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 index 0d46d749..f3e3b4b6 100644 --- a/internal/pushnotify/pushnotify.go +++ b/internal/pushnotify/pushnotify.go @@ -67,20 +67,22 @@ type reviewInfo struct { 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 sql.NullString + var friendlyName, prMrURL, mrTitle sql.NullString err := n.db.QueryRowContext(ctx, ` - SELECT user_email, friendly_name, repository, pr_mr_url + SELECT user_email, friendly_name, repository, pr_mr_url, mr_title FROM reviews WHERE id = $1 - `, reviewID).Scan(&info.userEmail, &friendlyName, &info.repository, &prMrURL) + `, 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 } @@ -161,26 +163,75 @@ type pushPayload struct { Tag string `json:"tag,omitempty"` } -func reviewLabel(info *reviewInfo) string { +// 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 info.repository + return shortRepoLabel(info.repository) } - return "Your review" + return "your review" } func buildPayload(kind Kind, reviewID int64, info *reviewInfo, prodURL string) pushPayload { - label := reviewLabel(info) + 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 — the first results are in.", label)} + p = pushPayload{Title: "First finding ready", Body: fmt.Sprintf("%s — %s: first results are in.", identity, detail)} case KindFailed: - p = pushPayload{Title: "Review failed", Body: fmt.Sprintf("%s did not complete.", label)} + // 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 is ready to view.", label)} + p = pushPayload{Title: "Review completed", Body: fmt.Sprintf("%s — %s: ready to view.", identity, detail)} } p.Tag = fmt.Sprintf("review-%d", reviewID) if prodURL != "" { diff --git a/storage/license/review_accounting_store.go b/storage/license/review_accounting_store.go index 85ac4b8f..a81607f6 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 00000000..b4b680f8 --- /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/public/notification-icon.png b/ui/public/notification-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..42bec0464254b991bb2e7a9cde38c339e749b104 GIT binary patch literal 25545 zcmcJ&4P2Au{y%Pcj8hy%Go?aUWghC7tn%bk9+p{$Q>L6UqXf6ql9GpsfEy3nU2zl* zfy#^&VU@?I2&N&7hp}7aNkUwT%JXj6fbqPIvFGi+|DWp~>geD3p7Z~Ie?MQhgr@G= zb$zbS!~668ym#auTSDjG_wapl=FFM@^50*49sYX({ruxS@b&ec7!UZ*z5D+DcEX%F zo{Q1XyXKrc_28U2yHj6&al;#eyC(YX`tm${=?S7E&1yk=AL3>q{C{^C=7F22KOi1C#1xc zZPeQf!=2Tq)+f3C+^xUZaN5uKeP}-VZ4>-`i`V&?zbnR6i^Z1u!Tpsor>UEF>z@>B ze#BPRZ)5(9UMpo_Im3D>G-T?toFJiXdc=bb-woWgY|-1Vd~oKAqksHxyed2Dm+eI> z^eHA>V~!1b3Qw9rfg~PbMO7xYv0(hg6V%HXs+a^UPIsc2amiQwjN$Txg_Pa zM@mvYf_IZN`dLoet(SQ%C*}I-^055%^WJ&+?P%ZXl7uH$Ye9v$9&AICAZ5b#?glj$Y(l7%Hp2#G2{k9%--1sj= zGI*vp;nSOV;`K?te7?OsNgcB$uc~DEV}kGM&^Bs#)f46RNmxiX`eJ{xXbF{7azdez zMyA56qA!JUm4jclqQmQ9t{z*P-J4PqpkDini3H1`Y!dVg=a9oWaX6g6m71@oXalRE zU)QyU$W&NP#I?0~t>4MD5rjFYZK}LGt{ZDyT@c?LEA2ID6oSUp7H;1lWBO~4-u*4B zq%rK_4PM)O;M<_B4;}huLEW##?a`fbGng24nr(w;3@69%5M%`;wZXwQ{6#Va588XC zvm;kDl6%irHaTO+kUVDhd#9tjyPG!DY4ol!@@Q00IyIL=ZtzO^b)z5HW*mF;wVYA@ zfdg}XdHPSzy=6bvJv}FAl74q?@6kfd)l>EKw*C?!Yk&t`hGmP2va+()8I5QX9<+_* zVamRc%7~4LiIGi?$>A9-f+r3R@W-SbQe{WS`#ak2dJ3IeAZg-JCM~)Vz0&Q)7O!$) z-F(*N5}Zsa*tE`cDfCdeH2K46QdA9pLLYe(Oy5lI;P*CQXQPJ<)=fch3uPlKxKk^< zr9JySpMR(+{y<{l8;$Wj=oa%Fjqos>SK)ylizwEkq*qr*M~9%dT?kK2dumO4k&lm0 zU0Mi3V@STay*s5Qget^rg_vCmPgrH*$ogxLfe{j2G~3B0FeQAETi z5q=HWBbwN-$GsMGjWWr{YH%U|_G_$KD^I0LswY*%NI%?#)aKl@=G-9TClbpi@2$xL z%kSM=HX2@ZBQY^?+6ULelZRXL@MQRPJ+9VkvzLRe(s^Z|1q&8~FNiErkwq{C_IP@H6tgNcFT{Q7*s#DsB)qH>#!^t+) z!T&GC&4G9wer>DI?q?X{aac-6>ygDQ9rZFt z7QNnP730%|?CFi6cDr5nN8~Lf#)l>PhYLCVOE??KtHBsL_gB)F2+kEAI%w%}PZjnV zIEpO3AOoIU>_)io#!qDd((>}3Z&t}B%DYJw>~uRxDpR$CFP%k~IR+o_NPGT74H>a- zNvv~>Ev2M{Db~*KP4Z@b?2SX1;7-n0_RU`k7kJaFZRgjnU3>V0lcd9o(s^OyO``E8 zIE!oxahD#iZ)Fi_2Dr2El%a3;?%ut-2i(b+(e?puipEVo=-#$uzGFY+6W1YX_Og6P zpZbrQt2c2n!H(HuiE^xc2Veh~z-4m7KC)V$FFIpx`uh5|=45A2)<6irHK}qu3zVK+Npr6__b&Q zH%3UleBK!1b$-41#jX^w$6rcghYA@DbB}kfDczbgfNzhWY>N3&;5-#@Ngea3kI~&6 zLxxbR&lMcWg`HSqGMTz+Yio0y9q=&7eg^p{Rr)CD(TyA#ItISL3da!c3xD-&W#zj0 zb-MFhdl}cB1$KrHa?hTtsS!GEgqU`>j2nrMsTao#Vc!;;*U1cu=AGgjmHy985)Ehh z-qa#nzRz$u?;VltwHNW$p)6+DiP+VF#=n!R%Zw7LXOfXN816Tr)f$-Yn|EEVwb#|v zf$wly>D8fS=D>FP>e-7`*!Ounxpl~#B7r9weLD#6=dXFKH_g3#LhS5OIC}^J@(71# ziLjTSoGY@=WiK3EL)B(a+B_F%9SJuz;!FMc*p>*bf%xWfd8bT2*jdp(ctR|Cz=tJU zM(MwE?xZ4cqmpoZ!%%T`s&-E5#7a7{jh~Dz`C-hMat_BaEu@BIFkuKL66h?E3OY=E z#4&E;8n+RHHOjG?Ws5?=^Sm7$OhsX)DBO^Wn^VojZ?5DVZUe{4Wa0h0*>A?ToVIwO z{UY12JZB-bG=opBqmIQYEmJYT2=yBPl%;u%UD9uJzeyQmSob#k%jOE}Rf{HkO6^jdOQ`mX&^$!4i= zF14s@v{1Cq?1fKsBcGf6C`XIJCc}o-RHn6OZORRRz{H+Ldvt3xA6Xa!w8z&!2SJF# z`%0l1D=scJ3eCOdRC?iGz+cn9;4=J%&0KQuA5ts-(oQk#%^G_cwaQdrig0j$HvHPY z6IN1Z+Q`WpmRijxOu76tN0Zo|iCa85hh1)6-`C*%aWe{zbgFdP zq4?0kP@#iTjt!y>N&-KO{N7Jmh_+k{sijDi`!VZ{67xrlx1>t-TzVx2%#a>w>1KRg zY_{S*T`pf|{aoAlLvBj=x5N;!d%Z=@tFg1>#)xG4jCdyG?MtIeD!&U{zy2h{*Vkp$ zB853kVb&2|dLjm#j@nn*507ghl{GcNt$Rv)aeFmxZ$(#+cE5MW#PE3Hv{ojy|3;I1 z8TB_cxdScaUGu%{O<>K`%O_$J99MbG5c$HZCVVW?sp$&5LRZb8^t4}|(+=5uyD*(4 zNLMrU_cLE>A2aU4FiHWQkh+?)a+WdcN5DJfM)XV%au+1!Vd?Qyz!kDnde;6=@!(&Ou?$>ZnYOUkG~dzvs=XyJ zgjBn{60<%KM~Ncsk-m4oPsh1dFmaT%U|#juF%%hGZYmmO+&6CCya_k`3e_ogcIKeK z`0%XLVS$~X(>diV_nC-jJvh5-Db0+L(0e1z8>P(x*}mh6@f> zaU>eJHRj#}-LdG^t;gvUrG2A=1^%$ZLVKT@p)Vui)~$!aG1cXWIFDT34&1R$T<57zVOl?D zS`VY*BnLclz>ca!0ww)!?|)T2mhSR$uBbTU<_g?jeU1<0@cvwT9{x6RB;gO6wW<=r z@f31%B`H@Yf~J!yOgBOb;_Ua3WkJD{%mBw^6Xv)|k|E3st|VnS#BoAa(mvKelB5`N z2(lj;W||jOwvcL|Z$}M>L>?p;UaKu;P3n|_awsTQIy7UF6FhQH!>Sw=cVt{F^z}cQM@J?ysr@@nj0(3+ij~7g)(F%Ste62l-xY)_I?am~cARFeG4x^@TCiPxDx4Vgsugo;cj8Vd( z8=q*@9SHu&4+Rt6*gN#n7j+?0>RQu&fkvEg1CzJ|1ZV5Y&l47VxM#S=@q01-g7W;4c=}JMc z-IJi41V-#FQ(5V3G#kI@<(P9lmV>eom5FP6kiLBQ7Cd1t3vUj82J#I~VjDRruIa9x zAd=d51Tl~sWTm1k)Ouod)EXv7CLmT9=#NSqzrX{j>T)vS5ppq|7N*uXP_pLd#}=i< z6gzLCThAT@fFg)7>+%T}6)V_BA~2it17!M=@IBo@mSQ}=^M}x>Z}%%r^`5x1mXUTI z#}x)Wol+FZz`du2ztY*$+v_y29d&F+f!11}?Pq(a*v+mCv08>Ke}8If>dDqUm|4vx z)tC;zo+FywyHiu`1JSViE8~(MV18?~`p1uj;q2HN#WqU#OH7^zZj_>Ai}S{n?gBx- zlx<1RL2)`{CQj>zc;paN22A)(Y#8OIB#HuF)|?DW*ggO!2nTZhj6DCaf=FI$Y(53G zKq&*|fzNS1%u}j7M9;&O6D-=1qEf_bcxitQcPir5W5NI^QkKfTNA70Qa?s262Td4UUJA5Km=-fRNqO+rL$ht;P#?}z6|FER}rpJ8#z;ue=UMT$#xml^)Yf>@d8}wvmw;u1-9kd z`fXHd(0IP+pR_HXFe#{^2>BoiB8sL|8I%OhA%tbN_V)JHApj4BM>d5L2`H|yPvPX@ zVWlBIU#D)$$tf-qTMzLphfsEyn)7mRa`mn0o9jF?PR{oVK^!v_~3os89@jQu4r_ zLciwBUF&TqnQKSM+^lQk?CwoFfx{xsYRsNoi>dYe2d=e@TFVPrpxhGQdW-i?U?Bsv#<<|K2bFSm5a+JoMUW7qTZ}Og+l@xksri*i+i6#ZbtkE1;lU zl+|yQKoReml;>T$S~N6wEgcv!9QkLDnj2F6x3kW3kcL~sF(_5=nV3xIHxtvFF=snG zj_yZEUj!wEINR9HHWnaKRi^=*b@vTucXjHJk^rS`hhPy(#rh#BSWR{NR_m|EoT@0; z%?di%(CpK(HhK9RAOE;_)B{;Tzg|5UxUNdzJtx$A^4;YAfPh}(AF>0Kt!Dzo7pMbK zoc{E6hWi-xnY*@=(_X#KBCdW(5JL;$TXzqQFziHT5Nhs8P|$aXq`|p0hp`NI<3lT* zf5N(&(9I|Ip$JlWyUcK|lKm0YhsgRg*y-J@kdtj*smWV{$Cp%)4cuJn?|jP1+fV+0 zHC7gq>vAX4?xuW#k$=zf;beO^Ol9YgUnc-0hSeri?gubirL3^>^*`VQKd6svB zK)0Ejtl&BNyr$A*T9j;ui<$=AsGN_kf5n=XTt>y>ZJojizFzFu% zhIIKvxaAS@y*9u*(^{lKT?C=2{101{w2->zf7+tBv|~(aYM47xX7xU5=j+cigi+|! zj=({ArDpNf-$<;JiJ88nzfW#z&;%bB7m`Y^rvuCu@%NX@lZOMzr%|D_X}={)j2m)8 z?4`*eN;BjHb#^F_Cm>=vrfDo3A<#UXB;CTYTx2>fGEemi)LE#A_D5+sK-35+R1CVi z8{JW{bq=ZtNdGEi&E8m1*t_Vwc>5Fkw~%C%{EKReWLd@UYrc`xZRRpEm80 zPnXjLEpDYk+JSxh&dLo}0@=o`@K_;BUs*%L@$wb~q)mu}lJ^7TX1RhI1sZFGelxCG z`E6=LyNXNKM<_)j3p?TVGYwb7O(PjKcT;)!siR_jINR~ivKzcQ%Ia#nd(N7b!vwfp))kEonW8M#yg_$l^a zrK?Wh_J@kT#5OFj2Tg<$Cw6KBNdHQ-A-TyK#p#G~9adY>qn3y)t1IkU>=^Yj;8y~4 zGFN#9W~Z~1pVHz4kC&;LDhFs&65kpvYmAPGtV$diE=ZlUUJr9#4;y?b!xAn)FlM+g z7u9d;p=#{-^aR^5AN&r&e+pW%cvGEXU*}lOI(OUsJXcT@HUMVg=ipACC!8VVBpeL& z@)g-d50uOw#$H~4>kWMJS`@ZMF?iCNM!v}~?PS~xt#5?JoyaYROTPLDxg=zIBLK1t z=d(=1vmS7ZWLmQbo+Az#Dmof~PkZvG*tPmld<9E3snIc;`&lCL3fDHS7^)0%K*Lf(?Swr^ zD%-va+*gPlnJBa*u>$NN)l1VfW@x^twPb4rsORe*pMOTs@-plo!}>Nk4}g^*t6h1N zx)=Fe&csG}Iye@}U%fwvZTZfuzLp=>hkYC>YhwNi;2b{S;K@hEmLViFQaOeI5^q*- zVw>rpr?pKYLv_D4VUA<{{@Vns3N}T7|uSh%ksnYn$ zCLT5zNIQ@~fS+fg4(EHBpnZaE{*u@13WO5~R{C=1g9vq&Poq4IuJMxlVG(p~-xN;g z7U|FwcL$eHKJTh8?OrP+0i_KCyE2geTl;X-igp66sAA-N27 zqnLWs(=1I-PdDcCmDV4`wjad5cxG6|{3+-F@bs-3={=X^P6`Z;C3_t4e$Pgp65`_djIR~^V53NQFHsGnoNo7+TM zyR}+O-cJVJDKvV{V|In?%9w5@Y%`%T&ed%CphcvWz0nkD&`@ z0N@)l?M9uH#)Nf-QNU~rTgB!9+H;YB>q48#d`ZKlTfS+xB&vYZV8Gkp4TpdD=YoStt8`qDzJzHunu9RK8 z2@QCRLa8dcbs zJ>fv%>vqbXi8tVV7YXtavxCgl6N5KGrUTbG&xZHSOIA1iK{EYyAG=N)w8A=3_&61D z3Pn#3!XkI@D<^NTKRU*=mdN(a%5Kths8qRWOsVN02x=Tfs$j$uC}7un>VK5j;0ZvD z;W7*|(m`K-k3JXyEsEKvR$w}6*2ox|f$m_?!~y%C5`Rc*~t*^+gS=jf6hKn){U2 zGVHb9>eb{Y&0!sLB39Bw+dj1qAghypSy`m4*B*IIEq2_W9A)YM z$+iBC42(37iZ7s^0O!H59D8P-; zIaVQAT=jO-W+wTL-_+00sk{hxs9oN-`N7;&M|d~%;F5oh5c*J`cVp0CFVLvd(Mrl@ zaOo%vdQGD=ElakLInzCLVPjZ$P9`=bjrQsqTz9s8M?dNNrBUdo2R_sb!9J`O$ZNI*NslSKWl$M%K#e_L-@KpEW zQ&83j#xuwz)aUMa%M=GS@#PgG-{qAvBP(?7*{Ij7R~3SAzSfjhAia#finLiiZ-aw@*7e}jJmexu^yw|t+nwp;A}6I0Dx zeY+zx`Eqp4+VqQ<3&$n6p=_#=DXv{#IDHsKP~vu5m96HaIT@Qa#Zs< z*{i+e+0;{XVu0We>r8OvHpZD03X zc7X4$JgkUCeN7Cpx3k6s2c^kLL)|`(C_$H2$i~cV+>2OQMonAJ(+Fk$>USwAA=0qs22mv7NgQ z!P?Xf<+OqN5F9u@kj^dMH&JeBU)vS@_2w+BsE~-i5SpeG8^k-|0!wt|l3MH#+zF=0 zR;1@nAPh04N4H=0w7Qlh91OFi4h$r=O=ttIb=Mrb$CDD5LODJYAuWD$Mq9xce3 zL5>71U~uWj{KJ$0i9wIrioVXu)L*!Raossz?}nKhk-2y6_H!ZI4O+c9tZVQXu98?4 zQ=jGv|KOEe89F8IM|ng=qGquLm_;)U8`;xsSIxq)Uffhn{F0E|-7d&#uK=&8ey%(K zJWVvs%0XBsyD8y?AchNdWZ@lfcTuB;L@mB1s*m1n_qfty_uyW!;?}#!mBHiB5$Om9 z7cU(l*Vekf`4a(~QWbNjmve-oT{at`1+EZ61)|tgpRw%o= zY)(2|v;$_GtJ&Wc;ke-S1nOnf-vTPFlIc-~ZO5sUjuykW6>)Isik5O77^_=Rx4(}V zTh68w$z2m3j;NGNEg>Ig$a%QziPuyYwL zNBFj3Lc@-L`*NHw^4&}586QMW>NCaUUPt&SG=%%Lq^Dz39Ce~ykX(@CMIz8(a(MWC z5is`_F{T0EMrX=3uOg;aAqr(Ya0BvC-tehrR*CA0Y}d~DFj`Sc18eE>^5Ts3*Ltpg zs{JRga7U`MPeJ_RMfMas!UeZo@M)(hTt1h^3Xl8Qzrh-26|W%{lmPt3$=$1uj02iY z9$Swv-<)i~RuTb>*4m&dIq>g1%9-0)J^9>_Z9?jikv`%xYPn0$ayP=rKyaWd2(BX;L+PKFMjQIT0m{jk-sTxYk8o6 z(Y@>xp(e%*_=wi3#zP?UYfqD~{$=u|J}a;k5%r5jgFaxFs7J6SPO$EG{a%vO*iPI3 z7_?vHD!7Q(jtSP(CnVEk>>Nu4tP+9IzCFNuAq-B)w1aYKQjoM_qzFIgtr{-D`)-Ow z7`d`$ygDs}g*zhjyjv%n;Nyy9er$_DI+f1mucU6=i5!2IQdWg7mpwh=2v5;PV1bC# z#Juc|P$x?uv*r_3q-~4^)Pw0HfYZ5pnnNDsqpEZdAhp2d%|^tv-wPmjyz_C6b0eO) z6(Do6SZP~vA2aE+;A><5=R4Qwct#&A(Ti*B4eP_}HgZghf(8ctnz4Tw!kh^r%7@6H zUO~-|DAXfA__k7im67P8I6*yvT2nrVmxt;)+^w7Sn-lM51BD*(r|kv6+R z`ObN$!(-r%++a^vuQi^{)e!13hM(+aUAo}ys) z`*A8uTZf%N4b{^4x)%M#->XwrNqc@4PWDtv?8x0Al5?1<6I>!9+j6wUG|QEm9%-wl z*^hND?_D`*R2p|w7kgRX;vDwG?CCuQJGT)#gOb2K2%t?3wrvLo9k49oLjOVGv&XP3 z`xS}hCPR21=4jVgwC6iGy1!^0RgO@EyyK>Myvx{`F^+TbS|BC;;@PXcM zP%d9|y9s*^t#npJHD4bITq*SEP>;YI24NZbNUd`Kebq;nVCi0t3srhQ#wE{qlR|zF zK=)R#up)^ikX&8^#Yt#uz|WQ6%br+}yO?c&2tpg?R@tP7>>=u^Xh_ZZn0nX*}#0H zwoh<1yNf64X6o?HG0}xQJ!2v&WOBuSm!c^01I6(=oxC&#q*%ARgR_HJJ9KobnTXDYOh@RR8Xpn_ zxCV9>(Z$$rA?p3MLC)mhQkKvt)xEH+*XZ42Ytt&QGvU;)TGjPBP674zF!D6kXTtI0 zR2C|;`yOSQ|4uH?$412lJ7Ed=PZ6O4!o2i~m`(i;5do!`3nu0E$^R4)Oglovd0evC zl9OmY7N^nU2C0Jw5TtJZsbpP}Becq?zz*S#Z^AVAwRFPPjYznHowNE*uKxE;gxsM< z>Ti83J>JAtsY52!*(fd92NYmTVa6!)EEs@u_;fY-F{0*xQy6C1{^4gl#JGt4>w^ca zy;|!wl9keGPT7c3wUHyZZF5k6xcsyK5R@X?xtJ4WA?_3lWK{0BI*Ra|ihcdnxH?dt zu9#$ov8 zmecv`QAfX*^UrNh>+uZ}D*=;~Y?^@Zl2s%W>DxHRsaKp=<_9@?v5&Ck3AW*3CUB{0 z5ggt%!*~f%r6U+&%VCXrHrOVjP_aqUV+55w4v6;Y^{qJr2L+9av^g~A+AW1S9c;}Z zKkBB)H%XS@Khzd43{HeqIW-W*lLMF{m=BZNP-tp}_R|3xmQrejN{#*$oCGS^g*~lI z(=O(LM3}C_?RsG5vIQ_bSD#Qh(kdT}c!OczFI#9PYjYDwp6Elc!qirCUjs3^*qT$` z#a4x3Q|7M@9if`{a+4b%ZASGtuqnmX*9&ab1SG3Kca4j*?`0@F)g$p(d=nFZ3VkGJ z0$r6&G*<^%Z5)NrAH*ZE+=otyX6Q%->0l_*ptV3s5JHiQn~$NdO$n9c{1DyTvBuQ2 zN8I6`k&djKp)$6q$)eCrV&sM^Qr6c8Kk4Ce(cnKWcGo zepmkz!t|U)@pOsF+7OnyjVyBW&_R~(Br*+4r1MCg)eb_*2-}H<>s&KTLH_jyJw3I_ z&?Cgm16XQntDqZ>YwS2>PYx*LP7ZS7;@iS8Pi8$J<;h(+^3^CkFJ>RfkT+Hv#QlAa z737qL`s#zPEaHEej)$jpx4(nx>qCW3rP;t|J6AZuQ&HF1(3Ye6{ft;CP~U9G^mfmP zHUAWZxVKtsYHIG0Payu+>|U>~UMtv)hzYMW#ijif_469BFRR6gfjLw@J_vcQAU#)o zIbEV=x1j#sKGXu{{s0Vx>vm@V^?5HlwNToFkCjvl0vI%CxcZ%QI8MFHFg(LZK*a3? z06%A!ul_*n#HSF0FQL*}EYd2h-aYyjMvJmdlu7Nq9pTyfV&_1QH?EzNSaGZlhk_w{&E24Y&qP2K(`MH?t-WQPn_tS zcj`G*Z8@!wzVp4vp6wN^Nk|^b%E373n z(&}nl{ET`oDJaBuqmG@^8*pLji`k8fJ7MeQssn=bFzUT3>l*6B6=jMSI<1`#|CwBQ zLylbZlUIT)>&ex~Pfi$!uTZzMEJLpLTFrJo@td4+1lfdg3QcfEpkcTcx~D>qqz%Ck z$A>g?0dg!fU*iPa%P(Z`$uO%NJQZVBUFGofGiNdDu#8arYR1&}EXP8!wAhqm>E_x! z7hKWmxjV-IO}GA^LDT60y-u3nF~f6XA$H%Rbe}U71Ma{>Y{Om4lva%+d~9i;thSJP zwl^)0x-X2%^pCd_?0mRwihtODX=RcN{igQQ4TvsHdkB^F(Es-aME!3X!vD^&Ds)oz zrC^+T4>}0bKEfw*QtG_=@n7~*hat9%^f><&h}Kiyy=zAd=OVgfbJ>}7qii9vf7_2*=%W}ph;J6DjBoP&9WVr|Z4*uBzn!1@ z7zG_Uef!`NLKAJ#K0AN8w|2b(mUloKqJT!@=sV4GTct~%D)(N7lMh4`9#|V+_ar2B z#NeF-Y5{Vo-)Jp$j!nOlXR5x-i|dj@6!H?H;T#6A@X1HfhOR|}{|eM=xHvO~A-AIR zB6)^~rqPHN9AD*L!ROx2$&1MMG-8{^L9*mt)Q*x=V^|-|hOT6O(Oo;iws%QdXEK=W zGqX6QsPAP&5(LTQ|Dtzo-koTOElH_uyb*#smuN2lztXCm8aCT^>?;2a$H0o{)=Vl6 zdexZ_H9W&oyV^pu26zOhd;VLJ#w@O9%ZYGoqP;+DlQA{ieN9a4snjt0DzZ9~Qb}4? z$`$SVZg`ps3FB)$PBY$&<>qI|7qDvd;Zw}F^QogbY`YzxVt^(DXeMc^M0cO$Y!c`% z5Qw>DqyhQYI8WE(eXbEznl3%?epFOcS!4jN_L2Ad2twjSEd&|+M!De8fMC6*!oUhS zMr~p0Z;InzCC0q@DYGUYDSbHL&L6f z=cH1zFOXe{X}VX}>Ul`8C=F3RysyN`^o@w9NjQP}VPT+NhN`m1@@HuFU&WPqh8G4> zfGI<{m@+B(IrjJiae@cJgS(YG!Um*r)r3|1222E~Aa(sJ)^PFF7BTq^G>BKqKh{jq zqwri6@b)GUH`A>V>%9QxN?ju`Mzkb0vP9REs17Ab`p9gUKFR4yo|aN`+_+@;R=Cc9 zbp>`-_P-W6FO?ts89_Cg-$U1R1n`Wf5e{R?9S`nz5);=ztK!9i7PpbCj`ctd&;p5r zveA^oCz#7rFS$knjv)S$=zMAE>JIV*u()^?XfQYu4gtumiH;P`;YckoRxz5fy2Zzz zH&Di&Y#JWwc3^8D@tTkbJhB6Nnr9d@o#BYAzUs zYih8DZeaPL4qzYBV3518M|Y}W?JWn3eT?`@;r3s(|H|VTsOE|;jh9?6tVEq3ao;Tu z%3Md~@oekSd=wBM1c(69nip5~f&Jn^PxmwwQMkTEDk8RVRL@1m#l~(nSRL(nKir8Y za|3qFvQ1{i7>k|R>+g7qT8vtv;VELtPR*fEJynaWGfX~qdM#qIEMA69N9?Z~6D^=V zUnWv(v<|2xV+va8a(n|#Hbgai69`7CzP`RYPRg45=>LDFAvAaL$9r^7kj(Dtu_MSu zGJ#=7J?W?S!=FbprnAybo*i)v+ktH;YrF~6x&mD|W(-IDI!l)9{e{$h?hyqIx%xb7 zhyoWO?{}r1`prP9J&o0{z*F4@FvF(qzr~57G7C(pgo1ER=jzzaw+oh897DPjJX#0! zw(W371!$JIhYIGy_4qYcYS0LCMtoG`%}VZOHE=4Gnh)5fD}PGm>)FjlCRruB3`jpp zT0FsNW5$i*E4|=Q9AVeAJr_0-{TW9(*ZlhpK~-J$WTmD6F?*bEa1sv#E`&I!GnrgE z%uoFZoC~2xG}IslOFj~w_EMt6vfbfn5|a@<cbB zds-Vw)OYe+pT=yMFV@=5DV<>a3BxU?CnYO>i?O&twnZH1tS>0 z%igAkOHoR}j6tJLKrH+{in}YdAm`o2XQ_9AB$81xR1Fi*X?#=<96MShEHG6QimjO0 zjZu%M;D8pYeDR69_O zBHZ2)`U0cMKpmqmdE390VoTnryywEmuxJ!9={4AC5`lj z+8G$Hs!xfI9&4uKfe)?9V2B5zYMyjnEHY+?o2mZYn;e0>k97bj3teyie-NL8s3C(MU z5fzhFV>BR2A}~cXjubQmAthKH^c|}NWBmS??l?dmA8@}?xMql&vSy3&LgNviyI?kF zZYGF(Jl~SZk5B9mn3El#Y`%K18W9!N!k7--KkmNM9r@$~Ya2V!ED-_oCCz=f8m-i~ zH$f5Xq8`(e)!mPf`5Ywua)!`u3>nz?rE9L(?#>4I`CLtna5M?{Aphu8y>xqip~U7I z>D|CH#>)Oa+qVDn?UkIhp!2d$@<|XyC$IZ9-r^4+(>+yI%H zx1KuG+S)p6NTbQf(}A)83zP+t7a>F#QR;rZJ%@(w3nE9(obwl>ZdMN{`#{Eght2gd(sn@8A%F8$ zza~iO;r8e~fs^2xq?Fpu!pvRBV`y4^+P%YrGgRzL=&I5>N4B9dm@Pz%%wEXtac4g| zdX2TCL#Yfgu7|$o_c8j%-hy%tO~4KPNCo@Zve}o>D}Gktdx|Os-py&JQ&^D0PeqbR z6R5F|*pwL{45oDzxzsZYsgDrVBxGtiLV4o;n+Zx?X^%Be+^~&;3Cb#Q-8q;cVE*VH zKmMBu%6I6NsFG<$%328g`|7(7Y^PD)N4aP$YPY7^;eg=&s7eFmUPYv)N}t5e*qtNV z#CgLl)0{O5%vj_Dshcg6W}dqWW%dFUy8~^l3p+j$0evG~{gK5j_Q`6kH%-xVO~&3C zqsSb-2AUm#n4z>{6f5xIniJNVK#Ad{<9B%Ft_jLE0I2v8Adc}IvfZH6?pP^J5)5C4 zq*h=`Ropj(+8GE%3n|H88^5cjtgMWdv0~FA1hP3Sya8Hm@rLEeFa>;3v0uXzEp<%+ zixxEbIluhv6fhbARU_$xb+@7q%?+wRDtQ|A(%&C@o;|wh8fkQ{B3|fm{GIb+Q9{@N zf_~_Edo#@2-?rb+0GMA-b%XiR=`_QAB4TE=ah5ho(?*AXiVHcZ3{O5DzHTjqoWE{v z;|bAQ-U>!vZ#YN0r80al;&2|LIrIIb$s=?e1jhf5RLXnht?A0q?yq2+llimE`LYHY z`XR&bV;D?-y1Y{|%ILR8_f`q?Dy{>`P$41)#-i3Oq>j*Yl{98~1!=s|1JFv)C&Mjs z*}A#(_9(0*C-yu-g58h|_!@l$fzpzcBR!Eki%m?R*l7iP`< zLtE)E?;iV_mN%I3Nb5KyrcLNV$rx@Q{Sg-R@aDEeRGII|0gDk_06wf7Bo18@Lpb%yVO3j0R6B^+>nMWr*B{Uv?+YvJ@;VR9~}$1>$8 zs_-2T*2Ce`wKbZ5zis)QGo32;aV@JN!@`_)4Np9ba98OJ$Z4gie&>(^jC$UT+(Az+ zwhnHbHT;q>L`qG~C@$ax$SWz(0-&PCj)V18V|qOUr%(TGnE_R@3=Bx%%`>(?OA4p` zd53qZo9(|nBemh4ZF$nNPouYtAHUWKIuiko39oA?U?IH&n$3&Q1!^y3R97gIhw#^b zD>X99{2@J`uS9LL8t&va&fvD0l;C%yMp7nV_fAZ85#P6B^d%|e!~LMP zB0$17?Y|Yi`JKy;>Cu3Qi=FRie_=-Lg*N@b1du;qfZ{{XnJ?S}HnZMsa_=5Hs1Z#% z)0t6dikov*q`45*FK5n>-A21%q*hMH^fqbrCkfR}jB_}->|%NA-K zXr#9Zw3)a+&+zGm{GRwGnpE9DPfEl5Z`K|6Q&I_}_L(E-Mj^bLfu`q^XN0Sy75bI( zyN|CT=Qld5Y0e0UL}eHCSxyrlqX{bn1@lw~&dLlBIb4a-3%_H4=3c;i-Q17)c?XMD zAamhev=5%K!x%HtM8FJ?@w96rir0w3$fHB3QlMEi?DZLI zgf)GR#BYj!D}IA~(R?I*lV*q3=({^4?U_!e6StsNAB{U+r3a(!kB}KNaJp+jGmY#b zq;qg5{(8DeM3BDwYZO-8dDqO{aB$z8Sm=LoY9DC zK#LHfaKSeaUV8Q=)V`LhqiO2&(;(ak)i%8d1zj^~VL6=No)20G*@k7-fUFo8Yb?p^ z1Ua!%!>6=#*2c@_R(^coTS_bN{#}f_M>!7ASQT0!O0&dV&^bjjXGn+5T^5HuSA~7$ z4lVpt<_Fx1n1>JbyG!PI(y?-U8|oaZiiv~N92lw`XFH`G!i$P?AYTV$9*9>>4Cm5i z*!76}p$=P4qoSLz>0%-U>7?9}N%|GAVb=FT0#PcTgnY{#VZavDQ_s(Y3=8t0=PE0c z1>+shE!o!5P^#gpAS`>hK@WmPN{3xd1F974rV7PUdML>( zAA;7xc9&C)NrJN?tu$kd))&G1Ty^yti1M#>LcVtuLg}buG14$vS2e~2DJ3!x=K2wj zGmj+?GHlJ+g`5ogRClew0(<2esb{)mM+RxK7YeX5!(O;HJ8;rMr;~Cz&%*rbsdpg0 z`Oxg^Y*#Le1WVVjScN+uEI_h{(YPK3Oo!*1a;Y>-W@{2gPD{)~4*%_txC3&fD`ES$ z+fm7)?~4P$9ZSxy+Meg7Nx{%yd2CO2j8ZoRamYFnsuw?rTwx|oE#bzaL&f`C?(iEV zzVr{fTnoaKc2FSlrU&+D8QTZi8&U-U0lSti>N%U)&+u&LgcNJQbQ3%DzjD%_JZ~OtszCu@9 zoRN`1C*!AS9y_S%9kOs$bW)6B6=6S#z}cDNp+S`y@`Y+XfRRhY?X?}NF;Te*+*xixyh*M(h4QTnWO5p z?4Y2dppvOVW-Ov-CqcZ@rIPt8QcK3CT^X~39YEUrTHS7-j_|bwU&2=Bl9mh+oeq1`+g*m}p6ctc0V^*ZKyN)FAeWYBrEOJ& zG^HOW`5R{RQBzP}WTq*VlmAH{HF$c5az$0U2)aTR{L;ib+o4tB8>|MFKb?<}OWdUj zK|l9~F(nw@{~LQqcOoagScO&6Qr)i1NQXpjKnyEo_wLh4J6If;Z@5WY!Hg{C$mJrq zIOc9>DqXEHMW6n;f!Y)2u?{3`y-=4c3ZtZ09?;2YZB`E)VC=u~0U>@iTaC17kE#W< zq&J#`At!0}7RIcn7P+LeT~`$eXg>@c5?Dd{;Rl%@8aGDQqm3v_osyh~>ZAs-Q+^J# z?q`PEa#PpQI)R?3l0Vn*Gq$xR&^-%kXzeEI4qC<8#|xr+_mAD1_S zCLEV~jsi&?uTH?11T#uWNRw(M*Z@)_%gUPn*e)3$;k`)Y*rq}C3~dXJ2iME}a|dZ%B|UJQ=C+wV~>;6XMCy%cmeyHWuCZaaC@M z3*>iwrQYQ{WTm010XVHH0U-i9v5)73zG zAJc~hzjCf@8``4=(;Add=X@b$H&^1sArO$%)YhK2*ZY@ff#yhGRje?B0g@68G=6## zWlm3(cD`C%CaR1y{ktV@a4^bIPUjNgrk5xcLL>RM3BmLv)ZD#BFFPsp^~XwhM48+5$BHGft=O~go7w`NJ=m7zf`Oi&&X4t znuJA!B#nNtd>1&et9wdMtSEyCVOrJQgedGTtud<3#UE=$h4EgmjTbTk(PM2cHL_a}e%KdhnhPn<&Igr%lp06K1hTNYDU>Jw@8)q7heC z5*=4H^5;ztN0oM7Hw(w?1ySpfj<5;z-J*KnXONm1S3R+Y^cGYiH03+g{SHKl5m)EOG ze9+4#`7Wwm?TgB54SWoMKh)2lBPIf+aaz@vmk9b*xD}*iE0LsqFp|jMYXE)8e$k1y zo8rfBjwLBG@R%IXVJJAK2hQ098YACd)V3bmW)a)Ty*kSM&q3?ihj8^FTZ}qe+Wi zP3NH?$3B^RB57IEv)Q{@e@cILai2VJvO*1oJbbhP{c!`30!gToMUxqzbV;895<<*z z`&fe>u&f6@T=8CU(sV?0EqcYWTWfDz&LIRo@E1n83*B8?9CxP#B=Php@xk0F>I ze}pKtCD;O~{o`JJhcCVU%E~kEzBuRI_N1V#H@B}k;{8JT?vsBmc(O764~H*5Rb2Ld z@sq2QKa4GVhMRfwv$a4(8Dl+dz;j&47MI9|35_#zZEx zZDxV48@)@=Dj24Reo%2ot3GQhpx{Hlsu(5ii@$;Wh~f&`wI}`hfR|qgpUaT+iN{)a zX)F3n2lZV*!bZO^Xevk9p0hrqiEzqIAWk{90j G`2PX*g- { body: payload.body, tag: payload.tag, data: { url: payload.url }, - icon: '/favicon.ico', + // .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)); From 3d1fbe2cd0646a030181a2c871fe267cdb8f15ae Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Fri, 30 Oct 2026 18:23:24 +0000 Subject: [PATCH 16/25] fix: Findings disappearing mid-review, and tab/URL state on review detail Findings disappearing: forced a full remount of the panel every time the overall review status changed (created -> in_progress -> completed). That wiped its internal state - already-loaded files, streamed-in comments, the comment nav count - back to a loading spinner, which then rebuilt from scratch. Visible as findings and the comment-nav widget (the '-/N' control) flashing in and then disappearing moments later. The key was a leftover from before DiffViewerPanel grew its own internal event-driven polling (reviewStreamState.ts) that already refetches once the review completes - matching how git-lrc's own review UI (~/bin/git-lrc/internal/staticserve/static/app.js) never remounts on a status change either, just updates state in place. Removed the key. Tab state / back button: activeTab on the review detail page was plain useState, so refreshing the page always landed back on Findings and the back button navigated away from the page entirely instead of undoing a tab switch. Moved it into the URL (?tab=...) via useSearchParams, matching how DiffViewerPanel's own file/hunk/comment navigation already works - a reload or shared link now restores the actual tab, and Back/Forward steps through tab switches. The one programmatic tab change (jumping to Findings when a review completes while the page is open) uses replace so it doesn't also add a history entry for something the user didn't click. LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%) --- .../routes_guide/reviews/review-detail.md | 4 +++ ui/src/pages/Reviews/ReviewDetail.tsx | 33 ++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/internal/docindex/docs/routes_guide/reviews/review-detail.md b/internal/docindex/docs/routes_guide/reviews/review-detail.md index 41230495..546fb26f 100644 --- a/internal/docindex/docs/routes_guide/reviews/review-detail.md +++ b/internal/docindex/docs/routes_guide/reviews/review-detail.md @@ -27,6 +27,10 @@ Any authenticated org member; the review must belong to their org - 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. ## When comments couldn't be posted to the PR diff --git a/ui/src/pages/Reviews/ReviewDetail.tsx b/ui/src/pages/Reviews/ReviewDetail.tsx index 426da5ee..a678dc0f 100644 --- a/ui/src/pages/Reviews/ReviewDetail.tsx +++ b/ui/src/pages/Reviews/ReviewDetail.tsx @@ -1,5 +1,5 @@ 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'; @@ -83,7 +83,26 @@ 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'); + 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([]); @@ -279,7 +298,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]); @@ -744,9 +763,13 @@ const ReviewDetail: React.FC = () => { {activeTab === 'findings' && (
- {/* Keyed on status so the findings reload once the review finishes. */} + {/* No key={review?.status} here - DiffViewerPanel has its own + internal polling that refetches on completion (see its + header comment). Remounting it on every status transition + used to wipe already-streamed comments and the comment nav + back to a loading state, then rebuild them from scratch - + visible as a flash of findings disappearing mid-review. */} Date: Fri, 30 Oct 2026 19:15:10 +0000 Subject: [PATCH 17/25] fix: Findings disappearing mid-review, add time-to-first-comment tracking Root cause: a single failed Gemini batch wiped review_result entirely, so a review that streamed real findings live could still end "failed" with zero comments. The Gemini batch loop now tolerates partial batch failures the same way the LangChain provider already does (only hard-fails if every batch fails). On the frontend, streamed comments are now only cleared once the review completes successfully (shouldClearStreamedComments) - a failed review keeps whatever already streamed instead of wiping it to match an empty "authoritative" result. Also ports git-lrc's time-to-first-comment tracking (review_performance_state.mjs) in full, including per-comment "Appeared in Xs" timing, wired through FileBlock/HunkBlock/CommentThread, plus a "First result in Xs" readout on the in-progress banner. Adds adversarial unit tests, a fast-check property-based test proving the no-silent-loss invariant on the failed path, a component-level regression test reproducing the exact bug end-to-end, and Go tests for the batch partial-failure tolerance (including the all-batches-fail safety net). LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%) --- internal/ai/gemini/batch.go | 38 +++- .../tests/batch_partial_failure_test.go | 169 +++++++++++++++ .../routes_guide/reviews/review-detail.md | 9 + ui/package-lock.json | 41 ++++ ui/package.json | 1 + .../reviews/diffviewer/CommentThread.tsx | 55 +++-- .../diffviewer/DiffViewerPanel.test.tsx | 194 ++++++++++++++++++ .../reviews/diffviewer/DiffViewerPanel.tsx | 64 +++++- .../reviews/diffviewer/FileBlock.tsx | 10 +- .../reviews/diffviewer/HunkBlock.tsx | 8 +- .../reviews/diffviewer/reviewPerformance.ts | 147 +++++++++++++ .../diffviewer/reviewStreamState.test.ts | 170 ++++++++++++++- .../reviews/diffviewer/reviewStreamState.ts | 20 ++ 13 files changed, 891 insertions(+), 35 deletions(-) create mode 100644 internal/ai/gemini/tests/batch_partial_failure_test.go create mode 100644 ui/src/components/reviews/diffviewer/DiffViewerPanel.test.tsx create mode 100644 ui/src/components/reviews/diffviewer/reviewPerformance.ts diff --git a/internal/ai/gemini/batch.go b/internal/ai/gemini/batch.go index da94b74a..ac095e16 100644 --- a/internal/ai/gemini/batch.go +++ b/internal/ai/gemini/batch.go @@ -80,8 +80,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 +96,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 +122,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 +140,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 00000000..c2de70a9 --- /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/docindex/docs/routes_guide/reviews/review-detail.md b/internal/docindex/docs/routes_guide/reviews/review-detail.md index 546fb26f..9ff458ad 100644 --- a/internal/docindex/docs/routes_guide/reviews/review-detail.md +++ b/internal/docindex/docs/routes_guide/reviews/review-detail.md @@ -31,6 +31,15 @@ Any authenticated org member; the review must belong to their org 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. ## When comments couldn't be posted to the PR diff --git a/ui/package-lock.json b/ui/package-lock.json index 4a4be288..8d829259 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 e3262c83..0ef492a6 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/src/components/reviews/diffviewer/CommentThread.tsx b/ui/src/components/reviews/diffviewer/CommentThread.tsx index 61591a38..55655756 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 00000000..51667b90 --- /dev/null +++ b/ui/src/components/reviews/diffviewer/DiffViewerPanel.test.tsx @@ -0,0 +1,194 @@ +// 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 { 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) { + 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(); + }); +}); diff --git a/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx b/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx index f9e1b527..1c3ee32f 100644 --- a/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx +++ b/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx @@ -10,7 +10,7 @@ // (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'; @@ -25,6 +25,7 @@ import { extractExternalCommentsFromEvents, extractNewEvents, inferReviewStatusFromEvents, + shouldClearStreamedComments, sinceWithOverlap, } from './reviewStreamState'; import type { RiskScore } from '../RiskAssessment'; @@ -47,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; @@ -132,6 +134,25 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport 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(getPerformanceNow()); + 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>({}); @@ -174,6 +195,8 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport setStreamedComments([]); seenEventIdsRef.current = new Set(); lastEventTimeRef.current = null; + baselineMsRef.current = getPerformanceNow(); + setCommentRenderTimes({}); getDiffReview(reviewId) .then((res) => { if (cancelled) return; @@ -233,7 +256,14 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport .then((finalRes) => { if (cancelled) return; setData(finalRes); - setStreamedComments([]); + // 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(() => {}); } @@ -425,14 +455,25 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport onHunkClick={jumpToHunk} />
- {isRunning && ( - }> -

- Findings are streaming in as each batch of files finishes — {navComments.length} so far. - This list will keep growing until the review completes. -

-
- )} + {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' && ( }>

@@ -505,6 +546,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 498e4837..42075299 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 21c67275..beb8f124 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 00000000..e9d269fb --- /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 index 2c262a6a..654df237 100644 --- a/ui/src/components/reviews/diffviewer/reviewStreamState.test.ts +++ b/ui/src/components/reviews/diffviewer/reviewStreamState.test.ts @@ -1,12 +1,14 @@ +import fc from 'fast-check'; import { appendStreamedCommentsToFiles, extractExternalCommentsFromEvents, extractNewEvents, inferReviewStatusFromEvents, normalizeStreamedComment, + shouldClearStreamedComments, sinceWithOverlap, } from './reviewStreamState'; -import { DiffReviewFile, ReviewEvent } from '../../../types/reviews'; +import { DiffReviewComment, DiffReviewFile, ReviewEvent } from '../../../types/reviews'; function batchEvent(id: number, comments: unknown[], overrides: Partial = {}): ReviewEvent { return { @@ -133,3 +135,169 @@ describe('sinceWithOverlap', () => { 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 index 45cf6953..af46df34 100644 --- a/ui/src/components/reviews/diffviewer/reviewStreamState.ts +++ b/ui/src/components/reviews/diffviewer/reviewStreamState.ts @@ -197,6 +197,26 @@ export function extractExternalCommentsFromEvents(events: ReviewEvent[] | undefi 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 From dadaad487122cdb78148d936ab4e80d7ade8b3f5 Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Fri, 30 Oct 2026 20:30:12 +0000 Subject: [PATCH 18/25] fix: comments dropped by flattenFilesByRisk once blast-radius data arrives Root cause (traced against review 13528's actual server log, which showed the backend streamed and kept all 26 comments correctly): the disappearing comments were a client-side bug. DiffViewerPanel defaults to the risk-flat sort, which dissolves every file's hunks into synthetic single-hunk entries the instant any hunk anywhere in the diff gets a blast-radius score (arrives asynchronously from a separate server-side job, after the AI review already streamed its findings). flattenFilesByRisk assigned each comment to the one hunk whose line range contained it and silently dropped any comment that fell in the gap between two hunks, or outside every hunk's range - a comment that was visible under the plain per-file view vanished the moment the view switched to risk-flat. flattenFilesByRisk now partitions each file's comments across its hunks in original order and queues any unmatched comment onto the file's last hunk instead of dropping it, so the total comment count is always conserved. Adds a fast-check property test proving conservation across arbitrary hunk/comment-line combinations, deterministic unit tests for the exact gap scenario, and a DiffViewerPanel regression test simulating blast-radius data arriving after comments are already rendered - both fail against the old filter-only implementation and pass after the fix. LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%) --- .../diffviewer/DiffViewerPanel.test.tsx | 63 ++++++++++++- ui/src/lib/blastRadius.test.ts | 94 ++++++++++++++++++- ui/src/lib/blastRadius.ts | 51 ++++++++-- 3 files changed, 194 insertions(+), 14 deletions(-) diff --git a/ui/src/components/reviews/diffviewer/DiffViewerPanel.test.tsx b/ui/src/components/reviews/diffviewer/DiffViewerPanel.test.tsx index 51667b90..a7d083e4 100644 --- a/ui/src/components/reviews/diffviewer/DiffViewerPanel.test.tsx +++ b/ui/src/components/reviews/diffviewer/DiffViewerPanel.test.tsx @@ -9,7 +9,7 @@ import '@testing-library/jest-dom'; import { MemoryRouter } from 'react-router-dom'; import DiffViewerPanel from './DiffViewerPanel'; import { getDiffReview, getReviewEvents } from '../../../api/reviews'; -import { DiffReviewStatusResponse, ReviewEvent, ReviewEventsResponse } from '../../../types/reviews'; +import { BlastRadiusReport, DiffReviewStatusResponse, ReviewEvent, ReviewEventsResponse } from '../../../types/reviews'; jest.mock('../../../api/reviews'); jest.mock('../../../api/githubApp', () => ({ @@ -22,10 +22,10 @@ jest.mock('../../../utils/notify', () => ({ notify: { success: jest.fn(), error: const mockGetDiffReview = getDiffReview as jest.Mock; const mockGetReviewEvents = getReviewEvents as jest.Mock; -function renderPanel(reviewId = 1) { +function renderPanel(reviewId = 1, blastReport?: BlastRadiusReport) { return render( - + ); } @@ -191,4 +191,61 @@ describe('DiffViewerPanel - findings streaming / disappearing-comments regressio // 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/lib/blastRadius.test.ts b/ui/src/lib/blastRadius.test.ts index 41b66893..e1f837a6 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 4dba1bff..3b38df9c 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, })); From f62d3a48d2e4657863f76a97d5d4172172b91a51 Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Sat, 31 Oct 2026 08:11:50 +0000 Subject: [PATCH 19/25] fix: reviews starving on 2 workers and lying about batch failures in Events tab Root causes (found via live-testing a real review on prod): batch worker count defaulted to runtime.NumCPU() (2 on prod) regardless of batch count, starving later batches until the 10-minute review timeout killed them; the Events tab had no way to distinguish a batch's terminal failure from a transient retry error, so a stale fileCount rendered as fake "Generated N comments" for batches that produced zero. - Replace the fixed worker pool with goroutine-per-batch + a semaphore sized to a configurable per-review ceiling (default 16, no longer CPU-derived), and add a global, self-tuning (AIMD) per-AI-connector concurrency limiter shared across concurrently-running reviews (internal/llm/adaptivelimiter, internal/llm/errclass). - Fix a retry-loop bug where a cancelled context only broke out of the inner select, not the retry loop, letting it burn through every retry near-instantly instead of bailing immediately. - Emit an explicit terminal batch-failure event distinct from a retry log, and stop using a batch's file count as a comment-count fallback in the frontend, so a failed batch always renders as Failed. - Cut review timeout 10m->4m and per-batch LLM call timeout 5-8m->90s. - Add instance-wide (system_settings) and per-org (org_review_ai_settings) configuration for the concurrency ceiling and review timeout, clamped so an org can only turn these down, never above the instance limit. LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%) --- ...070300_add_ai_review_performance_prefs.sql | 26 ++ db/schema.sql | 9 +- internal/ai/gemini/batch.go | 14 +- internal/ai/langchain/provider.go | 75 ++++-- internal/aiselection/aiselection.go | 21 +- .../api/ai_review_performance_settings.go | 41 +++ .../ai_review_performance_settings_test.go | 200 ++++++++++++++ internal/api/aiconnectors.go | 59 ++++- internal/api/review_service.go | 2 +- internal/api/reviews_api.go | 29 +- internal/api/server.go | 3 + internal/batch/config.go | 15 +- internal/batch/task_queue.go | 206 ++++++++++----- internal/batch/task_queue_simulation_test.go | 248 ++++++++++++++++++ .../docs/routes_guide/ai/ai-providers.md | 6 + .../docs/routes_guide/settings/storage.md | 14 +- internal/jobqueue/review_worker.go | 14 +- internal/jobqueue/scheduled_review_worker.go | 8 +- .../llm/adaptivelimiter/adaptivelimiter.go | 189 +++++++++++++ .../adaptivelimiter/adaptivelimiter_test.go | 231 ++++++++++++++++ internal/llm/errclass/errclass.go | 123 +++++++++ internal/llm/errclass/errclass_test.go | 89 +++++++ internal/logging/review_logger.go | 26 ++ internal/logging/review_logger_failed_test.go | 125 +++++++++ internal/review/config.go | 9 +- internal/review/service.go | 52 ++-- internal/reviewperf/reviewperf.go | 147 +++++++++++ internal/reviewperf/reviewperf_test.go | 72 +++++ .../aiconnectors/review_ai_settings_store.go | 30 ++- ui/src/api/connectors.ts | 32 ++- ui/src/components/Navbar/megaMenuData.ts | 1 + .../reviews/ReviewProgressView.test.tsx | 122 +++++++++ .../components/reviews/ReviewProgressView.tsx | 19 +- ui/src/pages/AIProviders/AIProviders.tsx | 74 +++++- ui/src/pages/AIProviders/types/index.ts | 18 ++ .../AIReviewPerformanceSettingsTab.tsx | 155 +++++++++++ ui/src/pages/Settings/StorageSettingsTab.tsx | 3 + 37 files changed, 2344 insertions(+), 163 deletions(-) create mode 100644 db/migrations/20261031070300_add_ai_review_performance_prefs.sql create mode 100644 internal/api/ai_review_performance_settings.go create mode 100644 internal/api/ai_review_performance_settings_test.go create mode 100644 internal/batch/task_queue_simulation_test.go create mode 100644 internal/llm/adaptivelimiter/adaptivelimiter.go create mode 100644 internal/llm/adaptivelimiter/adaptivelimiter_test.go create mode 100644 internal/llm/errclass/errclass.go create mode 100644 internal/llm/errclass/errclass_test.go create mode 100644 internal/logging/review_logger_failed_test.go create mode 100644 internal/reviewperf/reviewperf.go create mode 100644 internal/reviewperf/reviewperf_test.go create mode 100644 ui/src/components/reviews/ReviewProgressView.test.tsx create mode 100644 ui/src/pages/Settings/AIReviewPerformanceSettingsTab.tsx 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 00000000..625b7972 --- /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/schema.sql b/db/schema.sql index 0d0fd136..24a6299a 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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))) ); @@ -10598,4 +10602,5 @@ INSERT INTO public.schema_migrations (version) VALUES ('20261029150000'), ('20261029150100'), ('20261029150200'), - ('20261030134721'); + ('20261030134721'), + ('20261031070300'); diff --git a/internal/ai/gemini/batch.go b/internal/ai/gemini/batch.go index ac095e16..c680b7fa 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 diff --git a/internal/ai/langchain/provider.go b/internal/ai/langchain/provider.go index 89402d7d..efcde36e 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 408899be..ff62706f 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 00000000..524bad78 --- /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 00000000..5ee3300d --- /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 2cb18d32..7e25a750 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/review_service.go b/internal/api/review_service.go index 21e58caf..0553831a 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) diff --git a/internal/api/reviews_api.go b/internal/api/reviews_api.go index 4821e847..aa4de39a 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 b6b61d43..492f8e9d 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1484,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/batch/config.go b/internal/batch/config.go index d943b36f..bee2a22f 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 20b70ca1..923f7a81 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 00000000..eac231f3 --- /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/routes_guide/ai/ai-providers.md b/internal/docindex/docs/routes_guide/ai/ai-providers.md index 6e1ee95c..3b696c0c 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/settings/storage.md b/internal/docindex/docs/routes_guide/settings/storage.md index 55573f20..b94ca5e8 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 9998937d..ec70fa94 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 ce9796dc..22ef299a 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 00000000..ed865110 --- /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 00000000..923139ab --- /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 00000000..2f2f29ac --- /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 00000000..5bd05ae7 --- /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 b9df6199..190c1f5b 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 00000000..700522ec --- /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/review/config.go b/internal/review/config.go index 267ddef5..9ebeea34 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 c33d815b..bd3b7840 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/reviewperf/reviewperf.go b/internal/reviewperf/reviewperf.go new file mode 100644 index 00000000..08b12078 --- /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 00000000..fe42c27c --- /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/storage/aiconnectors/review_ai_settings_store.go b/storage/aiconnectors/review_ai_settings_store.go index d2d96646..d84bfe8a 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/ui/src/api/connectors.ts b/ui/src/api/connectors.ts index 56582243..9d72ffc6 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/components/Navbar/megaMenuData.ts b/ui/src/components/Navbar/megaMenuData.ts index 714f3a29..9eb75f5e 100644 --- a/ui/src/components/Navbar/megaMenuData.ts +++ b/ui/src/components/Navbar/megaMenuData.ts @@ -260,6 +260,7 @@ export const buildMegaMenuSections = (): MegaMenuSection[] => [ link('Log Compaction', React.createElement(Icons.Clock), '/settings?section=log-compaction#storage', (ctx) => ctx.isSuperAdmin), link('Preloaded Changes Archival', React.createElement(Icons.Folder), '/settings?section=archival#storage', (ctx) => ctx.isSuperAdmin || (ctx.orgRole === 'owner' && !isCloudMode())), link('Repo Cache', React.createElement(Icons.Folder), '/settings?section=repo-cache#storage', (ctx) => ctx.isSuperAdmin || (ctx.orgRole === 'owner' && !isCloudMode())), + link('AI Review Performance', React.createElement(Icons.AI), '/settings?section=ai-review-performance#storage', (ctx) => ctx.isSuperAdmin || (ctx.orgRole === 'owner' && !isCloudMode())), ], React.createElement(Icons.Settings)), ], }, diff --git a/ui/src/components/reviews/ReviewProgressView.test.tsx b/ui/src/components/reviews/ReviewProgressView.test.tsx new file mode 100644 index 00000000..d0d4a7ab --- /dev/null +++ b/ui/src/components/reviews/ReviewProgressView.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ReviewProgressView from './ReviewProgressView'; +import type { ReviewEvent } from './types'; + +// Replays review 13536's real raw event shape for a batch that exhausted +// retries and failed permanently with zero comments (confirmed via the raw +// /api/v1/reviews/13536/events payload: only repeated "request timeout" LLM +// errors for batch-6, zero completions). Before the fix, nothing ever told +// the frontend this batch had failed - it only saw a "processing" event +// (which stashed fileCount as a comment-count fallback) and transient error +// logs, so some later loosely-matched event flipped it to "completed" and +// rendered "✓ Generated 6 comments" for a batch that produced none. +const batch6ProcessingEvent: ReviewEvent = { + id: '1', + timestamp: '2026-10-31T04:54:45.302Z', + eventType: 'batch', + message: 'Batch batch-6 started: processing 6 files', + details: { batchId: 'batch-6', status: 'processing', fileCount: 6 }, + severity: 'info', +}; + +const batch6TransientErrorEvent = (n: number): ReviewEvent => ({ + 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')); +}; + +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(); + }); +}); diff --git a/ui/src/components/reviews/ReviewProgressView.tsx b/ui/src/components/reviews/ReviewProgressView.tsx index 369b8bd2..6770df07 100644 --- a/ui/src/components/reviews/ReviewProgressView.tsx +++ b/ui/src/components/reviews/ReviewProgressView.tsx @@ -352,6 +352,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 +381,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 || '')); diff --git a/ui/src/pages/AIProviders/AIProviders.tsx b/ui/src/pages/AIProviders/AIProviders.tsx index d039b4a9..23243859 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 d1b49337..9c5736a3 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/Settings/AIReviewPerformanceSettingsTab.tsx b/ui/src/pages/Settings/AIReviewPerformanceSettingsTab.tsx new file mode 100644 index 00000000..4617b521 --- /dev/null +++ b/ui/src/pages/Settings/AIReviewPerformanceSettingsTab.tsx @@ -0,0 +1,155 @@ +import React, { useEffect, useState } from 'react'; +import { Button } from '../../components/UIPrimitives'; +import apiClient from '../../api/apiClient'; +import { notify } from '../../utils/notify'; + +interface AIReviewPerformanceSettings { + max_concurrent_batches_ceiling: number; + min_review_timeout_floor_seconds: number; + max_review_timeout_ceiling_seconds: number; +} + +const ENDPOINT = '/api/v1/admin/settings/ai-review-performance'; + +// Instance-wide safety bounds for AI review concurrency and timeout. These +// are ceilings/floors, not targets: per-review concurrency is actually +// governed by a self-tuning limiter (see internal/llm/adaptivelimiter) that +// discovers the AI provider's real rate limit on its own. This page just +// sets the outer bounds that limiter - and any org's own soft preference in +// AI Providers → Review AI Settings - can never exceed. +const AIReviewPerformanceSettingsTab: 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/StorageSettingsTab.tsx b/ui/src/pages/Settings/StorageSettingsTab.tsx index 6c080b3c..b79f58b7 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' && }
); }; From 75c9c47af34f60f90b47f643bf57bc2bda89610f Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Sat, 31 Oct 2026 09:27:08 +0000 Subject: [PATCH 20/25] fix: review-detail/table UI truthfulness issues, resizable columns, stale 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%) --- ...61031120000_add_review_commits_message.sql | 10 ++ db/schema.sql | 4 +- internal/api/diff_review.go | 5 +- internal/api/review_commits.go | 2 +- internal/api/review_coverage.go | 7 +- internal/api/review_service.go | 6 +- .../routes_guide/explore/merge-requests.md | 3 +- .../routes_guide/reviews/review-detail.md | 20 ++- .../docs/routes_guide/reviews/reviews-list.md | 3 +- internal/providers/github/github.go | 16 ++- internal/providers/gitlab/gitlab.go | 7 +- internal/providers/shared.go | 10 +- internal/review_processor/commits.go | 23 ++-- ui/public/sw.js | 12 ++ ui/src/components/DataTable/ClientTable.tsx | 29 +++- .../components/reviews/ReviewEventsPage.tsx | 39 +++++- .../components/reviews/ReviewProgressView.tsx | 126 +++++++++++++----- .../reviews/diffviewer/DiffViewerPanel.tsx | 28 +++- ui/src/pages/Explore/MergeRequests.tsx | 9 ++ ui/src/pages/Reviews/ReviewDetail.tsx | 92 +++++++++---- ui/src/pages/Reviews/Reviews.tsx | 11 ++ ui/src/types/reviews.ts | 1 + 22 files changed, 362 insertions(+), 101 deletions(-) create mode 100644 db/migrations/20261031120000_add_review_commits_message.sql 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 00000000..b11dde0c --- /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/schema.sql b/db/schema.sql index 24a6299a..d5d10d14 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -2811,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]))) ); @@ -10603,4 +10604,5 @@ INSERT INTO public.schema_migrations (version) VALUES ('20261029150100'), ('20261029150200'), ('20261030134721'), - ('20261031070300'); + ('20261031070300'), + ('20261031120000'); diff --git a/internal/api/diff_review.go b/internal/api/diff_review.go index 6d9dee60..89b15ff1 100644 --- a/internal/api/diff_review.go +++ b/internal/api/diff_review.go @@ -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. diff --git a/internal/api/review_commits.go b/internal/api/review_commits.go index 8258e516..25c02005 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 7ae7c340..285de0b7 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 0553831a..c401a3d8 100644 --- a/internal/api/review_service.go +++ b/internal/api/review_service.go @@ -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/docindex/docs/routes_guide/explore/merge-requests.md b/internal/docindex/docs/routes_guide/explore/merge-requests.md index 3e46f0c2..a3d759c3 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 9ff458ad..630bc213 100644 --- a/internal/docindex/docs/routes_guide/reviews/review-detail.md +++ b/internal/docindex/docs/routes_guide/reviews/review-detail.md @@ -19,11 +19,24 @@ 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). @@ -41,6 +54,9 @@ Any authenticated org member; the review must belong to their org 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 A review can finish its analysis but be unable to put comments on the PR/MR diff --git a/internal/docindex/docs/routes_guide/reviews/reviews-list.md b/internal/docindex/docs/routes_guide/reviews/reviews-list.md index 5f820e90..41b121df 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/providers/github/github.go b/internal/providers/github/github.go index df5231c1..87e267cb 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 dcedeef8..b3543438 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 83db2919..5f278567 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/review_processor/commits.go b/internal/review_processor/commits.go index c9caa416..f264bc56 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/ui/public/sw.js b/ui/public/sw.js index 5cbbca95..c5b0cafd 100644 --- a/ui/public/sw.js +++ b/ui/public/sw.js @@ -7,6 +7,18 @@ // 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 { diff --git a/ui/src/components/DataTable/ClientTable.tsx b/ui/src/components/DataTable/ClientTable.tsx index 36b6aeb5..edc217db 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)) { @@ -120,18 +127,30 @@ export function ClientTable({ return (
- +
- {columnWidths.map((width, i) => ( - - ))} + {enableColumnResizing + ? table.getFlatHeaders().map((header) => ( + + )) + : columnWidths.map((width, i) => )} {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( - ))} diff --git a/ui/src/components/reviews/ReviewEventsPage.tsx b/ui/src/components/reviews/ReviewEventsPage.tsx index 6c226c9d..de07ba00 100644 --- a/ui/src/components/reviews/ReviewEventsPage.tsx +++ b/ui/src/components/reviews/ReviewEventsPage.tsx @@ -30,10 +30,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 +283,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 ? ( +
+ +
) : ( e.type === type && e.details === details); + if (existing) { + existing.occurrences.push({ timestamp, attempt }); + existing.resolved = existing.resolved || resolved; + if (attempt !== undefined) { + existing.attempt = attempt; + } + return; + } + substage.resiliencyEvents.push({ + type, + details, + attempt, + resolved, + occurrences: [{ timestamp, attempt }], + }); } export default function ReviewProgressView({ reviewId, events, isLive = false, className }: 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); @@ -420,12 +467,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(); } } @@ -437,22 +479,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(); } @@ -871,26 +903,48 @@ 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.details}

+ {resEvent.occurrences.map((occ, occIdx) => ( +

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

+ ))} +
+ )} +
+ ); + })}
)} diff --git a/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx b/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx index 1c3ee32f..8f052982 100644 --- a/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx +++ b/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx @@ -56,6 +56,23 @@ interface DiffViewerPanelProps { blastReport?: BlastRadiusReport; // Receives each scored change's badge score (blended with its findings) for the page header. onRiskScores?: (scores: RiskScore[]) => 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 @@ -122,7 +139,7 @@ interface DiffNavTarget { file?: string; } -const DiffViewerPanel: React.FC = ({ reviewId, blastReport, onRiskScores }) => { +const DiffViewerPanel: React.FC = ({ reviewId, blastReport, onRiskScores, reviewStartedAt }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -138,7 +155,7 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport // 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(getPerformanceNow()); + const baselineMsRef = useRef(baselineFromStartedAt(reviewStartedAt)); const [commentRenderTimes, setCommentRenderTimes] = useState>({}); const recordCommentRendered = useCallback((commentKey: string) => { setCommentRenderTimes((prev) => recordFirstRenderTime(prev, commentKey, getPerformanceNow())); @@ -195,7 +212,7 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport setStreamedComments([]); seenEventIdsRef.current = new Set(); lastEventTimeRef.current = null; - baselineMsRef.current = getPerformanceNow(); + baselineMsRef.current = baselineFromStartedAt(reviewStartedAt); setCommentRenderTimes({}); getDiffReview(reviewId) .then((res) => { @@ -491,6 +508,11 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport )} )} +
+ + {navComments.length} total comment{navComments.length === 1 ? '' : 's'} + +
{/* Toolbar row (sort mode + Expand All) — NOT sticky, matching diff --git a/ui/src/pages/Explore/MergeRequests.tsx b/ui/src/pages/Explore/MergeRequests.tsx index e96927d1..b8e06dd1 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/Reviews/ReviewDetail.tsx b/ui/src/pages/Reviews/ReviewDetail.tsx index a678dc0f..f06a77e1 100644 --- a/ui/src/pages/Reviews/ReviewDetail.tsx +++ b/ui/src/pages/Reviews/ReviewDetail.tsx @@ -4,7 +4,7 @@ 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 { 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'; @@ -83,6 +83,7 @@ const ReviewDetail: React.FC = () => { const [levelFilter, setLevelFilter] = useState(''); const [typeFilter, setTypeFilter] = useState(''); const [lastEventTime, setLastEventTime] = useState(null); + 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(); @@ -360,6 +361,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) { @@ -397,6 +407,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); @@ -479,6 +493,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 : ''; @@ -606,6 +627,7 @@ const ReviewDetail: React.FC = () => { /> + {elapsed && } 0 ? String(eventCount) : String(Object.values(summary?.eventCounts || {}).reduce((a: number, b: number) => a + b, 0))} /> {review.metadata?.pr_link_policy && ( @@ -641,29 +663,38 @@ const ReviewDetail: React.FC = () => { <>
    COMMITS_PREVIEW_LIMIT ? 'max-h-64 overflow-y-auto pr-1' : ''}`}> {(allCommitsShown ? commits : commits.slice(0, COMMITS_PREVIEW_LIMIT)).map((commit) => ( -
  • -
    - {commit.refType === 'commit' && githubBaseUrl ? ( - - {commit.ref.substring(0, 8)} - - ) : ( - - {commit.refType === 'commit' ? commit.ref.substring(0, 8) : commit.ref} - - )} - {commit.refType === 'range' && ( - - range - - )} +
  • +
    +
    + {commit.refType === 'commit' && githubBaseUrl ? ( + + {commit.ref.substring(0, 8)} + + ) : ( + + {commit.refType === 'commit' ? commit.ref.substring(0, 8) : commit.ref} + + )} + {commit.refType === 'range' && ( + + range + + )} +
    + {formatRelativeTime(commit.createdAt)}
    - {formatRelativeTime(commit.createdAt)} + {commit.message && ( +
    + +

    {commit.message}

    +
    +
    + )}
  • ))}
@@ -737,7 +768,19 @@ const ReviewDetail: React.FC = () => { tabs={[ { id: 'findings', label: 'Findings' }, { id: 'accounting', label: 'Accounting' }, - { id: 'events', label: 'Events' }, + { + id: 'events', + label: ( + + Events + {eventCount > 0 && ( + + {eventCount} + + )} + + ), + }, ...(review.metadata?.pr_link_policy ? [{ id: 'auto-approve', label: ( @@ -773,6 +816,7 @@ const ReviewDetail: React.FC = () => { reviewId={reviewId} blastReport={blastReport && !isBlastRadiusSkipped(blastReport) ? blastReport : undefined} onRiskScores={setRiskScores} + reviewStartedAt={review.startedAt} /> )} diff --git a/ui/src/pages/Reviews/Reviews.tsx b/ui/src/pages/Reviews/Reviews.tsx index 959bec9f..9dc430dd 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 = () => { Date: Sat, 31 Oct 2026 13:27:21 +0000 Subject: [PATCH 21/25] fix: review-detail table UI/UX, finalization status, Explore nav order, 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%) --- internal/policyengine/engine.go | 42 +++++++--- internal/policyengine/risk_reason_test.go | 54 ++++++++++--- internal/riskscore/calibration.go | 19 ++++- ui/src/components/DataTable/ClientTable.tsx | 20 +++-- .../components/DataTable/HeaderControls.tsx | 27 +++++-- ui/src/components/Navbar/Navbar.tsx | 2 +- ui/src/components/Navbar/megaMenuData.ts | 4 +- .../components/reviews/ReviewEventsPage.tsx | 10 ++- .../reviews/ReviewProgressView.test.tsx | 57 +++++++++++++ .../components/reviews/ReviewProgressView.tsx | 79 ++++++++++++++----- .../reviews/diffviewer/DiffViewerPanel.tsx | 13 +-- ui/src/pages/Explore/ExploreRoutes.tsx | 4 +- ui/src/pages/Explore/ExploreTabs.tsx | 2 +- ui/src/pages/Explore/Repositories.tsx | 8 ++ ui/src/pages/Reviews/ReviewDetail.tsx | 5 ++ .../pages/Settings/AutoApprovePolicyTab.tsx | 11 ++- 16 files changed, 284 insertions(+), 73 deletions(-) diff --git a/internal/policyengine/engine.go b/internal/policyengine/engine.go index 7c31bf3b..0ac7abf6 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 ef837b91..22351277 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/riskscore/calibration.go b/internal/riskscore/calibration.go index a790a7dc..8b84b841 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/ui/src/components/DataTable/ClientTable.tsx b/ui/src/components/DataTable/ClientTable.tsx index edc217db..b1b8be4e 100644 --- a/ui/src/components/DataTable/ClientTable.tsx +++ b/ui/src/components/DataTable/ClientTable.tsx @@ -127,14 +127,20 @@ export function ClientTable({ return (
-
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + {enableColumnResizing && header.column.getCanResize() && ( +
+ )}
+
{enableColumnResizing - ? table.getFlatHeaders().map((header) => ( - + ? // 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) => )} @@ -142,7 +148,7 @@ export function ClientTable({ {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( -
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {enableColumnResizing && header.column.getCanResize() && (
= ({ 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 (
{isExpanded && ( -
-

{resEvent.details}

+
{resEvent.occurrences.map((occ, occIdx) => ( -

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

+
+

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

+

{occ.details}

+
))}
)} diff --git a/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx b/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx index 8f052982..915d3017 100644 --- a/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx +++ b/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx @@ -56,6 +56,9 @@ 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. @@ -139,7 +142,7 @@ interface DiffNavTarget { file?: string; } -const DiffViewerPanel: React.FC = ({ reviewId, blastReport, onRiskScores, reviewStartedAt }) => { +const DiffViewerPanel: React.FC = ({ reviewId, blastReport, onRiskScores, onCommentCount, reviewStartedAt }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -324,6 +327,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 @@ -508,11 +514,6 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport )} )} -
- - {navComments.length} total comment{navComments.length === 1 ? '' : 's'} - -
{/* Toolbar row (sort mode + Expand All) — NOT sticky, matching diff --git a/ui/src/pages/Explore/ExploreRoutes.tsx b/ui/src/pages/Explore/ExploreRoutes.tsx index 41ab79f2..a4ad2e12 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 c4870add..84ac7b3b 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/Repositories.tsx b/ui/src/pages/Explore/Repositories.tsx index 69040f66..b7a01217 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 f06a77e1..25cd0cee 100644 --- a/ui/src/pages/Reviews/ReviewDetail.tsx +++ b/ui/src/pages/Reviews/ReviewDetail.tsx @@ -107,6 +107,7 @@ const ReviewDetail: React.FC = () => { const [blastReport, setBlastReport] = useState(null); const [blastPoll, setBlastPoll] = useState<'checking' | 'waiting' | 'gave_up'>('checking'); const [riskScores, setRiskScores] = useState([]); + const [commentCount, setCommentCount] = useState(undefined); useEffect(() => { // Blast radius comes from `git lrc review` or the server-side job, which runs alongside the @@ -117,6 +118,7 @@ const ReviewDetail: React.FC = () => { setBlastReport(null); setBlastPoll('checking'); setRiskScores([]); + setCommentCount(undefined); const load = () => { getBlastRadiusReport(reviewId) .then((report) => { @@ -630,6 +632,7 @@ const ReviewDetail: React.FC = () => { {elapsed && } 0 ? String(eventCount) : String(Object.values(summary?.eventCounts || {}).reduce((a: number, b: number) => a + b, 0))} /> + {commentCount !== undefined && } {review.metadata?.pr_link_policy && ( { reviewId={reviewId} blastReport={blastReport && !isBlastRadiusSkipped(blastReport) ? blastReport : undefined} onRiskScores={setRiskScores} + onCommentCount={setCommentCount} reviewStartedAt={review.startedAt} />
@@ -980,6 +984,7 @@ const ReviewDetail: React.FC = () => { severity: mapEventLevel(event.level) as 'info' | 'success' | 'warning' | 'warn' | 'error' | 'debug' }))} isLive={review?.status === 'in_progress'} + reviewStatus={review?.status} />
diff --git a/ui/src/pages/Settings/AutoApprovePolicyTab.tsx b/ui/src/pages/Settings/AutoApprovePolicyTab.tsx index bb042d76..4f8db494 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 { @@ -70,7 +70,16 @@ 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 orgId = currentOrgId ?? 0; From bd8ce5cc317e778a12b8070fcd1b406a6087ebb5 Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Sat, 31 Oct 2026 14:59:14 +0000 Subject: [PATCH 22/25] fix: risk score pinned at 1.0 due to unbounded features saturating sigmoid 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%) --- ...et_risk_model_seed_for_feature_rescale.sql | 33 +++ db/schema.sql | 3 +- internal/api/auto_approve_policy_handlers.go | 53 ++++- internal/riskscore/features.go | 40 ++-- internal/riskscore/model.go | 99 ++++++--- .../riskscore/referencemix/referencemix.go | 16 +- internal/riskscore/riskscore_test.go | 24 ++- internal/riskscore/saturation_test.go | 197 ++++++++++++++++++ ui/src/api/riskPolicy.ts | 12 +- .../pages/Settings/AutoApprovePolicyTab.tsx | 25 +++ 10 files changed, 450 insertions(+), 52 deletions(-) create mode 100644 db/migrations/20261031140000_reset_risk_model_seed_for_feature_rescale.sql create mode 100644 internal/riskscore/saturation_test.go 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 00000000..6ea7a415 --- /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 d5d10d14..c21b66fe 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -10605,4 +10605,5 @@ INSERT INTO public.schema_migrations (version) VALUES ('20261029150200'), ('20261030134721'), ('20261031070300'), - ('20261031120000'); + ('20261031120000'), + ('20261031140000'); diff --git a/internal/api/auto_approve_policy_handlers.go b/internal/api/auto_approve_policy_handlers.go index 6ab295b0..5116a8a3 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/riskscore/features.go b/internal/riskscore/features.go index d8f49bf2..5fea08b2 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/model.go b/internal/riskscore/model.go index 7975fe22..83744260 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 e77270de..64556ba6 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 aca2b45f..ba3d94e8 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 00000000..e6ba7635 --- /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/ui/src/api/riskPolicy.ts b/ui/src/api/riskPolicy.ts index 521f8d6b..f56088d3 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/pages/Settings/AutoApprovePolicyTab.tsx b/ui/src/pages/Settings/AutoApprovePolicyTab.tsx index 4f8db494..5dac31bb 100644 --- a/ui/src/pages/Settings/AutoApprovePolicyTab.tsx +++ b/ui/src/pages/Settings/AutoApprovePolicyTab.tsx @@ -14,6 +14,7 @@ import { retrainRiskModel, simulatePolicy, SimulateRow, + SimulateSummary, listReviewOutcomes, OutcomeRow, } from '../../api/riskPolicy'; @@ -81,6 +82,7 @@ const AutoApprovePolicyTab: React.FC = () => { 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'; @@ -185,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)); } @@ -278,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):

From 0f7cc1c2c81ce2e691fb79b70e2d7b37b88a9439 Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Sat, 31 Oct 2026 15:33:22 +0000 Subject: [PATCH 23/25] fix: risk assessment header flickering between risk tiers on tab switch 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%) --- ui/src/components/reviews/RiskAssessment.tsx | 23 +++++++++++++------ .../reviews/diffviewer/DiffViewerPanel.tsx | 11 +++++++-- ui/src/pages/Reviews/ReviewDetail.tsx | 10 ++++++-- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/ui/src/components/reviews/RiskAssessment.tsx b/ui/src/components/reviews/RiskAssessment.tsx index bf92bd06..529b9e71 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/DiffViewerPanel.tsx b/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx index 915d3017..f6317284 100644 --- a/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx +++ b/ui/src/components/reviews/diffviewer/DiffViewerPanel.tsx @@ -313,11 +313,18 @@ const DiffViewerPanel: React.FC = ({ reviewId, blastReport 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); diff --git a/ui/src/pages/Reviews/ReviewDetail.tsx b/ui/src/pages/Reviews/ReviewDetail.tsx index 25cd0cee..61afbc81 100644 --- a/ui/src/pages/Reviews/ReviewDetail.tsx +++ b/ui/src/pages/Reviews/ReviewDetail.tsx @@ -106,7 +106,13 @@ const ReviewDetail: React.FC = () => { }, [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(() => { @@ -117,7 +123,7 @@ const ReviewDetail: React.FC = () => { let attempts = 0; setBlastReport(null); setBlastPoll('checking'); - setRiskScores([]); + setRiskScores(null); setCommentCount(undefined); const load = () => { getBlastRadiusReport(reviewId) From 38034ad5b7c082c89da2f71a2ea7f2806f2045ec Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Sat, 31 Oct 2026 15:42:16 +0000 Subject: [PATCH 24/25] chore: ignore newly-disclosed dev-only glob/minimatch DoS CVEs in OSV 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%) --- config/osv-scanner.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/config/osv-scanner.toml b/config/osv-scanner.toml index 9c9ea582..efb80a5c 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." From 9d195aa47f5f9ee3b6ced84dd176b2aba3e6c8e7 Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Sat, 31 Oct 2026 15:59:27 +0000 Subject: [PATCH 25/25] fix: two more test fixtures left using pre-log1p raw feature values 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%) --- internal/policyengine/testdata/scenarios.json | 4 ++-- internal/riskscore/learning_cycle_test.go | 19 ++++++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/internal/policyengine/testdata/scenarios.json b/internal/policyengine/testdata/scenarios.json index d9591837..1f7475c1 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/riskscore/learning_cycle_test.go b/internal/riskscore/learning_cycle_test.go index df6483f5..041d2049 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)