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
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Periodic background jobs fetch PRs from GitHub, apply filters, and persist resul
### 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 whatever goes through `Notifier.Notify`. 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`, 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)
- `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 @@ -65,17 +65,17 @@ RPC handlers serve data to clients (web UI, Emacs).
- `images/` — downloads and caches the images embedded in PR bodies, reviews and comments to `$CRS_HOME/images`, content-addressed by URL. Only `github.com` / `*.githubusercontent.com` are fetched, since bodies are attacker-supplied text. Clients render from the cached file rather than from GitHub: a private repo's attachments are served only to a request carrying the server's token
- `llm/` — the text-generation `Client` interface built by `NewClient`, the stages a call fails at (`CallError`), and the call log: the AI runner appends every run to `~/.crs/llm_calls.log` via `AppendCallReport`. Two HTTP backends: Gemini (model pinned) and OpenRouter (`NewOpenRouterClient(model)`, a `ContextClient` so a deadline cancels the request)
- `openrouter/` — the OpenRouter chat-completions client, shared by `llm` and the bundled plugins (`cmd/internal/pluginkit`). It imports nothing from the module, so plugin binaries don't link the server's config/database (cgo sqlite)
- `ai/` — the AI feature layer (docs/ai_features.md): a `Registry` of `Feature`s (`comments-addressed`; `feature-flags`, which judges whether each hunk's logic change is behind a feature flag, a hunk that only adds definitions being judged where it's called; `change-diagram`, whose report is the raw Mermaid source of a flowchart of the PR's changes, which clients render themselves; and the applied features `file-ordering` and `review-ease`, an `AppliedFeature` being one whose result the server applies to what it serves — the diff's file order, the list's ease rating — rather than a report a client opens), the `Provider` (one-shot: Gemini or OpenRouter via `llm.Client`, or a command-backed CLI agent; `config.AIProviderFor` resolves the provider plus the command/model it reads) and `Agent` (tool loop on any provider) seams, `Request.FilteredDiff` (the PR's diff since the last review/interaction, from the filtered-diff cache), and the `Runner` — cache check keyed by head SHA + `InputsDigest`, in-memory in-flight tracking (so "pending" never outlives a restart), a cap of two concurrent automatic runs, a per-feature timeout, and one call-log entry per run. A `CodeOnlyFeature` is keyed by head SHA alone (`KeyDigest`), so comments don't make it stale. Results go to the `AIResults` table. Configured by `[AI]`/`[[AIFeatures]]`, all off by default; the legacy root-level `ExperimentalLLM*` keys still work — `config.AIFeatureSettings` reads a legacy flag as an automatic entry for file-ordering or review-ease (`legacyLLMKeys`), so ask it, not `AIFeatures`, whether a feature is on
- `ai/` — the AI feature layer (docs/ai_features.md): a `Registry` of `Feature`s (`comments-addressed`; `feature-flags`, which judges whether each hunk's logic change is behind a feature flag, a hunk that only adds definitions being judged where it's called; `change-diagram`, whose report is the raw Mermaid source of a flowchart of the PR's changes, which clients render themselves; and the applied features `file-ordering` and `review-ease`, an `AppliedFeature` being one whose result the server applies to what it serves — the diff's file order, the list's ease rating — rather than a report a client opens), the `Provider` (one-shot: Gemini or OpenRouter via `llm.Client`, or a command-backed CLI agent; `config.AIProviderFor` resolves the provider plus the command/model it reads) and `Agent` (tool loop on any provider) seams, `Request.FilteredDiff` (the PR's diff since the last review/interaction, from the filtered-diff cache), and the `Runner` — cache check keyed by head SHA + `InputsDigest`, in-memory in-flight tracking (so "pending" never outlives a restart), a cap of two concurrent automatic runs, a per-feature timeout, one call-log entry per run, and an `OnChange` hook told as a run is claimed (pending) and as its result is stored. A `CodeOnlyFeature` is keyed by head SHA alone (`KeyDigest`), so comments don't make it stale. Results go to the `AIResults` table. Configured by `[AI]`/`[[AIFeatures]]`, all off by default; the legacy root-level `ExperimentalLLM*` keys still work — `config.AIFeatureSettings` reads a legacy flag as an automatic entry for file-ordering or review-ease (`legacyLLMKeys`), so ask it, not `AIFeatures`, whether a feature is on
- `logger/` — the slog handler: text (`DATE::TIME - LEVEL - msg key=value`) or JSON (`CRS_LOG_FORMAT`), to stderr and optionally a file (`CRS_LOG_FILE`, moved aside at startup past 64 MB), at `CRS_LOG_LEVEL`
- `subprocess/` — runs a command with a timeout, capturing stdout/stderr; shared by plugins and the command provider
- `org/` — org-mode serialization for the Emacs client
- `utils/` — diff parsing utilities
- `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 `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/poll machinery (`crs--ai-insert-function`). 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. `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`).
- `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
- `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`).

## Key Data Flow

Expand Down
26 changes: 22 additions & 4 deletions ai/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ type Runner struct {
// NewProvider builds the provider a run's config chose. Tests replace it.
NewProvider func(choice config.AIProviderChoice) (Provider, error)

// OnChange, when set, is told each time a feature's output for a PR
// changes as a read of it would see: a run is claimed (pending, queued
// for a slot included) and the run's result is stored (its status). It is
// called on the claiming caller's goroutine or the run's, and must not
// block. The server sets it to notify subscribed clients.
OnChange func(owner, repo string, number int, feature, status string)

slots chan struct{}
inflight sync.Map // runKey -> struct{}
}
Expand Down Expand Up @@ -152,6 +159,7 @@ func (r *Runner) claim(job Job) (claim, Outcome) {
if _, running := r.inflight.LoadOrStore(runKey(job.Owner, job.Repo, job.Number, job.Feature), struct{}{}); running {
return claim{}, OutcomeAlreadyRunning
}
r.changed(job, StatusPending)
return claim{job: job, feature: f, mode: resolveMode(f, entry), provider: cfg.AIProviderFor(entry)}, OutcomeStarted
}

Expand Down Expand Up @@ -227,10 +235,11 @@ func (r *Runner) execute(c claim) {
BodyContent: fmt.Sprintf("**%s failed.**\n\n%v", c.feature.Name(), err),
}}
}
store(job, status, result, sha, digest)
status = store(job, status, result, sha, digest)
// The result is readable from here on; don't keep reporting it as pending
// while the log entry is written.
r.inflight.Delete(key)
r.changed(job, status)

entry := llm.CallReport{
Title: "AI Feature Run",
Expand Down Expand Up @@ -301,11 +310,19 @@ func runFeature(ctx context.Context, f Feature, req Request) (res Result, err er
return f.Run(ctx, req)
}

// store writes a run's result, keyed by the inputs it was computed from.
func store(job Job, status string, result Result, sha, digest string) {
// changed tells OnChange, if set, that job's feature output now reads status.
func (r *Runner) changed(job Job, status string) {
if r.OnChange != nil {
r.OnChange(job.Owner, job.Repo, job.Number, job.Feature, status)
}
}

// store writes a run's result, keyed by the inputs it was computed from, and
// returns the status it stored it under.
func store(job Job, status string, result Result, sha, digest string) string {
db := config.C().DB
if db == nil {
return
return status
}
encoded, err := result.Encode()
if err != nil {
Expand All @@ -317,4 +334,5 @@ func store(job Job, status string, result Result, sha, digest string) {
if err := db.UpsertAIResult(job.Owner, job.Repo, job.Number, job.Feature, encoded, status, sha, digest); err != nil {
slog.Error("Failed to store AI feature result", "feature", job.Feature, "repo", job.Repo, "pr", job.Number, "error", err)
}
return status
}
62 changes: 62 additions & 0 deletions ai/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import (
"crs/config"
"crs/database"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"sync/atomic"
Expand Down Expand Up @@ -466,3 +468,63 @@ func waitFor(t *testing.T, cond func() bool) {
time.Sleep(10 * time.Millisecond)
}
}

// changeLog records what a runner's OnChange is told, in order.
type changeLog struct {
mu sync.Mutex
changes []string
}

func (l *changeLog) record(owner, repo string, number int, feature, status string) {
l.mu.Lock()
defer l.mu.Unlock()
l.changes = append(l.changes, fmt.Sprintf("%s/%s#%d:%s %s", owner, repo, number, feature, status))
}

func (l *changeLog) get() []string {
l.mu.Lock()
defer l.mu.Unlock()
return append([]string(nil), l.changes...)
}

func TestRunnerAnnouncesARunStartingAndLanding(t *testing.T) {
release := make(chan struct{})
f := &fakeFeature{id: "fake", run: func(context.Context, Request) (Result, error) {
<-release
return Result{}, nil
}}
failing := &fakeFeature{id: "failing", run: func(context.Context, Request) (Result, error) {
return Result{}, errors.New("boom")
}}
r, _, _ := testRunner(t, nil, f, failing)
var log changeLog
r.OnChange = log.record

r.Dispatch(job("fake", TriggerExplicit, "s", "d"))
// Pending is announced as the run is claimed, before Dispatch returns: a
// client that hears it reads the run as in flight.
if got := log.get(); !reflect.DeepEqual(got, []string{"acme/widgets#42:fake pending"}) {
t.Fatalf("after dispatch: %v", got)
}
// A run already in flight is no change.
r.Dispatch(job("fake", TriggerRerun, "s", "d"))
close(release)
waitFor(t, func() bool { return len(log.get()) == 2 })
if r.Running("acme", "widgets", 42, "fake") {
t.Error("the landing was announced while the run still read as in flight")
}
// Up to date: nothing runs, nothing changes.
r.RunSync(job("fake", TriggerExplicit, "s", "d"))

r.RunSync(job("failing", TriggerExplicit, "s", "d"))

want := []string{
"acme/widgets#42:fake pending",
"acme/widgets#42:fake success",
"acme/widgets#42:failing pending",
"acme/widgets#42:failing error",
}
if got := log.get(); !reflect.DeepEqual(got, want) {
t.Errorf("announced %v, want %v", got, want)
}
}
Loading
Loading