Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,13 @@ Periodic background jobs fetch PRs from GitHub, apply filters, and persist resul
- `auxdata.go` — in-memory auxiliary data (reviews, commits, CI status, review threads) used during workflow display
- `webhooks.go` / `webhook_events.go` / `webhook_cache.go` — GitHub webhooks (`[Webhooks]`, docs/webhooks.md), off without that table. The process running the sync listens (`syncWebhookListener`, called from `ManagerService.Run` each cycle); `serveWebhook` verifies the signature and queues; one worker applies deliveries in order, patching the DB caches in place for any PR we hold (in a section, or cached by a cycle — the filters read candidates' caches) and never starting a list cache from one delivery. Only a moved head or base calls GitHub (`refetchPR`: a full warm plus the post-update hooks), and a new review thread (its GraphQL ID). `beginWebhookCycle` decides per cycle: polling until the listener is up, for the first cycle after, and every `FullRefreshInterval`; otherwise `webhookHandles(key)` is true for PRs in covered repos, and `planWebhookRefresh` / `prefetchAuxDataForPRs` read those whose caches are current (`cacheServes`) via `loadCachedAuxData` instead of fetching — the rest are warmed in full. `recordMergeability` re-asks only when the head moved or a push moved the base (`mergeabilityCurrent`), and the registered `git_tools.PRStateSource` (`webhookPRState`) answers the interaction and CI filters from the caches. `lockPR` serializes a fetch's read-then-write with a delivery's patch for the same PR. `activeCoverage` (behind `webhookHandles`) holds between cycles while webhooks are trusted: set by a webhook cycle or a completed full refresh, cleared when a cycle refreshes everything or the listener stops
- `webhook_membership.go` — after a delivery, the PR is checked against each workflow again (`webhookMember.webhookMembership`: repo/state + filters for Sync, review-request-to-me-or-my-teams for `ModeReviewRequested`, the mode filter for `ModeWaitingOnMe`, only drops for ProjectList) from the caches, and its section rows added/rewritten/dropped via `applyChange`. Queued by key and deduped (`scheduleReevaluation`), run by `runReevaluations` under `membershipMu`, which `ManagerService.Run` holds from a cycle's start until its sections are written and pruned; trust is checked when a check runs, so a delivery that lands mid-cycle is checked once the cycle ends (what makes `FullRefreshInterval <= SleepDuration` — poll every cycle, webhooks only in between — work). Each PR is judged by the newest PR object seen (`rememberPR`, from listings and payloads, newer `updated_at` wins), so a check that waited out a cycle can't undo its listing. A submitted review clears its author's request in the payload's PR (`withoutReviewer`)
- `webhook_changes.go` — telling clients what a delivery changed: each `heldPR` an apply function returns carries the fields it rewrote (`changes`: a cache field, `ChangeHead` after a refetch, `ChangePR` for a pull_request payload) and a few-word `summary` (`reviewSummary`, `ciSummaryChange` — CI is announced only as it settles), and `applyWebhookEvent` hands each PR with changes to the hook registered via `SetPRChangedHook` (`server.NotifyPRChanged`, wired in `main.go`) as a `PRChange`; `reevaluateMembership` sends one with `ChangeSections` when it rewrote rows. Synchronous on the webhook worker, so the hook must not block
- `webhook_stats.go` — per-cycle accounting: `takeWebhookActivity` (deliveries applied/ignored/rejected, refetches, their API calls, section adds/drops) and `takeTrafficSinceLastRow` (deltas of `git_tools.GetRequestCounts`, every request counted on the wire by budget), written with the cycle's `mode` (`database.CycleMode*`) into its `APICallStats` row

### Server Layer (`server/`)
RPC handlers serve data to clients (web UI, Emacs).
- `server.go` — RPC method dispatch (`GetAllReviews`, `GetPR`, `AddComment`, `SubmitReview`, the AI RPCs `ListAIFeatures`/`RunAIFeature`/`GetAIOutput`, etc.)
- `notify.go` — server → client notifications (docs/protocol.md#server-notifications): a client that calls `Subscribe` gets JSON-RPC notifications (`id: null`, named by `method`) on the stdout its replies use — a `Server.Heartbeat` every interval, and the events that go through `Notifier.Notify`: `Server.PluginOutputChanged` each time a plugin result is written (`storePluginResult`, plugins.go) or cleared by `RerunPlugins`, and `Server.AIOutputChanged` as an AI run is claimed and as its result is stored (the `ai.Runner`'s `OnChange`, set in `RunServer`). Each names the PR and plugin/feature with its new status, never the output; clients re-read `GetPluginOutput` / `GetAIOutput` on them instead of polling. Replies and notifications share one `syncWriter`, so lines never splice; a client that never subscribes never sees one (the TUI reads one line per call)
- `notify.go` — server → client notifications (docs/protocol.md#server-notifications): a client that calls `Subscribe` gets JSON-RPC notifications (`id: null`, named by `method`) on the stdout its replies use — a `Server.Heartbeat` every interval, and the events that go through `Notifier.Notify`: `Server.PluginOutputChanged` each time a plugin result is written (`storePluginResult`, plugins.go) or cleared by `RerunPlugins`, `Server.AIOutputChanged` as an AI run is claimed and as its result is stored (the `ai.Runner`'s `OnChange`, set in `RunServer`), and `Server.PRUpdated` (`NotifyPRChanged`) for each PR a webhook delivery changed. Each names the PR (and plugin/feature with its new status, or what a delivery rewrote), never the output; clients re-read `GetPluginOutput` / `GetAIOutput` / `GetPR` on them instead of polling. Replies and notifications share one `syncWriter`, so lines never splice; a client that never subscribes never sees one (the TUI reads one line per call)
- `renderer.go` — `GetPRDetails()` fetches PR metadata/diff/comments/reviews, checking DB caches first; `aiDiscussion` partitions its comments into threads/conversation for the AI features
- `plugins.go` — async plugin execution
- `hooks.go` — the post-update hooks (plugins, automatic AI features), the request for the applied AI features when a client opens a PR (`requestAppliedAIFeatures`), and AI request assembly
Expand All @@ -73,7 +74,7 @@ RPC handlers serve data to clients (web UI, Emacs).
- `cmd/` — plugin binaries (summarize_diff, security_check, etc.). The bundled LLM plugins call `pluginkit.ModelFromEnv()`, which picks Gemini or OpenRouter from `CRS_LLM_PROVIDER`/`CRS_LLM_MODEL` — set by the server from a `[[Plugins]]` entry's `Provider`/`Model` (`config.Plugin.Env`). `style_guidelines` is agentic instead: it drives its own two-phase tool loop (review, then validate the findings) over `pluginkit.Chat`, which speaks Gemini function calling and OpenRouter tool calls natively, and reads its guide from a file or a directory (`CRS_STYLE_GUIDE_DIR`)

### Clients
- `bun_client/` — Bun + React web UI. `server.ts` bridges HTTP/WebSocket to the Go backend's stdio — it subscribes to the server's notifications at startup and `server_events.ts` relays them to the page as Server-Sent Events on `GET /api/events`, where one shared `EventSource` (`frontend/src/server_events.ts`) feeds the top bar's heartbeat status and `useServerChanges`, which the plugin and AI views (`usePluginOutputs`, `useAIReport`, `useAIBackgroundRuns`) use to re-read their output when a change names their PR, and on reconnect, instead of polling — and `lsp_pool.ts` keeps language servers (`diff-lsp`, `gopls`, ...) running across LSP WebSockets so reopening a review doesn't re-index the workspace. The review toolbar's diff-range select swaps the diff for a filtered one (`FilteredDiffBanner.tsx`, `diff_filter_utils.ts`); `Review.tsx` keeps the full diff in `fullDiff` for comment positions, which a filtered diff doesn't share. The change diagram is drawn with the `mermaid` library (`MermaidDiagram.tsx`), imported lazily in `mermaid_utils.ts` so it stays out of the main bundle; its Copy image / Download image buttons rasterize the drawn SVG to a PNG on a canvas (`mermaid_image.ts`), made in the background once the diagram is drawn and kept for both, within a pixel budget and time limits
- `bun_client/` — Bun + React web UI. `server.ts` bridges HTTP/WebSocket to the Go backend's stdio — it subscribes to the server's notifications at startup and `server_events.ts` relays them to the page as Server-Sent Events on `GET /api/events`, where one shared `EventSource` (`frontend/src/server_events.ts`) feeds the top bar's heartbeat status, `useServerChanges`, which the plugin and AI views (`usePluginOutputs`, `useAIReport`, `useAIBackgroundRuns`) use to re-read their output when a change names their PR, and on reconnect, instead of polling, and the live refresh: `usePRUpdates` batches `Server.PRUpdated` bursts (and reads again when the stream reconnects after dropping, `onReconnect`), the review re-reads the PR in place (`Review.tsx` `refreshLive`, holding a new diff while a comment is being written) and the list its rows, with a toast saying what happened — and `lsp_pool.ts` keeps language servers (`diff-lsp`, `gopls`, ...) running across LSP WebSockets so reopening a review doesn't re-index the workspace. The review toolbar's diff-range select swaps the diff for a filtered one (`FilteredDiffBanner.tsx`, `diff_filter_utils.ts`); `Review.tsx` keeps the full diff in `fullDiff` for comment positions, which a filtered diff doesn't share. The change diagram is drawn with the `mermaid` library (`MermaidDiagram.tsx`), imported lazily in `mermaid_utils.ts` so it stays out of the main bundle; its Copy image / Download image buttons rasterize the drawn SVG to a PNG on a canvas (`mermaid_image.ts`), made in the background once the diagram is drawn and kept for both, within a pixel budget and time limits
- `client.el/` — Emacs client, split into modules with `crs-client.el` as the entry point (`crs-vars`, `crs-html`, `crs-rpc`, `crs-filtered-diff`, `crs-render`, `crs-list-mode`, `crs-review`, `crs-comments`, `crs-review-actions`, `crs-plugins`, `crs-ai`, `crs-diagram`). `crs-filtered-diff` (`F`) swaps a review buffer's `crs--buffer-diff` for a filtered one, keeping the full diff in `crs--buffer-full-diff`; its buffer-locals are `permanent-local` so re-entering the major mode on render keeps them, and `crs--render-and-update` skips inline comments while one is displayed and re-requests it on a fresh payload. `crs-diagram` shows the change diagram in a `mermaid-mode` buffer (an optional dependency) through `crs-ai`'s fetch machinery (`crs--ai-insert-function`). `crs-start-server` subscribes to notifications, `crs-rpc` hands them to `crs-server-notification-functions`, and open plugin, AI and diagram buffers re-read their PR's output on them (nothing polls). A new module must also be added to the byte-compile list in `.github/workflows/all-checks.yml`
- `chrome_extension/` — MV3 Chrome extension that shows the AI features and plugins for the GitHub PR a tab is on (any PR, listed or not), and the review list elsewhere; not a review UI (README.md). The service worker (`background.ts`, `native_client.ts`) owns the one native port, opened on the first RPC; one port is one `crs_native_host` and one `crs --server`, kept alive with the browser. It subscribes each new server first and relays `Server.PluginOutputChanged` / `Server.AIOutputChanged` (`server_events.ts`) to extension pages on a `crs-events` port (`usePRChanges`; PRTools re-reads on them, never polls), plus a `resync` when a new server subscribes, and tells a PR's tabs when a file order lands (`crs-file-order-changed`). `crs_native_host/` (Go, stdlib only, imports nothing from the module) bridges Chrome's length-prefixed frames to the server's newline-delimited JSON-RPC, chunks responses over Chrome's 1 MB host→extension limit into `crs_chunk` streams (`native_protocol.ts` reassembles them), reports `crs_host` ready/error/server_exited events, and merges `~/.crs/native_host.env` first because Chrome launches hosts without the shell's environment; `install.sh` registers it for the extension ID the manifest `key` pins. `content.ts` (IIFE, small, no RPC) toggles a modal over github.com holding the panel (`src/panel/`, React) in an iframe, passing `?owner=&repo=&number=` on a PR page; off GitHub the panel opens as a `?standalone=1` popup window. The service worker answers only extension pages and forwards only `RPC_METHODS` (`types.ts`): read-and-run RPCs, no review/merge/config mutations. The content script also draws the chips beside a PR's tabs, and on a Files changed view lays the stored file-ordering result over GitHub's own diffs and file tree (`file_order.ts`); both come from read-only messages the service worker answers for the sender tab's own PR (`crs-tools`, `crs-file-order`).

Expand All @@ -83,7 +84,7 @@ RPC handlers serve data to clients (web UI, Emacs).
2. **Aux data fetch**: `fetchAuxDataForPR` (manager.go) fetches reviews/commits/CI/review-threads and persists to DB caches, recording each write in `WorkflowActionLog` (workflow name, head SHA, fields written). What gets fetched is the union of what the workflows' filters asked for and what `applyCacheWarmRequirements` adds for a PR that is new or has a new head SHA — the warm covers every field `GetPRDetails` reads, so opening a review is a pure cache hit
3. **Post-update hooks**: for each PR the cycle added or re-fetched after a push, `notifyPRsUpdated` (manager.go) calls the hook registered via `workflows.SetPRUpdatedHook` — `server.WarmPRAnalysis` in server mode — which runs the plugins and any AI features configured `Automatic` in the background so they're cached before anyone opens the review, and warms the filtered diffs (`warmFilteredDiffs`, also on every client open, sync and submit). A client opening a PR (`ensurePostUpdateHooks`) runs the same hooks and also requests the enabled applied AI features, which no client asks for itself
4. **Image prefetch**: `ensurePostUpdateHooks` also hands every image URL found in the description, reviews and comments to `images.Store.Prefetch`, ahead of the head-SHA debounce — a new comment carrying a screenshot doesn't change the SHA. `PRPayload.images` then reports where each one landed, and `GetImage` fetches on demand anything the prefetch hasn't reached
5. **Webhooks** (only with `[Webhooks]`): deliveries patch the same DB caches steps 1–2 write, then re-check the PR's section membership from them, so a webhook cycle lists PRs, warms new or pushed ones, and reads the rest from the caches; without the table every cycle polls as above
5. **Webhooks** (only with `[Webhooks]`): deliveries patch the same DB caches steps 1–2 write, then re-check the PR's section membership from them, so a webhook cycle lists PRs, warms new or pushed ones, and reads the rest from the caches; without the table every cycle polls as above. Each PR a delivery changed is announced to a subscribed client as a `Server.PRUpdated`, and the web client re-reads it (step 6) from those caches
6. **Client request**: RPC `GetPR` → `GetPRDetails` (renderer.go) → checks DB caches → falls back to GitHub API
7. **Rendering**: `OrgRenderer` reads sections/items from DB, sorts by priority, returns org-mode or JSON

Expand Down
5 changes: 4 additions & 1 deletion bun_client/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ no GitHub token, network, or SQLite database:

The fake `crs` also answers `Subscribe`: it sends a `Server.Heartbeat` every
second (the launcher sets `CRS_HEARTBEAT_SECONDS=1`) until a spec calls
`backend.setHeartbeat(false)`, which `E2E.Reset` undoes.
`backend.setHeartbeat(false)`, which `E2E.Reset` undoes. `backend.updatePR(pr, ...)`
plays a webhook delivery: it changes the PR's fixture (a title, a conversation
comment, text swapped in the diff) and sends the `Server.PRUpdated` the server would
(`live_updates.e2e.ts`).

It serves filtered diffs too (`ListDiffFilters`, `GetFilteredDiff`): on
`acme/widgets#42`, "Since my last review" is the `src/main.ts` part of its diff;
Expand Down
33 changes: 33 additions & 0 deletions bun_client/e2e/harness/fake_crs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,39 @@ const handlers: Record<string, (args: any) => unknown> = {
if (typeof args.mermaid === 'string') state.diagramSource = args.mermaid;
return { okay: true };
},

// A webhook delivery changing a PR: apply what it says to the fixture —
// a new title, a conversation comment, a diff with `ReplaceInDiff`'s text
// swapped — then send the Server.PRUpdated notification the server would.
'E2E.UpdatePR': args => {
const pr = findPR(args);
if (typeof args.Title === 'string') pr.item.title = args.Title;
if (args.Comment) {
pr.comments.push(
comment({
id: String(state.nextCommentId++),
author: args.Comment.author,
body: args.Comment.body,
created_at: new Date().toISOString(),
})
);
}
if (Array.isArray(args.ReplaceInDiff)) {
pr.diff = pr.diff.replace(args.ReplaceInDiff[0], args.ReplaceInDiff[1]);
}
notify('Server.PRUpdated', {
owner: pr.item.owner,
repo: pr.item.repo,
number: pr.item.number,
event: args.Event ?? 'issue_comment.created',
changes: args.Changes ?? ['comments'],
actor: args.Actor ?? '',
own: !!args.Own,
summary: args.Summary ?? '',
time: new Date().toISOString(),
});
return { okay: true };
},
};

function respond(id: Request['id'], result: unknown, error: string | null) {
Expand Down
25 changes: 25 additions & 0 deletions bun_client/e2e/harness/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,31 @@ class Backend {
setDiagram(enabled: boolean, mermaid?: string) {
return this.rpc('E2E.SetDiagram', { enabled, mermaid });
}

/**
* Change a PR as a webhook delivery would, and push the Server.PRUpdated
* notification the server sends for it.
*/
updatePR(pr: { owner: string; repo: string; number: number }, update: PRUpdateFields) {
return this.rpc('E2E.UpdatePR', {
Owner: pr.owner,
Repo: pr.repo,
Number: pr.number,
...update,
});
}
}

export interface PRUpdateFields {
Title?: string;
Comment?: { author: string; body: string };
/** Swap the first text for the second in the PR's diff. */
ReplaceInDiff?: [string, string];
Event?: string;
Changes?: string[];
Actor?: string;
Own?: boolean;
Summary?: string;
}

export interface LspMessage {
Expand Down
Loading
Loading