diff --git a/.gitignore b/.gitignore index 731001fa..9e0ea538 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,12 @@ -dist/ +# The dashboard's built SPA is the ONE dist that is committed: internal/serve +# embeds it with go:embed, so a clean checkout cannot compile without it. Every +# other dist is build output. +/dist/ +web/dist/ +# Explicit, because a global gitignore's blanket "dist/" also matches this one +# and git will not descend into an excluded directory to re-include its files. +!internal/serve/dist/ +web/node_modules/ *.log .DS_Store diff --git a/AGENTS.md b/AGENTS.md index 82045259..e3078847 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,9 @@ Dependency rule (Go-enforced, no cycles): `dialect ← engine ← crq`, `state static `KnownCoReviewers()` list — Codex, Cursor Bugbot, Macroscope — each carrying its login, config name, check-run app slug, trigger command, and its wording hooks (`ClassifyComment`, `ClassifyCheck`, `ResolvedInSHA`, - `FindingDedupeKey`, …). The only place a bot's literal wording may appear. + `FindingDedupeKey`, `Price`, …). The only place a bot's literal wording may + appear. `pricing.go` holds the vendors' published prices and the per-bot cost + estimators behind `PricesCheckedAt` — money is bot knowledge like any other. - `internal/gh/` — GitHub REST/GraphQL transport, bot-agnostic. Owns the "GitHub REST quota" concept under the name **Throttle** (`ThrottleWait`/`IsThrottled`). The only package (besides dialect) allowed to say "rate limit". @@ -30,18 +32,26 @@ Dependency rule (Go-enforced, no cycles): `dialect ← engine ← crq`, `state credential-safe Git execution, stale-worktree pruning, and mirror migration. Owns persistent filesystem and process I/O for checkouts; `crq` supplies only configured roots and a current-token resolver. -- `internal/state/` — persisted schema v5: one `Round` per PR, one global - `FireSlot`, the CodeRabbit `AccountQuota`, an `Archive` ring. Round transition +- `internal/state/` — persisted schema v6: one `Round` per PR, one global + `FireSlot`, the CodeRabbit `AccountQuota`, an `Archive` ring, and the + per-repository records (`Repos` reviewer overrides incl. `PrimaryOff`, + `RepoAutofix`, `Enrolled`). `WriterCaps` is a monotonic integer bumped + whenever one of those records starts changing decisions, so a fleet running + two binary versions can name the hosts that will ignore a new one. Round transition methods, durable tombstones for tidied trigger comments, the CAS store, and dashboard rendering. `Round.CoBots` holds per- co-reviewer trigger bookkeeping; Codex's entry is **dual-written** to the legacy `Codex*` round fields because the fleet shares one state ref across - binary versions (`Normalize` folds them back on load). `Round` and `State` also - **round-trip unknown JSON members** (`tolerant.go`), so a field a newer binary - added survives being read and rewritten by an older one — which is what makes - ordinary additions safe without another dual-write or schema bump. Schema v5 - is the deliberate exception: older v4 clients refuse it, fencing pumping - clients that cannot enforce state-backed fleet policy. + binary versions (`Normalize` folds them back on load). `State`, `Round` and + every record NESTED inside them — `FireSlot`, `FleetDefaults`, `SolverSettings`, + `RepoReviewers`, `RepoEnrollment`, `HostReport`, `ToolReport` — **round-trip unknown JSON members** (`tolerant.go`), so a + field a newer binary added survives being read and rewritten by an older one. + Nesting is why each needs its own: the carrier recognises the member by name + and hands the whole object to an ordinary decoder, which drops anything inside + it. That is what makes ordinary additions safe without another dual-write or + schema bump. Schema v4 deliberately fenced older v3 pumping clients that + could not enforce administrative holds. Schema v5 similarly fences v4 + writers that would erase the dispatch scheduler's model and cooldown state. - `internal/engine/` — PURE decision logic, `now` passed in, no ctx/gh: `DecideFire` (the single fire owner), `Progress` (fired/reviewing round transitions), `Completion` (the one "is the round done?"), `BlockingFindings` diff --git a/README.md b/README.md index 52b6b2ca..6d4d5bcf 100644 --- a/README.md +++ b/README.md @@ -397,24 +397,41 @@ crq feedback # current normalized findings as JSON, WITHOUT trigger crq threads # every unresolved thread, outdated included crq resolve [...] # resolve addressed review threads crq decline [...] --reason "" [--resolve] # record why a finding is declined -crq reviewers # which bots review this project, and what each costs -crq reviewers set [--bots ] [--required ] # choose them (either flag alone) -crq reviewers clear # back to the fleet default -crq autofix install # ⭐ unattended: watch every PR and fix what needs fixing +crq autofix install # ⭐ unattended: watch every PR and fix what needs fixing crq watch # what autofix runs: drive open PRs through crq next, one JSON # line each. Fixing is ON by default (--no-dispatch observes only) crq autofix # which repositories crq may fix crq autofix off --reason "" # stop fixing there; watching and reviewing continue -crq autofix on # enable an explicit override -crq autofix default # return to the fleet default +crq autofix on | crq autofix default crq hold --reason "" # persistently stop reviews for a PR crq unhold # resume reviews for a held PR crq hold # list held PRs crq tidy # delete crq's own spent review-trigger comments (--dry-run previews) +crq serve # ⭐ the live web dashboard (crq serve install enables a reboot-persistent service) + +crq cost # what one more review round there would cost, before firing it + # (CRQ_WEEKLY_LIMIT sets the fair-use threshold the dashboard forecasts) + +crq fleet # the defaults every repository inherits (env → fleet record → repo override) +crq fleet adopt # ⭐ record THIS host's settings for the fleet (--dry-run shows the plan) +crq fleet env [|--clear] # any single setting, by its environment-variable name +crq fleet set [--bots ] [--required ] [--min-interval ] [--weekly-limit ] + [--autofix-default on|off] [--dry-run] # --dry-run reports the impact, writes nothing + +crq solver # models, scope, clarification policy, attempts, forks and prompt +crq solver set [--models ] [--severities minor,potential] [--ask uncertain] +crq solver set [--effort ] [--attempts ] [--forks on|off] [--prompt ] +crq solver set --inherit models,effort,severities,ask,forks,skip-authors # follow the fleet +crq solver set --fleet [...] # the default every repository inherits + +crq repos # which projects crq reviews, and where each answer comes from +crq repos add | crq repos remove --reason "" | crq repos default + crq reviewers # which bots review this project, and what each costs crq reviewers set [--bots ] [--required ] # choose them (either flag alone) +crq reviewers set --no-primary | --primary # CodeRabbit off/on for this project crq reviewers clear # back to the fleet default crq dismiss [...] --reason "" # account for a finding with no thread @@ -489,47 +506,16 @@ clears it. Ambiguous replies surface too — crq never buries a possible rebutta Set these in `~/.config/crq/env` (sourced automatically) or as environment variables: -### One configuration for the fleet, not one per machine - -These settings belong to the whole fleet and live in the state ref, not in the table below: -`scope`, `repos`, `exclude`, `required-bots`, `cobots`, `feedback-bots`, `rate-limit-co-degrade`, -`min-interval`, `inflight-timeout`, `rate-limit-fallback`, `calibrate-ttl`, `settle`, -`skip-marker`, `skip-authors`, and one family per co-reviewer — -`cobot--trigger`, `cobot--cmd`, `cobot--grace`. - -```bash -crq config # what is in force, and where it came from -crq config set exclude owner/paused-repo -crq config unset exclude -crq config seed # adopt this host's current answers as the fleet's -``` - -Per-host files diverge the moment somebody edits one — a repository excluded on the laptop and -reviewed by the server — and nothing says so, because each host is behaving correctly according to -what it can see. A recorded setting wins over the matching environment variable, so you can adopt -this one machine at a time, and `crq doctor` names any variable still set locally that the fleet -overrides. - -What stays per host: where the state lives (`CRQ_REPO`, `CRQ_ISSUE`, `CRQ_STATE_REF`), credentials, -and what the machine can physically do (`CRQ_DISPATCH_CMD`, `CRQ_WORKSPACE`, -`CRQ_DISPATCH_CONCURRENCY`). - -The primary reviewer stays per host too, for a different reason: who it is (`CRQ_BOT`) and the -wording crq reads it by (`CRQ_REVIEW_CMD` and the completion/rate-limit/review-done/calibration -markers) are one unit, and they are compiled into the dialect classifiers before any state ref is -read. Keep them the same on every host — `crq doctor` cannot report a disagreement about them. - - | Variable | Default | What it does | |----------|---------|--------------| | `CRQ_REPO` | *(required)* | the gate repo (`owner/name`) holding the state ref, dashboard, calibration PR | | `CRQ_ISSUE` | from `init` | dashboard issue number | | `CRQ_CAL_PR` | from `init` | calibration PR number | | `CRQ_SCOPE` | owner of `CRQ_REPO` | which owners/orgs share this quota (comma-separated) | -| `CRQ_STATE_REF` | `crq-state-v3` | git ref that stores the typed CAS state. The name is fixed; the schema inside it is v5, and a binary that predates a schema **refuses** the payload rather than erasing it — so upgrade every host together | +| `CRQ_STATE_REF` | `crq-state-v3` | git ref that stores the typed CAS state. The name is fixed; the schema inside it is v6, and a binary that predates a schema **refuses** the payload rather than erasing it — so upgrade every host together | | `CRQ_REPOS` | _(all in scope)_ | `autoreview` allowlist — only these `owner/name` repos (comma-separated) | | `CRQ_EXCLUDE` | _(none)_ | denylist — crq never reviews, watches or fixes these `owner/name` repos (comma-separated) | -| `CRQ_AUTOREVIEW_SKIP_AUTHORS` | `dependabot[bot]` | PR authors `autoreview` never enqueues (comma-separated; case and `[bot]` suffix don't matter) — set to empty to auto-review bot PRs too; manual `crq review` is unaffected | +| `CRQ_AUTOREVIEW_SKIP_AUTHORS` | `dependabot[bot]` | PR authors `autoreview` never enqueues, and the autofix watcher never touches (comma-separated; case and `[bot]` suffix don't matter) — set to empty to auto-review bot PRs too; manual `crq review` is unaffected | | `CRQ_AUTOREVIEW_SKIP_MARKER` | `` | exact PR-body marker that suppresses fleet auto-review; set empty to disable; manual `crq loop` is unaffected | | `CRQ_TIDY` | `0` | set to `1` to delete crq's own spent review-trigger comments as rounds progress (`crq tidy` by hand is unaffected) | | `CRQ_REQUIRED_BOTS` | `coderabbitai[bot]` | bots that must review the head for convergence (crq waits for all of them) | diff --git a/cmd/crq/main.go b/cmd/crq/main.go index 6c15418d..9aef976c 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -13,13 +13,18 @@ import ( "os/exec" "os/signal" "path/filepath" + "sort" "strconv" "strings" "syscall" "time" + _ "time/tzdata" "github.com/kristofferR/coderabbit-queue/internal/crq" + "github.com/kristofferR/coderabbit-queue/internal/dialect" ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" + "github.com/kristofferR/coderabbit-queue/internal/serve" + "github.com/kristofferR/coderabbit-queue/internal/state" ) type stderrLogger struct{} @@ -57,14 +62,6 @@ func run(ctx context.Context, args []string) int { return 0 case "doctor": report := doctor(ctx) - // The fleet's own view needs the state ref, which needs credentials, so - // it is asked for only when the rest of the report says that will work. - // A doctor that cannot answer the basics has more urgent things to say. - if report.Ready { - if diverged := fleetDivergence(ctx); len(diverged) > 0 { - report.Recommendations = append(report.Recommendations, diverged...) - } - } printJSON(report) if report.Ready { return 0 @@ -72,6 +69,27 @@ func run(ctx context.Context, args []string) int { return 1 case "preflight": return preflight(ctx, args[1:]) + case "autofix": + // A dry run is documented as a PREVIEW: it writes nothing and reads no + // GitHub state. Deciding it here, before the authenticated client is + // built, is what lets somebody inspect the setup before finishing it — + // otherwise the one command for looking at the plan was itself another + // thing to set up first. A real install still goes through the + // authenticated path below. + if opts, perr := parseAutofixArgs(args[1:]); perr == nil && opts.dryRun { + cfg, cerr := crq.LoadConfig() + if cerr != nil { + fatal(cerr) + return 1 + } + plan, ierr := crq.AutofixPlan(cfg, opts.agent, crq.SplitArgv(opts.agentArgs), opts.repos, true, opts.skipAuth) + if ierr != nil { + fatal(ierr) + return 1 + } + printJSON(plan) + return 0 + } } cfg, err := crq.LoadConfig() @@ -81,24 +99,6 @@ func run(ctx context.Context, args []string) int { } gh, err := ghapi.NewGitHub(ctx) if err != nil { - // A dry run is documented as a PREVIEW, so it has to work for somebody - // who has not authenticated yet — otherwise the one command for looking - // at the plan is itself another thing to set up first. An authenticated - // one takes the normal path below, where InstallAutofix builds it from the - // same fleet state as the real install (and falls back to this host's - // own plan if that state cannot be read). Every other command needs the - // token this just failed to find. - if args[0] == "autofix" { - if opts, perr := parseAutofixArgs(args[1:]); perr == nil && opts.dryRun { - plan, ierr := hostOnlyAutofixPlan(cfg, opts) - if ierr != nil { - fatal(ierr) - return 1 - } - printJSON(plan) - return 0 - } - } fatal(err) return 1 } @@ -123,7 +123,7 @@ func run(ctx context.Context, args []string) int { if result.CalibrationPR > 0 { fmt.Printf("export CRQ_CAL_PR=%q\n", strconv.Itoa(result.CalibrationPR)) } - fmt.Printf("export CRQ_SCOPE=%q\n", strings.Join(result.Scope, ",")) + fmt.Printf("export CRQ_SCOPE=%q\n", strings.Join(cfg.Scope, ",")) fmt.Printf("export CRQ_STATE_REF=%q\n", result.StateRef) return 0 case "status": @@ -281,6 +281,95 @@ func run(ctx context.Context, args []string) int { } printJSON(result) return 0 + case "cost": + repo, pr, ok := repoPR(args[1:]) + if !ok { + fatal(errors.New("usage: crq cost ")) + return 1 + } + if err := cfg.RequireState(); err != nil { + fatal(err) + return 1 + } + est, cerr := service.Cost(ctx, repo, pr) + if cerr != nil { + fatal(cerr) + return 1 + } + printJSON(est) + return 0 + case "fix-session": + // What `crq watch --dispatch` runs per pull request. It execs the agent, + // so this call does not return on success. + if err := crq.FixSession(ctx, cfg); err != nil { + fatal(err) + return 1 + } + return 0 + case "solver": + if err := cfg.RequireState(); err != nil { + fatal(err) + return 1 + } + return runSolver(ctx, service, args[1:]) + case "fleet": + if err := cfg.RequireState(); err != nil { + fatal(err) + return 1 + } + return runFleet(ctx, service, args[1:]) + case "repos": + if err := cfg.RequireState(); err != nil { + fatal(err) + return 1 + } + sub := "" + if len(args) > 1 { + sub = args[1] + } + switch sub { + case "", "list": + views, verr := service.Enrollments(ctx) + if verr != nil { + fatal(verr) + return 1 + } + printJSON(views) + return 0 + case "add", "remove", "default": + rest, reason, ok := parseAutofixReason(args[2:]) + if !ok || len(rest) != 1 { + fatal(errors.New(`usage: crq repos add|remove|default [--reason ""]`)) + return 1 + } + var view crq.EnrollmentView + var verr error + if sub == "default" { + view, verr = service.ClearEnrollment(ctx, rest[0]) + } else { + view, verr = service.SetEnrollment(ctx, rest[0], sub == "add", reason) + } + if verr != nil { + fatal(verr) + return 1 + } + printJSON(view) + return 0 + default: + // A bare repository name is a read, not a typo: `crq repos owner/name` + // is the obvious way to ask about one. + if strings.Contains(sub, "/") { + view, verr := service.Enrollment(ctx, sub) + if verr != nil { + fatal(verr) + return 1 + } + printJSON(view) + return 0 + } + fatal(fmt.Errorf("unknown repos subcommand %q (try: crq repos, crq repos add|remove|default )", sub)) + return 1 + } case "autofix": switch sub := autofixSubcommand(args[1:]); sub { case "?": @@ -309,12 +398,13 @@ func run(ctx context.Context, args []string) int { return 1 } if sub == "default" { - cleared, cerr := service.ClearAutofixEnabled(ctx, rest[0]) + setting, cleared, cerr := service.ClearAutofixEnabled(ctx, rest[0]) if cerr != nil { fatal(cerr) return 1 } - printJSON(map[string]any{"repo": crq.NormalizeRepo(rest[0]), "cleared": cleared, "enabled": true, "default": true}) + printJSON(map[string]any{"repo": setting.Repo, "cleared": cleared, + "enabled": setting.Enabled, "default": setting.Default}) return 0 } setting, serr := service.SetAutofixEnabled(ctx, rest[0], sub == "on", reason) @@ -331,21 +421,10 @@ func run(ctx context.Context, args []string) int { return 1 } if err := cfg.RequireState(); err != nil { - // Without CRQ_REPO there is no state ref to read, so a preview here - // is the host-only one InstallAutofix would otherwise produce. - if !opts.dryRun { - fatal(err) - return 1 - } - plan, ierr := hostOnlyAutofixPlan(cfg, opts) - if ierr != nil { - fatal(ierr) - return 1 - } - printJSON(plan) - return 0 + fatal(err) + return 1 } - plan, ierr := service.InstallAutofix(ctx, opts.agent, crq.SplitArgv(opts.agentArgs), opts.repos, opts.dryRun) + plan, ierr := service.InstallAutofix(ctx, opts.agent, crq.SplitArgv(opts.agentArgs), opts.repos, opts.dryRun, opts.skipAuth) if ierr != nil { fatal(ierr) return 1 @@ -501,7 +580,262 @@ func run(ctx context.Context, args []string) int { } printJSON(result) return 0 + case "serve": + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + addr := fs.String("addr", "127.0.0.1:7777", "address to listen on") + allowHosts := fs.String("allow-host", "", + "extra names actions may be addressed to (comma-separated), for a proxy or DNS alias in front of the dashboard") + readOnly := fs.Bool("read-only", false, "refuse every write from the dashboard") + poll := fs.Duration("poll", 5*time.Second, "how often to re-read the state ref") + dryRun := fs.Bool("dry-run", false, "with install: print the plan, write nothing") + skipAuth := fs.Bool("skip-auth-check", false, + "with install: install without proving the service can authenticate (a macOS host reached over SSH, where gh's keychain is the GUI session's)") + // `crq serve install` keeps the dashboard running across a logout and a + // reboot. Split before the flags are parsed so the subcommand word does + // not have to be a flag value. + serveArgs, install := args[1:], false + if len(serveArgs) > 0 && serveArgs[0] == "install" { + serveArgs, install = serveArgs[1:], true + } + if err := fs.Parse(serveArgs); err != nil { + return 1 + } + if strings.TrimSpace(*addr) == "" { + // net/http treats an empty address as :http. Keep an accidentally + // expanded-empty shell variable on the documented loopback listener. + *addr = "127.0.0.1:7777" + } + if !install && (*dryRun || *skipAuth) { + fatal(errors.New("--dry-run and --skip-auth-check apply to `crq serve install` only")) + return 1 + } + if install { + if err := cfg.RequireState(); err != nil { + fatal(err) + return 1 + } + plan, ierr := service.InstallServe(ctx, *addr, splitList(allowHosts), *readOnly, *poll, *dryRun, *skipAuth) + if ierr != nil { + fatal(ierr) + return 1 + } + printJSON(plan) + return 0 + } + if err := cfg.RequireState(); err != nil { + fatal(err) + return 1 + } + // One resolver for both: the fleet list is just the resolver applied to a + // repository with no override, so the two can never drift. + resolve := func(st crq.State, repo string) []serve.BotName { + rc := service.ConfigIn(st, repo) + out := []serve.BotName{} + if primary, ok := rc.Primary(); ok { + out = append(out, serve.BotName{ + Login: primary.Login, Name: primary.Name, Primary: true, Required: primary.Required, + Command: primary.Command, Trigger: string(primary.Trigger), + Grace: serve.Dur(primary.SelfHealGrace), + }) + } + for _, co := range rc.FreeRunning() { + out = append(out, serve.BotName{ + Login: co.Login, Name: co.Name, Required: co.Required, + Command: co.Command, Trigger: string(co.Trigger), + Grace: serve.Dur(co.SelfHealGrace), + }) + } + return out + } + bots := resolve(crq.DefaultState(cfg), "") + // Fix-session settings resolve through env, the fleet default and the + // repository's own record — all of which live in the service. + solverFor := func(st crq.State, repo string) serve.RepoSolver { + v := service.SolverIn(st, repo) + out := serve.RepoSolver{ + Overridden: v.Overridden, Agent: v.Agent, Models: v.Models, ModelChoices: v.ModelChoices, + Model: v.Model, Effort: v.Effort, + Prompt: v.Prompt, MaxAttempts: v.MaxAttempts, Forks: v.Forks, + Severities: v.Severities, AskMode: v.AskMode, + SkipAuthors: v.SkipAuthors, Sources: v.Sources, By: v.By, + Lagging: hostsOfWriters(v.Lagging), + } + // Which hosts can actually run the agent — capability, beside the + // policy, so a repository is never quietly set to something no + // machine can do. + agent := v.Agent + if i := strings.LastIndex(agent, "/"); i >= 0 { + agent = agent[i+1:] + } + if agent == "" { + // Nobody has said which agent this fleet fixes with, so there is + // nothing to check. Defaulting to claude answered a question no + // host had been asked: a codex installation had every host's + // claude probe reported as its agent's availability, and the + // setup that works read as the one that is missing. + return out + } + for _, r := range st.HostReportList() { + out.AgentOn = append(out.AgentOn, solverAgentHost(r, agent, time.Now().UTC())) + } + return out + } + // The enrollment rule lives in the service; serve only renders it. + enrollFor := func(st crq.State, repo string) serve.Enrollment { + v := service.EnrollmentIn(st, repo) + return serve.Enrollment{ + Source: v.Source, Enabled: v.Enabled, EnvConflict: v.EnvConflict, ClearEnables: v.ClearEnables, + Reason: v.Reason, By: v.By, UpdatedAt: parseStamp(v.UpdatedAt), + } + } + reviewers := []serve.ReviewerCfg{} + if primary, ok := cfg.Primary(); ok { + reviewers = append(reviewers, serve.ReviewerCfg{ + Login: primary.Login, Name: primary.Name, Primary: true, + Required: primary.Required, Metered: primary.Metered(), + Command: primary.Command, Trigger: string(primary.Trigger), + Grace: serve.Dur(primary.SelfHealGrace), + }) + } + for _, co := range cfg.FreeRunning() { + reviewers = append(reviewers, serve.ReviewerCfg{ + Login: co.Login, Name: co.Name, Required: co.Required, Metered: co.Metered(), + Command: co.Command, Trigger: string(co.Trigger), Grace: serve.Dur(co.SelfHealGrace), + }) + } + host, _ := os.Hostname() + // The dashboard is a service on this machine like the others, so the + // host table should say which machine serves it — otherwise the one + // host you are certainly talking to is the one it cannot name. + // + // On a timer for the lifetime of the server, the way autoreview and + // autofix report from their passes. On a host running nothing else, this + // is the only heartbeat there is: reporting once at startup left the + // still-running dashboard marking its own host stale after HostReportTTL, + // so Setup stopped naming the machine you were looking at it on. + serveCtx, stopReports := context.WithCancel(ctx) + defer stopReports() + dashboardReadOnly := *readOnly || cfg.DryRun + if !dashboardReadOnly { + service.ReportHost(ctx, "serve") + go func() { + tick := time.NewTicker(crq.HostReportTTL / 2) + defer tick.Stop() + for { + select { + case <-serveCtx.Done(): + return + case <-tick.C: + service.ReportHost(serveCtx, "serve") + } + } + }() + } + srv := serve.New(store, serve.Options{ + Addr: *addr, + AllowedHosts: splitList(allowHosts), + MinInterval: cfg.MinInterval, + Inflight: cfg.InflightTimeout, + WeeklyLimit: cfg.WeeklyReviewLimit, + // The three above are the startup fallback; this resolves them + // against the state the dashboard is rendering, so a fleet pacing or + // fair-use save reaches the cards on the next revision rather than on + // the next restart. The rule lives in the service, as every other + // resolution the dashboard renders does. + PacingFor: func(st crq.State) serve.Pacing { + c := service.ConfigIn(st, "") + return serve.Pacing{ + MinInterval: c.MinInterval, + Inflight: c.InflightTimeout, + WeeklyLimit: c.WeeklyReviewLimit, + } + }, + Bots: bots, + Resolve: resolve, + EnrollFor: enrollFor, + SolverFor: solverFor, + FleetFor: func(st crq.State) *serve.FleetSettings { + return fleetSettingsOf(service.FleetSettingsIn(st)) + }, + AllowReposFor: func(st crq.State) []string { + return keysOf(service.ConfigIn(st, "").AllowRepos) + }, + Discoverer: repoDiscoverer{service}, + Previewer: enrollPreviewer{service}, + Poll: *poll, + Assets: serve.Assets(), + Log: stderrLogger{}, + Host: host, + LookupToken: ghapi.LookupToken, + Observer: prObserver{svc: service, readOnly: dashboardReadOnly}, + Coster: prCoster{service}, + TailLog: func(ctx context.Context, repo, path string, maxBytes int64) (serve.LogTail, error) { + tail, err := service.TailSessionLog(ctx, repo, path, maxBytes) + return serve.LogTail{Text: tail.Text, Size: tail.Size, Truncated: tail.Truncated}, err + }, + Actor: prActor{service}, + ReadOnly: dashboardReadOnly, + Fleet: serve.FleetConfig{ + GateRepo: cfg.GateRepo, + StateRef: cfg.StateRef, + DashboardIssue: cfg.DashboardIssue, + CalibrationPR: cfg.CalibrationPR, + Scope: cfg.Scope, + AllowRepos: keysOf(cfg.AllowRepos), + ExcludeRepos: keysOf(cfg.ExcludeRepos), + SkipAuthors: keysOf(cfg.SkipAuthors), + SkipMarker: cfg.SkipMarker, + + MinInterval: serve.Dur(cfg.MinInterval), + InflightTimeout: serve.Dur(cfg.InflightTimeout), + WatchInterval: serve.Dur(cfg.WatchInterval), + + Reviewers: reviewers, + + AutofixCommand: cfg.DispatchCommand, + AutofixMaxAttempts: cfg.DispatchMaxAttempts, + AutofixConcurrency: cfg.DispatchConcurrency, + AutofixForks: cfg.DispatchForks, + WorkspaceRoot: cfg.WorkspaceRoot, + }, + }) + if err := srv.Run(ctx); err != nil { + fatal(err) + return 1 + } + return 0 case "autoreview", "auto": + // `crq autoreview install` makes it a service. WHICH host runs it is a + // real choice and not a detail: this daemon takes the leader lease, so + // the fleet only fires reviews while that machine is awake. A laptop + // that sleeps is the wrong host for it, and nothing about the queue says + // so — reviews simply stop arriving. + if len(args) > 1 && args[1] == "install" { + if err := cfg.RequireState(); err != nil { + fatal(err) + return 1 + } + dryRun, skipAuth := false, false + for _, arg := range args[2:] { + switch arg { + case "--dry-run": + dryRun = true + case "--skip-auth-check": + skipAuth = true + default: + fatal(fmt.Errorf("unknown flag %q: usage is crq autoreview install [--dry-run] [--skip-auth-check]", arg)) + return 1 + } + } + plan, ierr := service.InstallAutoReview(ctx, dryRun, skipAuth) + if ierr != nil { + fatal(ierr) + return 1 + } + printJSON(plan) + return 0 + } fs := flag.NewFlagSet("autoreview", flag.ContinueOnError) fs.SetOutput(os.Stderr) once := fs.Bool("once", false, "run one pass") @@ -534,68 +868,22 @@ func run(ctx context.Context, args []string) int { } printJSON(map[string]any{"status": "cancelled", "repo": crq.NormalizeRepo(repo), "pr": pr}) return 0 - case "config": + case "prioritize": + repo, pr, err := target(ctx, service, args[1:], "crq prioritize [ ]") + if err != nil { + fatal(err) + return 1 + } if err := cfg.RequireState(); err != nil { fatal(err) return 1 } - rest := args[1:] - switch { - case len(rest) == 0 || rest[0] == "show": - // Showing takes no operand either: a trailing token is a misspelled - // setting or subcommand, and printing the whole configuration would - // report success for an invocation that asked for something else. - if len(rest) > 1 { - fatal(errors.New("usage: crq config show")) - return 1 - } - settings, cerr := service.FleetConfig(ctx) - if cerr != nil { - fatal(cerr) - return 1 - } - printJSON(settings) - return 0 - case rest[0] == "set": - if len(rest) != 3 { - fatal(errors.New("usage: crq config set ")) - return 1 - } - if err := service.SetFleetConfig(ctx, rest[1], rest[2]); err != nil { - fatal(err) - return 1 - } - printJSON(map[string]any{"setting": rest[1], "value": rest[2], "scope": "fleet"}) - return 0 - case rest[0] == "unset": - if len(rest) != 2 { - fatal(errors.New("usage: crq config unset ")) - return 1 - } - dropped, uerr := service.UnsetFleetConfig(ctx, rest[1]) - if uerr != nil { - fatal(uerr) - return 1 - } - printJSON(map[string]any{"setting": rest[1], "cleared": dropped}) - return 0 - case rest[0] == "seed": - // Seeding takes no operand and records every unset fleet key, so a - // stray token is a typo for something narrower, not a selector. - if len(rest) != 1 { - fatal(errors.New("usage: crq config seed")) - return 1 - } - seeded, serr := service.SeedFleetConfig(ctx) - if serr != nil { - fatal(serr) - return 1 - } - printJSON(map[string]any{"seeded": seeded}) - return 0 + if err := service.Prioritize(ctx, repo, pr); err != nil { + fatal(err) + return 1 } - fatal(fmt.Errorf("unknown config subcommand %q (try: crq config, crq config set|unset [value], crq config seed)", rest[0])) - return 1 + printJSON(map[string]any{"status": "prioritized", "repo": crq.NormalizeRepo(repo), "pr": pr}) + return 0 case "debug": return debug(ctx, service, store, cfg, args[1:]) default: @@ -673,6 +961,7 @@ QUEUE WORKFLOWS crq wait [ ] block until there IS something to do, then say what crq loop queue one PR review round, then emit JSON feedback crq autoreview keep open PRs reviewed through the same queue + crq serve the live web dashboard (--read-only to refuse writes) crq status [--line] show the queue, in-flight review, and quota state DRIVING A PR REVIEW @@ -704,7 +993,7 @@ USAGE crq decline [...] --reason "" [--keep-open] reply on a thread to record why a finding is declined (resolves it; --keep-open leaves it open) - crq autofix install [--agent ] [--agent-args ] [--dry-run] [...] + crq autofix install [--agent ] [--dry-run] [...] install and start unattended autofix crq autofix [on|off|default ] which repositories crq may fix (on by default) @@ -722,15 +1011,15 @@ USAGE crq threads list every unresolved review thread, outdated ones included crq dismiss [...] --reason "" account for a finding GitHub gives you no thread to close + crq serve [--addr 127.0.0.1:7777] [--poll 5s] crq autoreview [--once] [--no-incremental] keep open PRs reviewed, rate-coordinated crq preflight [--type all|committed|uncommitted] [--base ] local CodeRabbit CLI pre-push review as JSON - crq config [set|unset [value]] | seed - fleet-wide policy, shared by every host crq doctor emit JSON readiness report for agents and humans crq status [--line] print the dashboard, or one line for a status bar crq cancel [ ] remove queued/in-flight state for a PR + crq prioritize [ ] move a tracked PR to the top of review and autofix crq debug maintenance tools; not for normal review loops @@ -878,41 +1167,195 @@ replies contesting the decline, crq re-surfaces that reply as its own finding. Pass --keep-open to leave it unresolved anyway (an on-the-record disagreement you intend to keep working). Thread IDs come from .findings[].thread_id. `) - case "reviewers": - fmt.Print(`crq reviewers -crq reviewers set [--bots ] [--required ] -crq reviewers clear - -Which bots review one project, and what each of them costs. - -Without a subcommand it reports the reviewers that will actually run there — the -fleet default, or this repository's own choice if it has one. Each entry carries -its budget: "account" is serialized against the shared CodeRabbit allowance, -"none" runs immediately, outside that queue. Budget is not requiredness: whether -a round WAITS for a reviewer is --required, whatever the reviewer costs. - - set --bots chooses the co-reviewers; --required chooses which reviewers - gate convergence. Either flag alone updates only its own half. An - empty --bots means none here, which is a different answer from not - setting it at all; an empty --required is refused, because a round - that gates on nobody converges before any reviewer runs. - clear drops the override so the repository follows the fleet again. - -The configuration lives in the shared state ref, not in a file the repository -carries: the daemon has no checkout of the repos it reviews, and a daemon and an -agent reading different configurations while writing one state ref is a class of -bug worth not having. - -The primary reviewer is fleet-wide. Its markers and command are compiled into the -classifiers when crq starts, so a per-repo primary would mean per-repo -classifiers — a much larger change than choosing who else runs. + case "serve": + fmt.Print(`crq serve [--addr host:port] [--allow-host names] [--read-only] [--poll ] +crq serve install [--addr host:port] [--allow-host names] [--read-only] [--dry-run] [--skip-auth-check] + +The live web dashboard: the queue, the repositories, the bots and the settings, +in a page that updates itself. The GitHub issue dashboard is unaffected and +stays exactly as it was. + +State is pushed over server-sent events whenever the state ref moves, and +countdowns tick in the browser between pushes, so the page is live without +polling and without a request per second. The expensive per-pull-request GitHub +read is a separate layer, fetched when you open a PR and cached by head — the +cheap state layer always renders immediately, and a GitHub failure costs one +card rather than the page. + + --addr default 127.0.0.1:7777. Bind 0.0.0.0 to reach it from another + machine on a private network; there is NO authentication, so do + not put it on one you do not trust. + --allow-host extra names an action may be addressed to, comma-separated. + Actions are accepted on an IP literal, on localhost, on the bound + address and on this machine's own name — a name that merely + RESOLVES here is refused, because that is how a page on another + site reaches an unauthenticated local dashboard. Name a reverse + proxy or a DNS alias here. + --read-only refuse every write, for pointing at a fleet you do not administer. + install write a service definition (systemd user unit, or a launchd + agent) and start it, so the dashboard survives a reboot. It + carries only the path to your config file, so editing that file + changes the dashboard by restarting it, not by reinstalling. +`) + case "cost": + fmt.Printf(`crq cost + +What one more review round on this pull request would cost, before firing it. + +Everything is an ESTIMATE and the output says which parts are not: + + .low / .high the range, in US dollars + .exact true only when every reviewer's figure is a real number + .unpriced reviewers crq has no pricing for, so the total is a floor + .reviewers[] per bot, each keeping its own .basis — the sentence that + explains the figure, because a number without its reasoning + cannot be checked + .prices_checked_at when the published prices behind this were last verified + .pricing_note the vendor billing disclosure shared with the dashboard + +%s +`, dialect.PricingDisclosure) + case "fix-session": + fmt.Print(`crq fix-session + +One fix session, run by the watcher for one pull request. You do not call this; +'crq autofix install' points the watcher at it. + +It exists so crq is one binary. The install used to write two bash scripts — a +wrapper that started the watcher and a session script that assembled the +agent's command line — so three things had to agree about the configuration and +two of them were generated text on disk that no test ever ran. A setting added +to crq reached a fleet only after every host reinstalled, and nothing said which +hosts had not. + +It reads the dispatcher's environment: the agent and prompt file from the unit, +the model, effort and extra instruction from the repository's solver settings. +An unset model or effort adds no flag at all rather than an empty one, which +every agent rejects differently and none ignores. +`) + case "solver": + fmt.Print(`crq solver +crq solver set [--models ] [--effort ] [--prompt ] + [--severities ] + [--ask blocked|uncertain|ambiguous] + [--attempts ] [--forks on|off] [--skip-authors ] + [--inherit models,effort,severities,ask,forks,skip-authors] +crq solver set --fleet [...] (the default every repository inherits) +crq solver clear | crq solver clear --fleet + +How a fix session runs here: which model, how hard it thinks, what else to tell +it, and the limits crq itself enforces. + +Three layers, least specific first: this host's env, the fleet default, then +this repository. .sources says which layer answered for each setting. + + --models preferred model followed by ordered fallbacks + --model legacy spelling for a one-entry model ranking + --effort low | medium | high | xhigh | max + --prompt standing instruction appended to every fix session here + ("this project uses bun, never npm") + --severities only hand these finding severities to the agent + --ask when uncertainty becomes a dashboard clarification + --attempts failed code-fix sessions per head before crq stops; provider + outages do not count; 0 inherits + --forks allow sessions on pull requests from another repository. + Off by default: a session runs an agent over somebody else's + code with approvals bypassed and a write token in reach + --skip-authors pull-request authors crq does not enqueue here + --inherit hand models, effort, forks or skip-authors to the layer beneath + +Models are tried in order. A provider/model outage is parked until its reset +time, the next model is tried, and no attempt is spent. Ordinary failed fixes +consume an attempt and rotate models. An exhausted attempt cycle cools down, +then automatically opens a fresh cycle with increasing backoff. If all models +are parked, crq waits for the earliest reset instead of permanently exhausting +the pull request. + +The AGENT itself is not per repository. It is chosen by 'crq autofix install' +and baked into the session script, because switching between claude and codex +is a different command line rather than a different flag. Model and effort are +per repository, because every agent has them. + +These reach the session through its environment rather than its arguments: the +watcher's argv is fixed when it starts, and one watcher handles every +repository. A session script from an install that predates this ignores them +and runs exactly as it did, so reinstalling autofix is what turns them on. +`) + case "fleet": + fmt.Print(`crq fleet +crq fleet set [--bots ] [--required ] [--min-interval ] + [--weekly-limit ] [--autofix-default on|off] [--dry-run] +crq fleet clear +crq fleet adopt [--dry-run] (record THIS host's settings for the fleet) +crq fleet env [|--clear] (any one setting, by its env name) + +The defaults every repository inherits, recorded once for the whole fleet +instead of in each host's env file. + +Three layers, least specific first: this host's env, then the fleet record, +then a repository's own override. A setting absent from the record keeps using +env, so a fleet that has never run 'fleet set' behaves exactly as before — +and .sources says, per setting, which layer the current value came from. That +distinction is the point: changing a value that reads "env" here changes it for +this host only. + +--dry-run reports what the change WOULD do and writes nothing. It is worth +using: a per-repo save affects the repository you are looking at, and a fleet +save affects every repository that has not overridden the setting. Adding a +required reviewer invalidates the "this head was reviewed" marker on every +completed round that never had it, so those rounds are reopened and reviewed +again — .reopened counts them before you decide. + +'clear' drops the whole record and returns every setting to this host's env. + +'adopt' is the one to run on a fleet that predates all this. Every answer lives +in one machine's env file, so the dashboard reports "env" beside all of them — +true, and useless. Adopting copies those values into the shared record so they +become the fleet's answer and every host reads the same one. It takes only what +CAN be fleet-wide: identity (which repository holds the queue) and per-host +values (paths, this machine's name, the fix agent) are reported as skipped +rather than silently dropped. Values equal to the default are skipped too — +recording one would pin today's default, invisibly. --dry-run shows the plan. + +'env' reaches any single setting by its environment-variable name, including +the ones with no flag of their own. It refuses a value that would not parse, +because a fleet setting fails on every host at once. +`) + case "repos": + fmt.Print(`crq repos (every repository crq knows about) +crq repos (one repository, and why) +crq repos add +crq repos remove --reason "" +crq repos default (back to whatever this host's env says) + +Which projects crq reviews at all, recorded in the shared state ref so every +host agrees — and so the decision can be made from the dashboard instead of by +editing an env file on whichever machine happens to run the daemon. + +.source says where the answer came from, which matters more than the answer: + + state a record here decided it — add/remove/default write these + env this host's CRQ_REPOS lists it, with no record either way + excluded this host's CRQ_EXCLUDE names it, or it is the gate repository + scope no allow-list at all, so everything in CRQ_SCOPE is reviewed + off no record, no env mention, and an allow-list that omits it + +CRQ_EXCLUDE wins over everything: it is a per-host kill switch, and the machine +that has one usually has a reason the fleet does not know. Otherwise a record +wins in BOTH directions — an Off switch that only tells you which file to go and +edit on another machine is not a switch. When a record turns off a repository a +host's CRQ_REPOS lists, .env_conflict says so rather than letting the file and +the fleet disagree in silence. + +Removing needs --reason. The repository disappears from every queue, and "why did +this stop being reviewed" is a question the fleet should be able to answer itself. `) case "autofix": - fmt.Print(`crq autofix install [--agent ] [--agent-args ] [--dry-run] [...] + fmt.Print(`crq autofix install [--agent ] [--dry-run] [...] crq autofix (which repositories crq may fix) crq autofix off [--reason ""] crq autofix on -crq autofix default (back to the fleet default, which is on) +crq autofix default (back to the fleet default; on unless the fleet says otherwise) Install and start unattended autofix: crq watches every open PR and starts a fix session for the ones that need one. @@ -930,9 +1373,7 @@ platform needs to survive a logout, and starts it. --dry-run prints the paths an commands without touching anything. The agent defaults to "claude" on PATH; --agent takes another path to it, or a -wrapper around it. --agent-args passes a shell-style argument string to that -agent, for example a model or reasoning-effort override. The installed service -runs the agent with Claude Code's own +wrapper around it. The installed service runs the agent with Claude Code's own flags (-p, --permission-mode, --output-format), so an agent with a different CLI needs its own wrapper script and "crq watch -- ...", which runs whatever argv you give it. --agent must name something runnable; a name that @@ -979,7 +1420,7 @@ review in one queue. crq still does not decide which findings are real — it starts the session and says which PR to look at; the session judges. Every dispatch is claimed under compare-and-swap, so two watchers cannot both work one PR, and bounded per head -(CRQ_DISPATCH_MAX_ATTEMPTS, default 3) so a fix that keeps not working stops +(CRQ_DISPATCH_MAX_ATTEMPTS, default 5) so a fix that keeps not working stops instead of spending a review round each time. Repositories default to CRQ_REPOS. @@ -1029,6 +1470,45 @@ a finding — deleting those would destroy feedback nobody had read yet. Set CRQ_TIDY=1 to run it automatically as rounds progress under crq autoreview. --dry-run reports what it would remove. +`) + case "reviewers": + fmt.Print(`crq reviewers +crq reviewers set [--bots ] [--required ] [--primary|--no-primary] +crq reviewers clear + +Which bots review one project, and what each of them costs. + +Without a subcommand it reports the reviewers that will actually run there — the +fleet default, or this repository's own choice if it has one. Each entry carries +its budget: "account" is serialized against the shared CodeRabbit allowance, +"none" runs immediately, outside that queue. Budget is not requiredness: whether +a round WAITS for a reviewer is --required, whatever the reviewer costs. + + set --bots chooses the co-reviewers; --required chooses which reviewers + gate convergence; --no-primary / --primary turns the metered primary + off or on here. Each flag alone updates only its own half. An empty + --bots means none here, which is a different answer from not setting + it at all; an empty --required is refused, because a round that gates + on nobody converges before any reviewer runs. + clear drops the override so the repository follows the fleet again. + +The configuration lives in the shared state ref, not in a file the repository +carries: the daemon has no checkout of the repos it reviews, and a daemon and an +agent reading different configurations while writing one state ref is a class of +bug worth not having. + +WHICH bot is primary is fleet-wide: its markers and command are compiled into +the classifiers when crq starts, so a per-repo primary would mean per-repo +classifiers. Whether it RUNS here is a different question. --no-primary means +crq never posts its review command on this repository, never spends the account +quota or the fire slot on it, and never waits for its review — the co-reviewers +resolve those rounds alone. That is the switch for a private repository on a +free plan, where the metered reviewer produces nothing worth queueing for. + +It also drops the primary from the effective required set, because a reviewer +that does not run cannot gate, and is refused when nobody else is required. +Leaving the primary out of --required alone is the weaker thing: it is still +asked and still spends quota, the round just does not WAIT for it. `) case "threads": fmt.Print(`crq threads @@ -1050,10 +1530,10 @@ Read this, decide, then crq resolve (or crq decline) the ones you have answered. Record that you have accounted for a finding that has no review thread, so it stops blocking the round. -crq resolve and crq decline both act on a thread. Review-body findings, -review-skipped notices, outside-diff remarks and issue-comment findings have no -thread, so neither command can act on them. Dismiss one after judging it so it -does not block the current head's round. +crq resolve and crq decline both act on a thread. A review-body finding, a +review-skipped notice or an outside-diff remark has none, so neither command can +touch it — and a finding that can never be cleared blocks every future round, leaving +a PR whose current head no review was ever requested for. Finding IDs come from .findings[].id. They are content-derived, not GitHub node IDs, so the repo and PR are required. A dismissal covers the current head only: @@ -1065,13 +1545,15 @@ dismissing only records that you decided to live with it at this head. `) case "autoreview", "auto": fmt.Print(`crq autoreview [--once] [--no-incremental] +crq autoreview install [--dry-run] [--skip-auth-check] Keep open PRs in CRQ_SCOPE reviewed, using the same account-wide queue and quota. Run only one long-lived autoreview daemon. Manual crq loop calls share its idempotent queue entry, so they re-attach to the same wait instead of firing a duplicate review. - --once scan once and exit - --no-incremental only review PRs that have never been reviewed by CodeRabbit + --once scan once and exit + --no-incremental only review PRs that have never been reviewed by CodeRabbit + --skip-auth-check with install: do not prove the service can authenticate first Use this instead of CodeRabbit native auto-review. Native auto-review must be off. `) @@ -1117,44 +1599,6 @@ Typical setup: crq init Save the printed exports to ~/.config/crq/env on every machine or agent host. -`) - case "config": - fmt.Print(`crq config -crq config set -crq config unset -crq config seed - -The policy every host shares, kept in the state ref rather than in each -machine's environment. - -Per-host configuration files diverge the moment somebody edits one — a -repository excluded on the laptop and reviewed by the server, a rate-limit -window one host respects and another does not — and nothing says so, because -each host is behaving correctly according to what it can see. These settings -have one answer for the fleet: - - scope, repos, exclude, required-bots, cobots, feedback-bots, - rate-limit-co-degrade, min-interval, inflight-timeout, rate-limit-fallback, - calibrate-ttl, settle, skip-marker, skip-authors, and per co-reviewer: - cobot--trigger, cobot--cmd, cobot--grace - -Three kinds of setting deliberately stay local: where the state lives -(CRQ_REPO, CRQ_ISSUE, CRQ_STATE_REF — a host cannot read fleet policy until it -knows where to look), credentials, and what a machine can physically do (which -fix agent is installed, where its disk is, how many sessions it can take). - -A recorded setting wins over the environment variable it replaces, so adopting -this is one machine at a time: until something is recorded, every host keeps -using its own. "crq config seed" writes this host's current answers for -everything the fleet has not decided yet — run it once, on the machine whose -configuration is the one you mean. - -A value this crq cannot read is refused at "set", and one that arrives anyway -from a newer binary leaves the host on its own value rather than acting on half -a policy. "crq config unset" drops such a setting from any host — except while a -newer crq is driving the queue, since that binary is the one that can reconcile -its removal. "crq doctor" reports both that and any variable still set here that -the fleet overrides. `) case "doctor": fmt.Print(`crq doctor @@ -1184,6 +1628,12 @@ actually knows which one that is. `) case "cancel": fmt.Print("crq cancel \n\nRemove a PR from queued/in-flight crq state.\n") + case "prioritize": + fmt.Print(`crq prioritize [ ] + +Move a tracked pull request to the top of both the review and autofix queues. +Inside a pull request checkout, the target can be omitted. +`) case "debug": fmt.Print(`crq debug @@ -1327,9 +1777,17 @@ func runReviewers(ctx context.Context, service *crq.Service, args []string) int } var repo string var bots, required *string + var primary *bool + setPrimary := func(on bool) { primary = &on } for i := 0; i < len(rest); i++ { arg := rest[i] switch { + // The primary is the one reviewer that costs the shared account, so + // turning it off for a project is a budget decision, not a taste one. + case arg == "--no-primary": + setPrimary(false) + case arg == "--primary": + setPrimary(true) case arg == "--bots", arg == "--required": if i+1 >= len(rest) { fatal(fmt.Errorf("%s needs a value", arg)) @@ -1369,23 +1827,23 @@ func runReviewers(ctx context.Context, service *crq.Service, args []string) int case "clear": // Ignoring a mutation flag here would turn a malformed automation call // like `reviewers clear repo --bots codex` into a silent wipe. - if bots != nil || required != nil { - fatal(errors.New("clear takes no --bots/--required (it drops the whole override)")) + if bots != nil || required != nil || primary != nil { + fatal(errors.New("clear takes no --bots/--required/--primary (it drops the whole override)")) return 1 } view, err = service.ClearReviewers(ctx, repo) case "set": - if bots == nil && required == nil { - fatal(errors.New("set needs --bots or --required (crq reviewers clear drops the override)")) + if bots == nil && required == nil && primary == nil { + fatal(errors.New("set needs --bots, --required or --primary/--no-primary (crq reviewers clear drops the override)")) return 1 } - view, err = service.SetReviewers(ctx, repo, splitList(bots), splitList(required)) + view, err = service.SetReviewers(ctx, repo, splitList(bots), splitList(required), primary) default: // `crq reviewers owner/repo --bots codex` is a set command missing its // verb. Showing the configuration and exiting 0 tells automation the // mutation worked when nothing changed. - if bots != nil || required != nil { - fatal(errors.New("did you mean `crq reviewers set`? --bots/--required only apply to set")) + if bots != nil || required != nil || primary != nil { + fatal(errors.New("did you mean `crq reviewers set`? --bots/--required/--primary only apply to set")) return 1 } view, err = service.Reviewers(ctx, repo) @@ -1897,48 +2355,15 @@ func configPath() string { return home + "/.config/crq/env" } -// fleetDivergence asks the state ref what the fleet's policy is and reports -// where this host disagrees. Errors are swallowed: doctor is a report, and one -// that fails because the network was down would be worse than one missing a -// section. -func fleetDivergence(ctx context.Context) []string { - cfg, err := crq.LoadConfig() - if err != nil || cfg.RequireState() != nil { - return nil - } - gh, err := ghapi.NewGitHub(ctx) - if err != nil { - return nil - } - store := crq.NewGitStateStore(cfg, gh, nil) - diverged, err := crq.NewService(cfg, gh, store, nil).FleetDivergence(ctx) - if err != nil { - return nil - } - return diverged -} - // autofixArgs is `crq autofix install`'s parsed command line. type autofixArgs struct { agent string agentArgs string dryRun bool + skipAuth bool repos []string } -// hostOnlyAutofixPlan is the preview for a host that cannot reach fleet state at -// all: this machine's own answers, labelled so the plan cannot be mistaken for -// the fleet-backed one an authenticated install would write. -func hostOnlyAutofixPlan(cfg crq.Config, opts autofixArgs) (crq.AutofixInstall, error) { - plan, err := crq.AutofixPlan(cfg, opts.agent, crq.SplitArgv(opts.agentArgs), opts.repos, true) - if err != nil { - return crq.AutofixInstall{}, err - } - plan.PolicySource = "host" - plan.Warning = crq.HostOnlyAutofixWarning - return plan, nil -} - // parseAutofixArgs is shared by the pre-authentication dry-run path and the // install itself, so the two cannot disagree about what was asked for. func parseAutofixArgs(args []string) (autofixArgs, error) { @@ -1947,6 +2372,8 @@ func parseAutofixArgs(args []string) (autofixArgs, error) { agent := fs.String("agent", "", "fix agent to run: claude or codex (default: claude on PATH)") agentArgs := fs.String("agent-args", "", "extra flags for the agent, e.g. model and reasoning effort") dryRun := fs.Bool("dry-run", false, "print what would be written and run") + skipAuth := fs.Bool("skip-auth-check", false, + "install without proving the service can authenticate (a macOS host reached over SSH, where gh's keychain is the GUI session's)") sub, rest := "", args if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") { sub, rest = rest[0], rest[1:] @@ -1955,9 +2382,9 @@ func parseAutofixArgs(args []string) (autofixArgs, error) { return autofixArgs{}, err } if sub != "install" { - return autofixArgs{}, errors.New("usage: crq autofix install [--agent ] [--agent-args ] [--dry-run] [...]") + return autofixArgs{}, errors.New("usage: crq autofix install [--agent ] [--dry-run] [...]") } - return autofixArgs{agent: *agent, agentArgs: *agentArgs, dryRun: *dryRun, repos: fs.Args()}, nil + return autofixArgs{agent: *agent, agentArgs: *agentArgs, dryRun: *dryRun, skipAuth: *skipAuth, repos: fs.Args()}, nil } // autofixSubcommand names what `crq autofix ...` was asked to do. An empty string @@ -2000,3 +2427,699 @@ func parseAutofixReason(args []string) (rest []string, reason string, ok bool) { } return rest, reason, true } + +func solverAgentHost(report state.HostReport, agent string, now time.Time) serve.HostHas { + has := serve.HostHas{ + Host: state.WriterHost(report.Host), + Stale: !report.RolesFresh([]string{"autofix"}, now, crq.HostReportTTL), + } + for _, tool := range report.Tools { + if tool.Name != agent { + continue + } + found := tool.Path != "" + has.Has, has.Path = &found, tool.Path + } + return has +} + +// keysOf flattens a config set into the sorted list the dashboard displays. +func keysOf(set map[string]bool) []string { + if len(set) == 0 { + return nil + } + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// prObserver adapts the orchestrator's feedback path to the dashboard's +// read-only view. The mapping lives here rather than in serve so that package +// stays independent of the frozen CLI report shape. +type prObserver struct { + svc *crq.Service + readOnly bool +} + +func (o prObserver) Observe(ctx context.Context, repo string, pr int) (serve.Observation, error) { + var report crq.FeedbackReport + var err error + if o.readOnly { + report, err = o.svc.FeedbackReadOnly(ctx, repo, pr) + } else { + report, err = o.svc.Feedback(ctx, repo, pr) + } + if err != nil { + return serve.Observation{}, err + } + return serve.Observation{ + Head: report.Head, + Converged: report.Converged, + Status: report.Status, + Reason: report.Reason, + ReviewedBy: report.ReviewedBy, + Findings: report.Findings, + Dismissed: report.Dismissed, + CheckedAt: report.CheckedAt, + }, nil +} + +// prActor mirrors the CLI verbs the dashboard exposes. Each one calls the same +// Service method the command line does, so the two cannot drift apart. +type prActor struct{ svc *crq.Service } + +func (a prActor) Hold(ctx context.Context, repo string, pr int, reason string) error { + _, err := a.svc.Hold(ctx, repo, pr, reason) + return err +} + +func (a prActor) Unhold(ctx context.Context, repo string, pr int) error { + _, err := a.svc.Unhold(ctx, repo, pr) + return err +} + +func (a prActor) Prioritize(ctx context.Context, repo string, pr int) error { + return a.svc.Prioritize(ctx, repo, pr) +} + +func (a prActor) Cancel(ctx context.Context, repo string, pr int) error { + return a.svc.Cancel(ctx, repo, pr) +} + +func (a prActor) SetAutofix(ctx context.Context, repo string, enabled bool, reason string) error { + _, err := a.svc.SetAutofixEnabled(ctx, repo, enabled, reason) + return err +} + +func (a prActor) ClearAutofix(ctx context.Context, repo string) error { + _, _, err := a.svc.ClearAutofixEnabled(ctx, repo) + return err +} + +func (a prActor) SetReviewers( + ctx context.Context, + repo string, + coBots, required []string, + primary *bool, + expectedRev *int64, + preview bool, +) ([]string, serve.FleetImpact, error) { + if preview { + impact, err := a.svc.PreviewReviewers(ctx, repo, coBots, required, primary) + return nil, fleetImpactOf(impact), err + } + view, impact, err := a.svc.SetReviewersAt(ctx, repo, coBots, required, primary, expectedRev) + if err != nil { + return nil, serve.FleetImpact{}, err + } + // A host that honours overrides but not the primary switch would still fire + // the metered reviewer here, which is the whole thing being turned off. + return unionHosts(view.Lagging, view.LaggingPrimaryOff), fleetImpactOf(impact), nil +} + +// unionHosts merges two lagging-host lists without repeating a host that lacks +// both capabilities. +func unionHosts(a, b []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(a)+len(b)) + for _, list := range [][]string{a, b} { + for _, h := range list { + if seen[h] { + continue + } + seen[h] = true + out = append(out, h) + } + } + return out +} + +func (a prActor) SetEnrollment(ctx context.Context, repo string, enabled bool, reason string, expectedRev *int64) ([]string, error) { + view, err := a.svc.SetEnrollmentAt(ctx, repo, enabled, reason, expectedRev) + if err != nil { + return nil, err + } + return view.Lagging, nil +} + +func (a prActor) ClearEnrollment(ctx context.Context, repo string, expectedRev *int64) error { + _, err := a.svc.ClearEnrollmentAt(ctx, repo, expectedRev) + return err +} + +func (a prActor) ClearReviewers(ctx context.Context, repo string, expectedRev *int64, preview bool) (serve.FleetImpact, error) { + var impact crq.FleetImpact + var err error + if preview { + impact, err = a.svc.PreviewClearReviewers(ctx, repo) + } else { + _, impact, err = a.svc.ClearReviewersAt(ctx, repo, expectedRev) + } + if err != nil { + return serve.FleetImpact{}, err + } + return fleetImpactOf(impact), nil +} + +func (a prActor) ResolveThreads(ctx context.Context, threadIDs []string) error { + _, err := a.svc.ResolveThreads(ctx, threadIDs) + return err +} + +func (a prActor) DeclineThreads(ctx context.Context, threadIDs []string, reason string, resolve bool) error { + _, err := a.svc.DeclineThreads(ctx, threadIDs, reason, resolve) + return err +} + +func (a prActor) DismissFindings(ctx context.Context, repo string, pr int, ids []string, reason string) error { + _, err := a.svc.Dismiss(ctx, repo, pr, ids, reason) + return err +} + +// repoDiscoverer lists the repositories in CRQ_SCOPE for the dashboard's +// repository picker. +type repoDiscoverer struct{ svc *crq.Service } + +func (d repoDiscoverer) Discover(ctx context.Context) (serve.Listing, error) { + repos, truncated, err := d.svc.ScopeRepos(ctx) + if err != nil { + return serve.Listing{}, err + } + out := make([]serve.Candidate, 0, len(repos)) + for _, r := range repos { + c := serve.Candidate{ + Repo: r.FullName, Private: r.Private, Archived: r.Archived, + Fork: r.Fork, Issues: r.OpenIssues, Language: r.Language, + } + if !r.PushedAt.IsZero() { + at := r.PushedAt + c.PushedAt = &at + } + out = append(out, c) + } + return serve.Listing{Repos: out, Truncated: truncated}, nil +} + +// parseStamp turns the service's RFC3339 timestamps back into a time for the +// JSON the dashboard renders. An unparseable one is reported as absent rather +// than as the zero time, which would render as the year 1. +func parseStamp(s string) *time.Time { + if s == "" { + return nil + } + at, err := time.Parse(time.RFC3339, s) + if err != nil { + return nil + } + return &at +} + +// prCoster prices one more round for the dashboard's PR page. +type prCoster struct{ svc *crq.Service } + +func (c prCoster) Cost(ctx context.Context, repo string, pr int) (serve.Cost, error) { + est, err := c.svc.Cost(ctx, repo, pr) + if err != nil { + return serve.Cost{}, err + } + out := serve.Cost{ + Head: est.Head, Low: est.Low, High: est.High, Exact: est.Exact, Unpriced: est.Unpriced, + Summary: est.Summary, PricesCheckedAt: est.PricesCheckedAt, PricingNote: est.PricingNote, + Diff: serve.CostDiff{ + Additions: est.Diff.Additions, Deletions: est.Diff.Deletions, + ChangedFiles: est.Diff.ChangedFiles, + }, + } + for _, r := range est.Reviewers { + out.Reviewers = append(out.Reviewers, serve.CostReviewer{ + Bot: r.Bot, Low: r.Low, High: r.High, Exact: r.Exact, Unknown: r.Unknown, Basis: r.Basis, + }) + } + return out, nil +} + +// runFleet is the fleet-defaults CLI: read, set with an optional dry run, or +// clear. It mirrors `crq reviewers`, with the flags a fleet-wide default has +// that a per-repo one does not. +func runFleet(ctx context.Context, service *crq.Service, args []string) int { + action, rest := "show", args + if len(args) > 0 && (args[0] == "set" || args[0] == "clear" || args[0] == "adopt" || args[0] == "env") { + action, rest = args[0], args[1:] + } + var change crq.FleetChange + dryRun := false + hasMutation := false + if action == "env" { + // `crq fleet env` reads or writes ONE setting by its environment name, + // which is how the settings that have no flag of their own are reached. + switch { + case len(rest) == 0: + fatal(errors.New(`usage: crq fleet env [|--clear]`)) + return 1 + case len(rest) == 1: + key := rest[0] + st, err := service.LoadState(ctx) + if err != nil { + fatal(err) + return 1 + } + for _, set := range service.EnvSettings(st) { + if set.Key == key { + printJSON(set) + return 0 + } + } + fatal(fmt.Errorf("%s is not a setting crq knows", key)) + return 1 + } + unset := rest[1] == "--clear" + if unset && len(rest) != 2 { + fatal(errors.New(`usage: crq fleet env [|--clear]`)) + return 1 + } + value := "" + if !unset { + value = strings.Join(rest[1:], " ") + } + view, err := service.SetEnv(ctx, rest[0], value, unset) + if err != nil { + fatal(err) + return 1 + } + printJSON(view) + return 0 + } + for i := 0; i < len(rest); i++ { + arg := rest[i] + value := func() (string, bool) { + if strings.Contains(arg, "=") { + return strings.SplitN(arg, "=", 2)[1], true + } + if i+1 >= len(rest) { + fatal(fmt.Errorf("%s needs a value", arg)) + return "", false + } + i++ + return rest[i], true + } + switch name := strings.SplitN(arg, "=", 2)[0]; name { + case "--dry-run": + dryRun = true + case "--bots": + hasMutation = true + v, ok := value() + if !ok { + return 1 + } + change.CoBots = splitList(&v) + case "--required": + hasMutation = true + v, ok := value() + if !ok { + return 1 + } + change.Required = splitList(&v) + case "--min-interval": + hasMutation = true + v, ok := value() + if !ok { + return 1 + } + change.MinInterval = &v + case "--weekly-limit": + hasMutation = true + v, ok := value() + if !ok { + return 1 + } + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + fatal(fmt.Errorf("--weekly-limit: %w", err)) + return 1 + } + change.WeeklyLimit = &n + case "--autofix-default": + hasMutation = true + v, ok := value() + if !ok { + return 1 + } + on := strings.EqualFold(strings.TrimSpace(v), "on") + if !on && !strings.EqualFold(strings.TrimSpace(v), "off") { + fatal(errors.New("--autofix-default takes on or off")) + return 1 + } + change.AutofixDefault = &on + default: + fatal(fmt.Errorf("unknown flag %s (see crq help fleet)", arg)) + return 1 + } + } + if action != "set" && hasMutation { + fatal(fmt.Errorf("fleet mutation flags are valid only with `crq fleet set`")) + return 1 + } + + switch action { + case "adopt": + adopted, err := service.AdoptEnv(ctx, dryRun) + if err != nil { + fatal(err) + return 1 + } + printJSON(adopted) + return 0 + case "clear": + change = crq.FleetChange{Clear: true} + case "show": + view, err := service.FleetSettings(ctx) + if err != nil { + fatal(err) + return 1 + } + printJSON(view) + return 0 + } + if dryRun { + impact, err := service.PreviewFleet(ctx, change) + if err != nil { + fatal(err) + return 1 + } + printJSON(impact) + return 0 + } + view, impact, err := service.SetFleetSettings(ctx, change) + if err != nil { + fatal(err) + return 1 + } + printJSON(map[string]any{"fleet": view, "impact": impact}) + return 0 +} + +func (a prActor) Fleet(ctx context.Context) (*serve.FleetSettings, error) { + view, err := a.svc.FleetSettings(ctx) + if err != nil { + return nil, err + } + return fleetSettingsOf(view), nil +} + +func (a prActor) SetFleet(ctx context.Context, change serve.FleetChange, preview bool) (serve.FleetImpact, error) { + c := crq.FleetChange{ + CoBots: change.CoBots, Required: change.Required, + MinInterval: change.MinInterval, WeeklyLimit: change.WeeklyLimit, + AutofixDefault: change.AutofixDefault, ExpectedRev: change.ExpectedRev, Clear: change.Clear, + } + var impact crq.FleetImpact + var err error + if preview { + impact, err = a.svc.PreviewFleet(ctx, c) + } else { + _, impact, err = a.svc.SetFleetSettings(ctx, c) + } + if err != nil { + return serve.FleetImpact{}, err + } + return fleetImpactOf(impact), nil +} + +func fleetImpactOf(impact crq.FleetImpact) serve.FleetImpact { + return serve.FleetImpact{ + Rev: impact.Rev, Repos: impact.Repos, Reopened: impact.Reopened, Overridden: impact.Overridden, + Changes: impact.Changes, Summary: impact.Summary, + } +} + +func fleetSettingsOf(view crq.FleetView) *serve.FleetSettings { + out := &serve.FleetSettings{ + Recorded: view.Recorded, MinInterval: view.MinInterval, WeeklyLimit: view.WeeklyLimit, + AutofixDefault: view.AutofixDefault, Sources: view.Sources, Overriding: view.Overriding, + By: view.By, UpdatedAt: view.UpdatedAt, Lagging: hostsOfWriters(view.Lagging), + } + for _, r := range view.Reviewers { + out.Reviewers = append(out.Reviewers, serve.FleetReviewer{ + Login: r.Login, Budget: r.Budget, Required: r.Required, Trigger: r.Trigger, + }) + } + return out +} + +// hostsOfWriters reduces writer keys ("host=X pid=… run=…") to machine names. +func hostsOfWriters(writers []string) []string { + out := make([]string, 0, len(writers)) + seen := map[string]bool{} + for _, w := range writers { + host := state.WriterHost(w) + if seen[host] { + continue + } + seen[host] = true + out = append(out, host) + } + return out +} + +// runSolver is the fix-session settings CLI, per repository or fleet-wide. +func runSolver(ctx context.Context, service *crq.Service, args []string) int { + action, rest := "show", args + if len(args) > 0 && (args[0] == "set" || args[0] == "clear") { + action, rest = args[0], args[1:] + } + var repo string + var change crq.SolverChange + fleet := false + hasMutation := false + for i := 0; i < len(rest); i++ { + arg := rest[i] + name, inline, hasInline := strings.Cut(arg, "=") + value := func() (string, bool) { + if hasInline { + return inline, true + } + if i+1 >= len(rest) { + fatal(fmt.Errorf("%s needs a value", name)) + return "", false + } + i++ + return rest[i], true + } + switch name { + case "--fleet": + fleet = true + case "--models", "--model", "--effort", "--prompt", "--severities", "--ask": + hasMutation = true + v, ok := value() + if !ok { + return 1 + } + switch name { + case "--models": + change.Models = splitList(&v) + case "--model": + change.Model = &v + case "--effort": + change.Effort = &v + case "--prompt": + change.Prompt = &v + case "--severities": + change.Severities = splitList(&v) + case "--ask": + change.AskMode = &v + } + case "--attempts": + hasMutation = true + v, ok := value() + if !ok { + return 1 + } + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + fatal(fmt.Errorf("--attempts: %w", err)) + return 1 + } + change.MaxAttempts = &n + case "--forks": + hasMutation = true + v, ok := value() + if !ok { + return 1 + } + on := strings.EqualFold(strings.TrimSpace(v), "on") + if !on && !strings.EqualFold(strings.TrimSpace(v), "off") { + fatal(errors.New("--forks takes on or off")) + return 1 + } + change.Forks = &on + case "--skip-authors": + hasMutation = true + v, ok := value() + if !ok { + return 1 + } + change.SkipAuthors = splitList(&v) + case "--inherit": + hasMutation = true + // Its own instruction, not a value some other flag could carry: + // --forks off is a fork policy and --skip-authors "" is "skip + // nobody", so neither can also mean "follow the layer beneath". + v, ok := value() + if !ok { + return 1 + } + for _, field := range splitList(&v) { + switch strings.ToLower(field) { + case "models", "model": + change.UnsetModels = true + case "effort": + change.UnsetEffort = true + case "prompt": + change.UnsetPrompt = true + case "severities": + change.UnsetSeverities = true + case "ask", "ask-mode", "ask_mode": + change.UnsetAskMode = true + case "forks": + change.UnsetForks = true + case "skip-authors", "skip_authors": + change.UnsetSkipAuthors = true + default: + fatal(fmt.Errorf("--inherit: %q is not a solver setting that can be unset (models, effort, prompt, severities, ask, forks, skip-authors)", field)) + return 1 + } + } + default: + if strings.HasPrefix(arg, "-") { + fatal(fmt.Errorf("unknown flag %s (see crq help solver)", arg)) + return 1 + } + if repo != "" { + fatal(fmt.Errorf("unexpected argument %q", arg)) + return 1 + } + repo = arg + } + } + if err := validateSolverTarget(repo, fleet); err != nil { + fatal(err) + return 1 + } + if action != "set" && hasMutation { + fatal(errors.New("solver mutation flags are valid only with `crq solver set`")) + return 1 + } + if action == "clear" { + change = crq.SolverChange{Clear: true} + } + + if fleet { + if action == "show" { + view, err := service.FleetSolver(ctx) + if err != nil { + fatal(err) + return 1 + } + printJSON(view) + return 0 + } + sv, err := service.SetFleetSolver(ctx, change) + if err != nil { + fatal(err) + return 1 + } + printJSON(sv) + return 0 + } + if action == "show" { + view, err := service.Solver(ctx, repo) + if err != nil { + fatal(err) + return 1 + } + printJSON(view) + return 0 + } + view, err := service.SetSolver(ctx, repo, change) + if err != nil { + fatal(err) + return 1 + } + printJSON(view) + return 0 +} + +func validateSolverTarget(repo string, fleet bool) error { + if fleet && repo != "" { + return errors.New("a repository and --fleet are mutually exclusive solver targets") + } + if !fleet && repo == "" { + return errors.New("usage: crq solver [set|clear] [flags], or --fleet for the default") + } + return nil +} + +func (a prActor) SetSolver(ctx context.Context, repo string, change serve.SolverChange) error { + c := crq.SolverChange{ + Models: change.Models, Model: change.Model, Effort: change.Effort, Prompt: change.Prompt, + MaxAttempts: change.MaxAttempts, Forks: change.Forks, + Severities: change.Severities, AskMode: change.AskMode, + SkipAuthors: change.SkipAuthors, Clear: change.Clear, + UnsetModels: change.UnsetModels, UnsetEffort: change.UnsetEffort, + UnsetPrompt: change.UnsetPrompt, UnsetSeverities: change.UnsetSeverities, UnsetAskMode: change.UnsetAskMode, + UnsetForks: change.UnsetForks, + UnsetSkipAuthors: change.UnsetSkipAuthors, + } + // An empty repo means the fleet default, the same convention the CLI's + // --fleet flag expresses. + if strings.TrimSpace(repo) == "" { + _, err := a.svc.SetFleetSolver(ctx, c) + return err + } + _, err := a.svc.SetSolver(ctx, repo, c) + return err +} + +func (a prActor) EnvSettings(st crq.State) []serve.EnvSetting { + out := []serve.EnvSetting{} + for _, s := range a.svc.EnvSettings(st) { + out = append(out, serve.EnvSetting{ + Key: s.Key, Kind: s.Kind, Group: s.Group, Label: s.Label, Help: s.Help, + PerHost: s.PerHost, Identity: s.Identity, ReviewImpact: s.ReviewImpact, + Value: s.Value, Source: s.Source, HostValue: s.HostValue, + }) + } + return out +} + +func (a prActor) SetEnv(ctx context.Context, key, value string, unset bool, expectedRev *int64, preview bool) (serve.FleetImpact, error) { + var impact crq.FleetImpact + var err error + if preview { + impact, err = a.svc.PreviewEnv(ctx, key, value, unset) + } else { + _, impact, err = a.svc.SetEnvAt(ctx, key, value, unset, expectedRev) + } + if err != nil { + return serve.FleetImpact{}, err + } + return fleetImpactOf(impact), nil +} + +// enrollPreviewer prices an enrollment for the dashboard's add-repo dialog. +type enrollPreviewer struct{ svc *crq.Service } + +func (p enrollPreviewer) PreviewEnroll(ctx context.Context, repo string) (serve.EnrollImpact, error) { + i, err := p.svc.PreviewEnroll(ctx, repo) + if err != nil { + return serve.EnrollImpact{}, err + } + return serve.EnrollImpact{ + Rev: i.Rev, Repo: i.Repo, Open: i.Open, Eligible: i.Eligible, Skipped: i.Skipped, + Metered: i.Metered, Low: i.Low, High: i.High, Unpriced: i.Unpriced, Unexamined: i.Unexamined, + Summary: i.Summary, PricesCheckedAt: i.PricesCheckedAt, + }, nil +} diff --git a/cmd/crq/main_test.go b/cmd/crq/main_test.go index 351cec7c..c6295d73 100644 --- a/cmd/crq/main_test.go +++ b/cmd/crq/main_test.go @@ -3,6 +3,9 @@ package main import ( "strings" "testing" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/state" ) func TestWatchDispatchOptionHonorsFalse(t *testing.T) { @@ -150,6 +153,72 @@ func TestReasonFlagDetection(t *testing.T) { } } +func TestSolverTargetIsUnambiguous(t *testing.T) { + if err := validateSolverTarget("owner/repo", true); err == nil { + t.Fatal("a repository together with --fleet must be rejected") + } + for _, target := range []struct { + repo string + fleet bool + }{ + {repo: "owner/repo"}, + {fleet: true}, + } { + if err := validateSolverTarget(target.repo, target.fleet); err != nil { + t.Errorf("valid target %+v was rejected: %v", target, err) + } + } +} + +func TestMutationFlagsRequireSetAction(t *testing.T) { + ctx := t.Context() + for _, tc := range []struct { + name string + run func() int + }{ + {name: "fleet show", run: func() int { + return runFleet(ctx, nil, []string{"--bots", "codex"}) + }}, + {name: "fleet clear", run: func() int { + return runFleet(ctx, nil, []string{"clear", "--weekly-limit", "60"}) + }}, + {name: "solver show", run: func() int { + return runSolver(ctx, nil, []string{"owner/repo", "--forks", "on"}) + }}, + {name: "solver clear", run: func() int { + return runSolver(ctx, nil, []string{"clear", "owner/repo", "--attempts", "2"}) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + if code := tc.run(); code == 0 { + t.Fatal("mutation flag outside set action succeeded") + } + }) + } +} + +func TestSolverAgentHostMarksHistoricalProbesStale(t *testing.T) { + const stamp = "2026-07-28T10:00:00Z" + now, err := time.Parse(time.RFC3339, stamp) + if err != nil { + t.Fatal(err) + } + report := state.HostReport{ + Host: "fixer", + Roles: []string{"serve"}, + Tools: []state.ToolReport{{Name: "codex", Path: "/usr/bin/codex"}}, + At: now, + } + + got := solverAgentHost(report, "codex", now) + if !got.Stale { + t.Fatal("a host that no longer reports the autofix role must be stale") + } + if got.Has == nil || !*got.Has || got.Path != "/usr/bin/codex" { + t.Fatalf("historical probe = %+v, want the last known installed path", got) + } +} + // parseDismissArgs decides what counts as a finding ID, so a typo must not // quietly become one. func TestParseDismissArgs(t *testing.T) { diff --git a/internal/crq/auto.go b/internal/crq/auto.go index 9f59b64a..48aa9816 100644 --- a/internal/crq/auto.go +++ b/internal/crq/auto.go @@ -3,7 +3,6 @@ package crq import ( "context" "errors" - "sort" "strings" "time" @@ -25,17 +24,33 @@ func (s *Service) AutoReview(ctx context.Context, opts AutoOptions) error { // from. Spelling it out here again would let the two drift apart. owner := s.cfg.WriterID() token := randomToken() + // Report what this machine can reach before doing anything else, and again + // as it goes: a fleet's tool inventory is only useful if it is a fleet's, + // and a daemon is the only thing that knows the PATH its service runs with. + s.ReportHost(ctx, "autoreview") + // Pacing is a fleet setting, so it is resolved from the state this loop + // already reads rather than frozen at startup: a poll interval the dashboard + // records and then reports as the effective fleet value has to actually pace + // the daemon reading it. This host's env is the answer until the first read + // lands, and whenever one fails. + poll := s.cfg.AutoReviewPoll for { - held, err := s.acquireLeader(ctx, owner, token) + state, held, err := s.acquireLeader(ctx, owner, token) if err != nil { if _, ok := ghapi.ThrottleWait(err); ok { - if cont, serr := s.sleepThrottle(ctx, opts, "leader", err); serr != nil || !cont { + if cont, serr := s.sleepThrottle(ctx, opts, poll, "leader", err); serr != nil || !cont { return serr } continue } return err } + // Positive only. A record of "0s" would otherwise turn every daemon in + // the fleet into a spin loop from one save, which is a far worse thing + // for one setting to be able to do than to be ignored. + if resolved := s.cfg.WithFleet(state.Fleet).AutoReviewPoll; resolved > 0 { + poll = resolved + } if !held { if opts.Once { return nil @@ -43,7 +58,7 @@ func (s *Service) AutoReview(ctx context.Context, opts AutoOptions) error { select { case <-ctx.Done(): return ctx.Err() - case <-time.After(s.cfg.AutoReviewPoll): + case <-time.After(poll): continue } } @@ -58,7 +73,7 @@ func (s *Service) AutoReview(ctx context.Context, opts AutoOptions) error { // sleep out the window instead of immediately pumping and re-scanning, // which would just hammer the quota. Skip Pump entirely in that case. if _, ok := ghapi.ThrottleWait(passErr); ok { - if cont, serr := s.sleepThrottle(ctx, opts, "pass", passErr); serr != nil || !cont { + if cont, serr := s.sleepThrottle(ctx, opts, poll, "pass", passErr); serr != nil || !cont { if opts.Once { return s.finishAutoReviewOnce(ctx, token, serr) } @@ -75,11 +90,11 @@ func (s *Service) AutoReview(ctx context.Context, opts AutoOptions) error { // stop being needed, so tidying here costs one observation on a PR // crq was already looking at rather than a sweep of the fleet. if err == nil { - err = s.tidyAfterPump(ctx, pumped) + err = s.tidyAfterPump(ctx, state, pumped) } if err != nil { if _, ok := ghapi.ThrottleWait(err); ok { - if cont, serr := s.sleepThrottle(ctx, opts, "pump", err); serr != nil || !cont { + if cont, serr := s.sleepThrottle(ctx, opts, poll, "pump", err); serr != nil || !cont { if opts.Once { return s.finishAutoReviewOnce(ctx, token, serr) } @@ -104,7 +119,7 @@ func (s *Service) AutoReview(ctx context.Context, opts AutoOptions) error { select { case <-ctx.Done(): return ctx.Err() - case <-time.After(s.cfg.AutoReviewPoll): + case <-time.After(poll): } } } @@ -121,13 +136,18 @@ func (s *Service) finishAutoReviewOnce(ctx context.Context, token string, err er // throttleBackoff bounds how long the autoreview daemon sleeps when GitHub // throttles it: at least one poll interval, plus a small buffer past the // reset, capped at an hour so a bogus reset header can't wedge the daemon. -func (s *Service) throttleBackoff(wait time.Duration) time.Duration { +// poll is the caller's resolved interval, so a fleet-recorded one paces the +// backoff too. +func (s *Service) throttleBackoff(wait, poll time.Duration) time.Duration { + if poll <= 0 { + poll = s.cfg.AutoReviewPoll + } if wait <= 0 { - wait = s.cfg.AutoReviewPoll + wait = poll } wait += 5 * time.Second - if wait < s.cfg.AutoReviewPoll { - wait = s.cfg.AutoReviewPoll + if wait < poll { + wait = poll } if wait > time.Hour { wait = time.Hour @@ -141,9 +161,9 @@ func (s *Service) throttleBackoff(wait time.Duration) time.Duration { // be a throttle error (the caller checks ghapi.ThrottleWait first). It returns // cont=false (nil error) when opts.Once means we should stop after the wait, and a // non-nil error only when the context is cancelled mid-wait. -func (s *Service) sleepThrottle(ctx context.Context, opts AutoOptions, stage string, cause error) (cont bool, err error) { +func (s *Service) sleepThrottle(ctx context.Context, opts AutoOptions, poll time.Duration, stage string, cause error) (cont bool, err error) { wait, _ := ghapi.ThrottleWait(cause) - wait = s.throttleBackoff(wait) + wait = s.throttleBackoff(wait, poll) if s.log != nil { s.log.Printf("autoreview: %s throttled (%v); sleeping %s before next pass", stage, cause, wait.Round(time.Second)) } @@ -158,15 +178,17 @@ func (s *Service) sleepThrottle(ctx context.Context, opts AutoOptions, stage str return true, nil } -func (s *Service) acquireLeader(ctx context.Context, owner, token string) (bool, error) { +// acquireLeader claims or extends the lease and hands back the state it read, +// so the caller can resolve the fleet's pacing from a read it already paid for. +func (s *Service) acquireLeader(ctx context.Context, owner, token string) (State, bool, error) { state, held, err := s.renewLeader(ctx, owner, token) if err != nil { - return false, err + return State{}, false, err } if held { s.sync(ctx, state) } - return held, nil + return state, held, nil } func (s *Service) releaseLeader(ctx context.Context, token string) error { @@ -189,12 +211,35 @@ func (s *Service) releaseLeader(ctx context.Context, token string) error { return nil } +// leaderTTL is how long a lease survives without a heartbeat, resolved the way +// every other fleet setting is: this host's env, then whatever the fleet +// recorded. A lease length the dashboard displays as the fleet's answer has to +// be the one the daemon actually renews against — otherwise saving it changes a +// number on a page and nothing else. Non-positive is ignored, since a recorded +// 0 would expire every lease the instant it was written. +func (s *Service) leaderTTL(st State) time.Duration { + if resolved := s.cfg.WithFleet(st.Fleet).LeaderTTL; resolved > 0 { + return resolved + } + return s.cfg.LeaderTTL +} + +// feedbackWait is how long a fired round is waited on, resolved for the same +// reason and in the same way. The deadline is STAMPED into the round, so the +// startup value did not merely mispace one poll: it outlived the setting on +// every round fired after the fleet shortened or lengthened the wait. +func (s *Service) feedbackWait(st State) time.Duration { + if resolved := s.cfg.WithFleet(st.Fleet).FeedbackWaitTimeout; resolved > 0 { + return resolved + } + return s.cfg.FeedbackWaitTimeout +} + // renewLeader claims or extends the leader lease via compare-and-swap on the // state ref. It does not sync the dashboard, so it's cheap enough to call as an // in-pass heartbeat. held is false when another live lease holder owns it. func (s *Service) renewLeader(ctx context.Context, owner, token string) (State, bool, error) { now := time.Now().UTC() - expires := now.Add(s.cfg.LeaderTTL) held := false state, err := s.store.Update(ctx, func(st *State) error { if st.Leader != nil && st.Leader.ExpiresAt.After(now) && st.Leader.Token != token { @@ -204,7 +249,7 @@ func (s *Service) renewLeader(ctx context.Context, owner, token string) (State, st.Leader = &LeaderLease{ Owner: owner, Token: token, - ExpiresAt: expires, + ExpiresAt: now.Add(s.leaderTTL(*st)), UpdatedAt: now, Capabilities: []string{leaderCapabilityHolds}, } @@ -222,36 +267,61 @@ func (s *Service) renewLeader(ctx context.Context, owner, token string) (State, } func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, token string) error { + // Refreshed each pass. ReportHost writes nothing when nothing changed and + // the record is still fresh, so this is a probe, not a write. + s.ReportHost(ctx, "autoreview") // Load the queue snapshot once per pass and reuse it across candidates: a // git-backed Load is GetRef+GetCommit+GetTree+GetBlob, so reloading it per PR // would burn the shared REST quota on a large scan. The heartbeat refreshes it, // and enqueueBatch re-checks Contains under CAS, so a slightly stale snapshot // during collection is safe. // - // It is loaded BEFORE the targets are chosen, because the state ref is where - // the fleet records which repositories those are. Reading this host's own - // list first is what let one machine scan a repository another had excluded. + // It is also what says which repositories are enrolled, which is why it is + // read BEFORE the targets are chosen: a repository enrolled from the + // dashboard has to be searched on the very next pass, not after a restart. state, _, err := s.store.Load(ctx) if err != nil { return err } - cfg := s.fleetCfg(state) - // Copied, never sorted in place. Without a fleet `scope` the configuration is - // this host's own, so the slice shares its backing array with s.cfg.Scope and - // the sort below would silently reorder the operator's configured scan order - // — and race any concurrent reader of it. The allow-repository branch already - // builds its own. - targets := append([]string(nil), cfg.Scope...) - byRepo := false - if len(cfg.AllowRepos) > 0 { - targets = make([]string, 0, len(cfg.AllowRepos)) - for repo := range cfg.AllowRepos { - targets = append(targets, repo) - } - byRepo = true + fleetCfg := s.cfg.WithFleet(state.Fleet) + // The scan bound is a fleet setting like the pacing above, resolved from the + // snapshot this pass just read rather than from the env the process started + // with. Positive only, for the same reason: a recorded 0 would stop the + // whole fleet scanning anything, silently. + maxScan := s.cfg.AutoReviewMaxScan + if resolved := fleetCfg.AutoReviewMaxScan; resolved > 0 { + maxScan = resolved + } + // Owner-wide searches only when there is no allow-list at all. An allow-list + // with nothing left active in it scans NOTHING: falling back to CRQ_SCOPE + // there made every pass walk the organisation's whole open-PR result set for + // reviewsRepo to reject each row before the scan counter even advanced. + // + // A scope-wide host still searches its individually enrolled repositories by + // name — scanTargets returns the ones no owner in CRQ_SCOPE covers — so the + // two search shapes are mixed in one pass and each target says which it is. + targets, scoped := s.scanTargets(state) + byRepo := func(target string) bool { return !scoped || strings.Contains(target, "/") } + if scoped { + targets = append(targets, fleetCfg.Scope...) } - sort.Strings(targets) // stable: a pass must not depend on map iteration var candidates []queueCandidate + var titles []queueCandidate + // The skip rules below are per-repository settings like everything else, and + // the enrollment preview already answers with the resolved ones. Reading + // s.cfg here instead made this pass enqueue — and spend the shared allowance + // on — pull requests the dialog had just promised would be skipped. Memoized + // per repository: cfgFor re-parses the merged env, and a scan sees the same + // repository many times over. + skipCfg := map[string]Config{} + repoSkips := func(repo string) Config { + cfg, ok := skipCfg[repo] + if !ok { + cfg = s.cfgFor(state, repo) + skipCfg[repo] = cfg + } + return cfg + } lastBeat := time.Now() for _, target := range targets { // Per-target scan budget so one large scope can't consume the whole budget @@ -260,27 +330,30 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t // Stream results and stop once the post-filter scan budget is spent, so // excluded/gate-repo results can't crowd out in-scope PRs (a fixed pre-filter // limit would never reach them) while we still don't over-fetch pages. - err := s.gh.EachOpenPR(ctx, target, byRepo, func(pr ghapi.SearchPR) (bool, error) { - if scanned >= s.cfg.AutoReviewMaxScan { + err := s.gh.EachOpenPR(ctx, target, byRepo(target), func(pr ghapi.SearchPR) (bool, error) { + if scanned >= maxScan { return true, nil } repo := NormalizeRepo(pr.Repo) - if repo == NormalizeRepo(s.cfg.GateRepo) || cfg.ExcludeRepos[repo] { - return false, nil - } - if len(cfg.AllowRepos) > 0 && !cfg.AllowRepos[repo] { + // One question, one answer: env allow/exclude, the gate repository + // and any enrollment record all resolve in enrollmentOf, so a scope + // search cannot enqueue what a by-repo search would have skipped. + if !s.reviewsRepo(state, repo) { return false, nil } - if cfg.SkipAuthors[dialect.NormalizeBotName(strings.ToLower(pr.Author))] { + skips := repoSkips(repo) + if skips.SkipAuthors[dialect.NormalizeBotName(strings.ToLower(pr.Author))] { return false, nil } - if cfg.SkipsReview(pr.Body) { + if skips.SkipsReview(pr.Body) { return false, nil } scanned++ // Heartbeat: renew the lease partway through a long pass so a standby // can't steal it mid-scan and cause brief double-leadership (#4). - if s.cfg.LeaderTTL > 0 && time.Since(lastBeat) >= s.cfg.LeaderTTL/2 { + // Half of the SAME lease length renewLeader writes, so a fleet that + // shortens the lease starts beating faster to match. + if ttl := s.leaderTTL(state); ttl > 0 && time.Since(lastBeat) >= ttl/2 { st, held, herr := s.renewLeader(ctx, owner, token) if herr != nil { return false, herr @@ -288,9 +361,17 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t if !held { return false, errLostLeadership } - state = st // reuse the freshly written snapshot for later candidates + state = st // reuse the freshly written snapshot for later candidates + clear(skipCfg) // the memo above answers for the snapshot it was built from lastBeat = time.Now() } + // A round that already exists at this head is not a candidate, so it + // would never reach enqueueBatch and would never learn its title. + // The scan already has one; recording it here is what stops every + // existing round reading as a bare number for ever. + if pr.Title != "" { + titles = append(titles, queueCandidate{Repo: repo, PR: pr.Number, Title: pr.Title}) + } need, head, nerr := s.needsReview(ctx, state, repo, pr.Number, opts.Incremental) if nerr != nil { // A throttle must abort the pass so AutoReview's outer backoff kicks @@ -305,7 +386,7 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t return false, nil } if need { - candidates = append(candidates, queueCandidate{Repo: repo, PR: pr.Number, Head: head}) + candidates = append(candidates, queueCandidate{Repo: repo, PR: pr.Number, Head: head, Title: pr.Title}) } return false, nil }) @@ -313,8 +394,11 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t return err } } + // Titles first, in one write: they change nothing about scheduling, so a + // failure here must not stop the enqueue that does. + s.noteTitles(ctx, titles) // One batched write for the whole pass instead of N (#2). - return s.enqueueBatch(ctx, candidates, cfg.FleetRevision) + return s.enqueueBatch(ctx, candidates) } // needsReview reports whether an open PR should be enqueued for review, and its @@ -324,7 +408,18 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t // bot's last review commit differs from the head, or (first-review) the bot has // never reviewed and no review-done marker is present. It reads the caller's // preloaded state snapshot so a pass doesn't reload git-backed state per candidate. +// +// It announces each "yes" to the log, which is what makes an autoreview pass +// explain the work it queued. func (s *Service) needsReview(ctx context.Context, state State, repo string, pr int, incremental bool) (bool, string, error) { + return s.reviewNeeded(ctx, state, repo, pr, incremental, s.logEnqueue) +} + +// reviewNeeded is the predicate itself, with the announcement injected. The +// enrollment preview asks exactly this question about pull requests it is not +// enqueueing, and an "enqueue …" line from a dialog that wrote nothing is how an +// estimate reads as an action already taken. +func (s *Service) reviewNeeded(ctx context.Context, state State, repo string, pr int, incremental bool, announce func(repo string, pr int, head, reason string)) (bool, string, error) { head, err := s.headShort(ctx, repo, pr) if err != nil { return false, "", err @@ -338,7 +433,7 @@ func (s *Service) needsReview(ctx context.Context, state State, repo string, pr if !r.ReviewersChanged { return false, head, nil } - s.logEnqueue(repo, pr, head, "reviewer configuration changed while the pr was closed") + announce(repo, pr, head, "reviewer configuration changed while the pr was closed") return true, head, nil } reviews, err := s.gh.ListReviews(ctx, repo, pr) @@ -374,7 +469,7 @@ func (s *Service) needsReview(ctx context.Context, state State, repo string, pr } } if need { - s.logEnqueue(repo, pr, head, "no "+dialect.NormalizeBotName(missing)+" review at head") + announce(repo, pr, head, "no "+dialect.NormalizeBotName(missing)+" review at head") } return need, head, nil } @@ -397,10 +492,13 @@ func (s *Service) needsReview(ctx context.Context, state State, repo string, pr if strings.Contains(pull.Body, s.cfg.ReviewDoneMarker) { return false, head, nil } - s.logEnqueue(repo, pr, head, "never reviewed") + announce(repo, pr, head, "never reviewed") return true, head, nil } +// noAnnounce is reviewNeeded's announcement for a caller that is only asking. +func noAnnounce(string, int, string, string) {} + // logEnqueue records one line per autoreview enqueue decision so a runaway is // visible in the daemon log (repo#pr, head, and why it was queued). func (s *Service) logEnqueue(repo string, pr int, head, reason string) { diff --git a/internal/crq/autofix.go b/internal/crq/autofix.go index 493b55b7..664c1410 100644 --- a/internal/crq/autofix.go +++ b/internal/crq/autofix.go @@ -22,34 +22,40 @@ import ( //go:embed dispatch/fix-prompt.txt var fixPrompt string -// HostOnlyAutofixWarning labels a preview built from this host's own -// configuration because fleet state could not be read. Shared so every path -// that can only offer that answer says the same thing about it. -const HostOnlyAutofixWarning = "GitHub fleet state is unavailable; this host-only preview may differ from an authenticated install" - // AutofixInstall describes what an install would do, so --dry-run can print it and // the result can be reported. type AutofixInstall struct { - Platform string `json:"platform"` - Prompt string `json:"prompt"` - Wrapper string `json:"wrapper"` - Unit string `json:"unit"` - LogDir string `json:"log_dir"` - Workspace string `json:"workspace,omitempty"` - Agent string `json:"agent"` - PolicySource string `json:"policy_source"` - Warning string `json:"warning,omitempty"` - // Invocation is the exact command the wrapper runs, so --dry-run shows what - // the fix session will actually be — including the model, which is the - // agent's business and not crq's to hardcode. - Invocation string `json:"invocation,omitempty"` - Repos []string `json:"repos"` - Commands []string `json:"commands"` + Platform string `json:"platform"` + Prompt string `json:"prompt"` + // Binary is the crq the unit runs. There is no wrapper script and no + // session script: the unit runs `crq watch -- crq fix-session`, so one + // binary starts the watcher and one binary runs each session. Two generated + // bash files used to sit in between, which meant three things had to agree + // about the configuration and two of them were text on disk no test ever + // ran — a setting reached a fleet only after every host reinstalled, and + // nothing said which had not. + Binary string `json:"binary"` + Unit string `json:"unit"` + LogDir string `json:"log_dir"` + Workspace string `json:"workspace,omitempty"` + Agent string `json:"agent"` + // Invocation is the exact command the unit runs, so --dry-run shows it. + Invocation string `json:"invocation,omitempty"` + // AgentArgs are the operator's extra arguments, passed to the session + // through the unit's environment. + AgentArgs []string `json:"agent_args,omitempty"` + Repos []string `json:"repos"` + Commands []string `json:"commands"` // Retire is the pre-rename watcher this install shuts down, when one is // still on disk. Empty when there is nothing to retire. - Retire string `json:"retire,omitempty"` - DryRun bool `json:"dry_run,omitempty"` - Started bool `json:"started,omitempty"` + Retire string `json:"retire,omitempty"` + DryRun bool `json:"dry_run,omitempty"` + // SkipAuthCheck installs without proving the service can authenticate. For + // the one case the check cannot decide: a macOS host reached over SSH, + // where gh's keychain is readable by the GUI-domain agent and not by this + // session. + SkipAuthCheck bool `json:"skip_auth_check,omitempty"` + Started bool `json:"started,omitempty"` } // InstallAutofix sets up unattended autofix: the prompt, a wrapper, a @@ -60,49 +66,13 @@ type AutofixInstall struct { // files, remember `loginctl enable-linger`, and get the environment right — and // a setup people get wrong is a setup that silently does nothing, which is the // failure this whole feature is about. -func (s *Service) InstallAutofix(ctx context.Context, agent string, agentArgs []string, repos []string, dryRun bool) (AutofixInstall, error) { +func (s *Service) InstallAutofix(ctx context.Context, agent string, agentArgs []string, repos []string, dryRun, skipAuth bool) (AutofixInstall, error) { effectiveDryRun := dryRun || s.cfg.DryRun - st, _, err := s.store.Load(ctx) - if err != nil { - // A dry run is documented as a preview, so a state ref this host cannot - // read downgrades it to the host's own plan rather than failing — the - // warning is what keeps it from being mistaken for the fleet's. A real - // install has no such fallback: writing a unit from policy the fleet may - // disagree with is the divergence this whole mechanism exists to end. - if !effectiveDryRun { - return AutofixInstall{}, err - } - plan, perr := AutofixPlan(s.cfg, agent, agentArgs, repos, true) - if perr != nil { - return AutofixInstall{}, perr - } - plan.PolicySource = "host" - plan.Warning = HostOnlyAutofixWarning - return plan, nil - } - effective := s.fleetCfg(st) - plan, err := AutofixPlan(effective, agent, agentArgs, repos, effectiveDryRun) - plan.PolicySource = "fleet" + plan, err := AutofixPlan(s.cfg, agent, agentArgs, repos, effectiveDryRun, skipAuth) if err != nil || effectiveDryRun { return plan, err } - return s.applyAutofix(ctx, plan, s.autofixFallbackConfig(repos), repos) -} - -// autofixFallbackConfig is what the unit should carry for settings the fleet may -// later unset. Fleet policy is overlaid from state at runtime; baking today's -// effective values into the service would make them survive after that policy -// was removed. Explicit install repositories remain this host's chosen fallback. -func (s *Service) autofixFallbackConfig(repos []string) Config { - cfg := s.cfg - if len(repos) == 0 { - return cfg - } - cfg.AllowRepos = make(map[string]bool, len(repos)) - for _, repo := range repos { - cfg.AllowRepos[repo] = true - } - return cfg + return s.applyAutofix(ctx, plan) } // AutofixPlan computes what an install WOULD write and run, from configuration @@ -112,7 +82,7 @@ func (s *Service) autofixFallbackConfig(repos []string) Config { // as a preview and must work for somebody who has not authenticated yet: the // plan reads no GitHub state, so requiring a token to see it turned the one // command for inspecting the setup into another thing to set up first. -func AutofixPlan(cfg Config, agent string, agentArgs []string, repos []string, dryRun bool) (AutofixInstall, error) { +func AutofixPlan(cfg Config, agent string, agentArgs []string, repos []string, dryRun, skipAuth bool) (AutofixInstall, error) { home, err := os.UserHomeDir() if err != nil { return AutofixInstall{}, err @@ -141,23 +111,20 @@ func AutofixPlan(cfg Config, agent string, agentArgs []string, repos []string, d // records: map order would make two identical installs disagree. repos = sortedRepoList(cfg.AllowRepos) } - if len(repos) == 0 { - return AutofixInstall{}, fmt.Errorf("no repositories to watch: pass them, or set CRQ_REPOS") - } - // The service writes its output here. systemd refuses to start a unit whose // StandardOutput path cannot be opened (209/STDOUT), so the directory has to // exist before the unit does — a service that will not start is exactly the // silent nothing this command exists to prevent. logDir := filepath.Join(home, ".local", "state", "crq") plan := AutofixInstall{ - Platform: runtime.GOOS, - LogDir: logDir, - Prompt: filepath.Join(home, ".local", "share", "crq", "fix-prompt.txt"), - Wrapper: filepath.Join(home, ".local", "bin", "crq-autofix"), - Agent: agent, - Repos: repos, - DryRun: dryRun, + Platform: runtime.GOOS, + LogDir: logDir, + Prompt: filepath.Join(home, ".local", "share", "crq", "fix-prompt.txt"), + Agent: agent, + AgentArgs: agentArgs, + Repos: repos, + DryRun: dryRun, + SkipAuthCheck: skipAuth, } if root := strings.TrimSpace(cfg.WorkspaceRoot); root != "" { plan.Workspace, err = filepath.Abs(root) @@ -198,41 +165,45 @@ func AutofixPlan(cfg Config, agent string, agentArgs []string, repos []string, d plan.Commands = append([]string{"systemctl --user disable --now crq-drain"}, plan.Commands...) } } - invocation, err := agentInvocation(agent, plan.Prompt, agentArgs) + self, err := os.Executable() if err != nil { - return plan, err + return plan, fmt.Errorf("resolving the crq binary: %w", err) } - plan.Invocation = invocation - + if resolved, rerr := filepath.EvalSymlinks(self); rerr == nil { + self = resolved + } + plan.Binary = self + // What the unit runs, shown by --dry-run: one binary starting the watcher, + // and the same binary running each session. + plan.Invocation = strings.Join(autofixArgv(plan), " ") return plan, nil } +// autofixArgv is the unit's command line: the watcher, told to dispatch each +// fix session by running crq again. +func autofixArgv(plan AutofixInstall) []string { + return []string{plan.Binary, "watch", "--", plan.Binary, "fix-session"} +} + // applyAutofix writes the plan to disk and starts the service. -func (s *Service) applyAutofix(ctx context.Context, plan AutofixInstall, cfg Config, repos []string) (AutofixInstall, error) { - invocation, logDir := plan.Invocation, plan.LogDir - self, err := os.Executable() - if err != nil { - return plan, err - } - if err := autofixCanAuthenticate(ctx); err != nil { - return plan, err +func (s *Service) applyAutofix(ctx context.Context, plan AutofixInstall) (AutofixInstall, error) { + logDir := plan.LogDir + if !plan.SkipAuthCheck { + if err := serviceCanAuthenticate(ctx, "autofix"); err != nil { + return plan, err + } } if err := os.MkdirAll(logDir, 0o755); err != nil { return plan, err } - // Known agents receive their non-interactive flags. Any other executable is - // treated as a self-contained prompt-taking wrapper. - wrapper := autofixWrapper(self, invocation, repos) - for _, f := range []struct { path string body string mode os.FileMode }{ {plan.Prompt, fixPrompt, 0o644}, - {plan.Wrapper, wrapper, 0o755}, - {plan.Unit, autofixUnitFor(cfg, plan), 0o644}, + {plan.Unit, s.autofixUnit(plan), 0o644}, } { if err := os.MkdirAll(filepath.Dir(f.path), 0o755); err != nil { return plan, err @@ -281,23 +252,6 @@ func (s *Service) applyAutofix(ctx context.Context, plan AutofixInstall, cfg Con return plan, nil } -func autofixWrapper(self, invocation string, repos []string) string { - watchArgs := make([]string, 0, len(repos)) - for _, repo := range repos { - watchArgs = append(watchArgs, shellQuote(repo)) - } - watch := strings.Join(watchArgs, " ") - if watch != "" { - watch += " " - } - return fmt.Sprintf(`#!/usr/bin/env bash -# Installed by "crq autofix install". Runs autofix: crq decides, and a -# fix session is started for each PR that needs one. -set -uo pipefail -exec %s watch %s-- %s -`, shellQuote(self), watch, invocation) -} - func writeAutofixFile(path, body string, mode os.FileMode) error { if err := os.WriteFile(path, []byte(body), mode); err != nil { return err @@ -361,7 +315,7 @@ func launchdJobAbsent(command string, output []byte) bool { return strings.Contains(text, "no such process") || strings.Contains(text, "could not find service") } -// autofixCanAuthenticate reports whether the SERVICE will find a GitHub +// serviceCanAuthenticate reports whether the named SERVICE will find a GitHub // credential — which is not the same question as whether this shell has one. // // crq resolves a token from GITHUB_TOKEN/GH_TOKEN or `gh auth token`, and the @@ -371,7 +325,15 @@ func launchdJobAbsent(command string, output []byte) bool { // nothing this command exists to prevent. Writing the token into the unit is not // the answer — that file is readable by every local user — so the credential has // to be one the service can resolve itself. -func autofixCanAuthenticate(ctx context.Context) error { +// A macOS host is the case this cannot decide for itself: gh keeps its token in +// the login keychain, which a launchd agent in the GUI domain can read and an +// SSH session cannot. Over SSH, `gh auth status` there reports the account by +// name and the token as invalid — which is also exactly what a genuinely +// expired token looks like. Since the two are indistinguishable from outside +// that session, the escape hatch is an explicit flag rather than a guess: an +// operator typing --skip-auth-check has made a claim, which is not the silent +// nothing this check exists to prevent. +func serviceCanAuthenticate(ctx context.Context, service string) error { if path := ConfigPath(); path != "" { values, err := readEnvFile(path) if err == nil { @@ -389,7 +351,7 @@ func autofixCanAuthenticate(ctx context.Context) error { if out, err := cmd.Output(); err == nil && strings.TrimSpace(string(out)) != "" { return nil } - return fmt.Errorf("the autofix service would have no GitHub credential: a service does not inherit this shell's GITHUB_TOKEN/GH_TOKEN. Run 'gh auth login', or put the token in %s, then install again", ConfigPath()) + return fmt.Errorf("the %s service would have no GitHub credential: a service does not inherit this shell's GITHUB_TOKEN/GH_TOKEN. Run 'gh auth login', or put the token in %s, then install again", service, ConfigPath()) } // autofixPath is the PATH the service runs with: this shell's, plus wherever crq @@ -410,7 +372,7 @@ func autofixPath(plan AutofixInstall) string { seen[dir] = true dirs = append(dirs, dir) } - paths := []string{plan.Wrapper, plan.Agent} + paths := []string{plan.Binary, plan.Agent} if self, err := os.Executable(); err == nil { paths = append(paths, self) // the session runs `crq resolve` itself } @@ -436,106 +398,67 @@ func autofixPath(plan AutofixInstall) string { // wrong still reports Started, and the watcher then loads a different queue, or // none. func (s *Service) autofixEnv(plan AutofixInstall) map[string]string { - return autofixEnvFor(s.cfg, plan) -} - -func autofixEnvFor(cfg Config, plan AutofixInstall) map[string]string { - // Only an operator's OWN list travels. When it was derived from who reviews, - // writing the currently derived logins out makes the service read them back - // as an explicit choice, frozen: a later fleet `cobots` change that enables - // an optional reviewer would then never reach the surfaced set, and a round - // could converge without ever showing that bot's findings. Empty is the - // environment's "unset" — the same reasoning, and the same encoding, as the - // implicit co-reviewer trigger below. - feedbackBots := "" - if cfg.FeedbackBotsExplicit { - feedbackBots = strings.Join(cfg.FeedbackBots, ",") - } env := map[string]string{ - "CRQ_REPOS": strings.Join(sortedRepoList(cfg.AllowRepos), ","), + "CRQ_REPOS": strings.Join(plan.Repos, ","), // The denylist travels with the allowlist. Carrying one and not the other // installs a service that watches a repository the operator excluded. - "CRQ_EXCLUDE": strings.Join(sortedRepoList(cfg.ExcludeRepos), ","), - "CRQ_SCOPE": strings.Join(cfg.Scope, ","), - "CRQ_WATCH_INTERVAL": cfg.WatchInterval.String(), - "CRQ_DISPATCH_MAX_ATTEMPTS": fmt.Sprint(cfg.DispatchMaxAttempts), - "CRQ_DISPATCH_CONCURRENCY": fmt.Sprint(cfg.DispatchConcurrency), - "CRQ_DISPATCH_FORKS": strconv.FormatBool(cfg.DispatchForks), - "CRQ_AUTOREVIEW_SKIP_MARKER": cfg.SkipMarker, - "CRQ_BOT": cfg.Bot, - "CRQ_REQUIRED_BOTS": strings.Join(cfg.RequiredBots, ","), - "CRQ_FEEDBACK_BOTS": feedbackBots, - "CRQ_REVIEW_CMD": cfg.ReviewCommand, - "CRQ_RATELIMIT_CMD": cfg.RateLimitCommand, - "CRQ_RL_MARKER": cfg.RateLimitMarker, - "CRQ_CAL_REPLY_MARKER": cfg.CalibrationMarker, - "CRQ_REVIEW_DONE_MARKER": cfg.ReviewDoneMarker, - "CRQ_COMPLETION_MARKER": cfg.CompletionMarker, - "CRQ_MIN_INTERVAL": cfg.MinInterval.String(), - "CRQ_INFLIGHT_TIMEOUT": cfg.InflightTimeout.String(), - "CRQ_POLL": cfg.PollInterval.String(), - "CRQ_FEEDBACK_WAIT_TIMEOUT": cfg.FeedbackWaitTimeout.String(), - "CRQ_SETTLE": cfg.SettleWindow.String(), + "CRQ_EXCLUDE": strings.Join(sortedRepoList(s.cfg.ExcludeRepos), ","), + "CRQ_SCOPE": strings.Join(s.cfg.Scope, ","), + "CRQ_WATCH_INTERVAL": s.cfg.WatchInterval.String(), + "CRQ_DISPATCH_MAX_ATTEMPTS": fmt.Sprint(s.cfg.DispatchMaxAttempts), + "CRQ_DISPATCH_CONCURRENCY": fmt.Sprint(s.cfg.DispatchConcurrency), + "CRQ_DISPATCH_FORKS": strconv.FormatBool(s.cfg.DispatchForks), + "CRQ_AUTOREVIEW_SKIP_MARKER": s.cfg.SkipMarker, + // The session reads these; there is no script holding them any more. + "CRQ_FIX_AGENT": plan.Agent, + // Quoted, not space-joined: the session splits this back into argv, and + // an argument the operator quoted once must not be split a second time. + "CRQ_FIX_ARGS": JoinArgv(plan.AgentArgs), + "CRQ_FIX_PROMPT_FILE": plan.Prompt, + "CRQ_BOT": s.cfg.Bot, + "CRQ_REQUIRED_BOTS": strings.Join(s.cfg.RequiredBots, ","), + "CRQ_FEEDBACK_BOTS": "", + "CRQ_REVIEW_CMD": s.cfg.ReviewCommand, + "CRQ_RATELIMIT_CMD": s.cfg.RateLimitCommand, + "CRQ_RL_MARKER": s.cfg.RateLimitMarker, + "CRQ_CAL_REPLY_MARKER": s.cfg.CalibrationMarker, + "CRQ_REVIEW_DONE_MARKER": s.cfg.ReviewDoneMarker, + "CRQ_COMPLETION_MARKER": s.cfg.CompletionMarker, + "CRQ_MIN_INTERVAL": s.cfg.MinInterval.String(), + "CRQ_INFLIGHT_TIMEOUT": s.cfg.InflightTimeout.String(), + "CRQ_POLL": s.cfg.PollInterval.String(), + "CRQ_FEEDBACK_WAIT_TIMEOUT": s.cfg.FeedbackWaitTimeout.String(), + "CRQ_SETTLE": s.cfg.SettleWindow.String(), // The quota timings belong here for the same reason as every other // setting: the service does not inherit the shell that installed it. A // deliberately longer fallback set only in that shell was folded into // this config, written into no unit, and then silently replaced by the // default — so the watcher retried a review command earlier than // configured for every account block it could not parse a window from. - "CRQ_CALIBRATE_TTL": cfg.CalibrationTTL.String(), - "CRQ_RL_FALLBACK": cfg.RateLimitFallback.String(), + "CRQ_CALIBRATE_TTL": s.cfg.CalibrationTTL.String(), + "CRQ_RL_FALLBACK": s.cfg.RateLimitFallback.String(), "PATH": autofixPath(plan), } - if cfg.RateLimitCoDegrade { + if s.cfg.FeedbackBotsExplicit { + env["CRQ_FEEDBACK_BOTS"] = strings.Join(s.cfg.FeedbackBots, ",") + } + if s.cfg.RateLimitCoDegrade { env["CRQ_RL_CO_DEGRADE"] = "1" } else { env["CRQ_RL_CO_DEGRADE"] = "0" } - coNames := make([]string, 0, len(cfg.CoBots)) - for _, co := range cfg.CoBots { + coNames := make([]string, 0, len(s.cfg.CoBots)) + for _, co := range s.cfg.CoBots { coNames = append(coNames, co.Name) - } - written := map[string]bool{} - writeCoBot := func(co CoBotConfig) { - key := strings.ToUpper(co.Name) - if written[key] { - return - } - written[key] = true - prefix := "CRQ_COBOT_" + key + prefix := "CRQ_COBOT_" + strings.ToUpper(co.Name) env[prefix+"_CMD"] = co.Command - // The explicitness bit has to survive the install, not just the mode. An - // implicit trigger is the registry default for how the bot is REQUIRED, - // recomputed whenever that changes; writing it out as a value makes the - // service read it back as an operator's explicit choice, and a later - // fleet `required-bots` change can then no longer promote the bot to its - // required trigger — leaving a required reviewer that is never commanded - // and a round that waits for it forever. Empty is the environment's - // "unset", and it is written rather than omitted so an inherited variable - // cannot supply an explicitness the installing host did not have. - trigger := "" + env[prefix+"_TRIGGER"] = "" if co.TriggerExplicit { - trigger = string(co.Trigger) + env[prefix+"_TRIGGER"] = string(co.Trigger) } - env[prefix+"_TRIGGER"] = trigger env[prefix+"_REQUIRED"] = strconv.FormatBool(co.Required) env[prefix+"_GRACE"] = co.SelfHealGrace.String() } - // The enabled entries first: they carry the requiredness this host resolved, - // and with it the trigger mode that requiredness implies. - for _, co := range cfg.CoBots { - writeCoBot(co) - } - // Then the registry-wide fallbacks for the bots this host has switched off. - // A command, trigger or grace set for one of those is still the value this - // host would drive it with the moment something enables it — a per-repo - // reviewer override, or a fleet `cobots` change that names the bot without - // naming its per-bot keys. Carrying only the enabled set left the service - // running that bot on registry defaults while the installing shell's preview - // showed the host's own. - for _, co := range cfg.KnownCoBots { - writeCoBot(co) - } // Explicitly carry an empty set: omitting this key would re-enable every // default co-reviewer when the service starts. env["CRQ_COBOTS"] = strings.Join(coNames, ",") @@ -544,7 +467,7 @@ func autofixEnvFor(cfg Config, plan AutofixInstall) map[string]string { // or dispatch silently falls back to another filesystem. workspace := plan.Workspace if workspace == "" { - workspace = cfg.WorkspaceRoot + workspace = s.cfg.WorkspaceRoot } if workspace != "" { env["CRQ_WORKSPACE"] = workspace @@ -556,28 +479,24 @@ func autofixEnvFor(cfg Config, plan AutofixInstall) map[string]string { // the dashboard, and the PR the account quota is probed on. Without the first // two, the watcher cannot even load state — or loads a queue nobody else is // using, which looks exactly like an idle fleet. - if cfg.GateRepo != "" { - env["CRQ_REPO"] = cfg.GateRepo + if s.cfg.GateRepo != "" { + env["CRQ_REPO"] = s.cfg.GateRepo } - if cfg.StateRef != "" { - env["CRQ_STATE_REF"] = cfg.StateRef + if s.cfg.StateRef != "" { + env["CRQ_STATE_REF"] = s.cfg.StateRef } - if cfg.DashboardIssue > 0 { - env["CRQ_ISSUE"] = fmt.Sprint(cfg.DashboardIssue) + if s.cfg.DashboardIssue > 0 { + env["CRQ_ISSUE"] = fmt.Sprint(s.cfg.DashboardIssue) } - if cfg.CalibrationPR > 0 { - env["CRQ_CAL_PR"] = fmt.Sprint(cfg.CalibrationPR) + if s.cfg.CalibrationPR > 0 { + env["CRQ_CAL_PR"] = fmt.Sprint(s.cfg.CalibrationPR) } return env } // autofixUnit renders the platform's service definition. func (s *Service) autofixUnit(plan AutofixInstall) string { - return autofixUnitFor(s.cfg, plan) -} - -func autofixUnitFor(cfg Config, plan AutofixInstall) string { - env := autofixEnvFor(cfg, plan) + env := s.autofixEnv(plan) // Sorted: the unit is a file on disk that a re-install rewrites, and map order // would make every rewrite a different file for the same configuration. keys := make([]string, 0, len(env)) @@ -596,7 +515,7 @@ func autofixUnitFor(cfg Config, plan AutofixInstall) string { Labelno.kristofferr.crq-autofix - ProgramArguments%s + ProgramArguments%s EnvironmentVariables %s RunAtLoad @@ -604,7 +523,7 @@ func autofixUnitFor(cfg Config, plan AutofixInstall) string { StandardOutPath%s/autofix.log StandardErrorPath%s/autofix.err -`, html.EscapeString(plan.Wrapper), entries.String(), +`, plistArgv(autofixArgv(plan)), entries.String(), html.EscapeString(logDir), html.EscapeString(logDir)) } var lines strings.Builder @@ -631,7 +550,7 @@ StandardError=append:%s/autofix.err [Install] WantedBy=default.target -`, lines.String(), systemdExecWord(plan.Wrapper), logDir, logDir) +`, lines.String(), systemdExecLine(autofixArgv(plan)), logDir, logDir) } // systemdExecWord makes one literal word for an ExecStart command line. @@ -642,36 +561,6 @@ func systemdExecWord(word string) string { return strconv.Quote(word) } -// agentInvocation renders the shell words that run one fix session. -// -// crq knows how to CALL the agents it ships support for, and nothing about which -// model they should use — that belongs in the agent's own configuration or in -// --agent-args, not baked into a queue. Both known agents are given the two -// things a session needs: the prompt, and permission to act without a human -// there to approve each step. -func agentInvocation(agent, promptPath string, extra []string) (string, error) { - quoted := make([]string, 0, len(extra)) - for _, a := range extra { - quoted = append(quoted, shellQuote(a)) - } - args := strings.Join(quoted, " ") - switch filepath.Base(agent) { - case "claude": - // stream-json so the session log fills as it works rather than only at - // the end, where a hung session would leave an empty file. - return fmt.Sprintf(`%s -p "$(cat %s)" --permission-mode bypassPermissions --output-format stream-json --verbose %s`, - shellQuote(agent), shellQuote(promptPath), args), nil - case "codex": - // exec is codex's non-interactive form; the prompt is its final - // positional argument. --skip-git-repo-check because the session runs in - // a detached worktree crq created. - return fmt.Sprintf(`%s exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox %s "$(cat %s)"`, - shellQuote(agent), args, shellQuote(promptPath)), nil - default: - return fmt.Sprintf(`%s %s "$(cat %s)"`, shellQuote(agent), args, shellQuote(promptPath)), nil - } -} - // shellQuote makes one literal POSIX shell word. The generated wrapper is Bash, // so single quotes prevent parameter, command and backtick expansion; an // embedded single quote is represented by ending and reopening the quoted word. @@ -690,3 +579,21 @@ func sortedRepoList(set map[string]bool) []string { sort.Strings(out) return out } + +// plistArgv renders a command line as launchd's ProgramArguments entries. +func plistArgv(argv []string) string { + var b strings.Builder + for _, a := range argv { + fmt.Fprintf(&b, "%s", html.EscapeString(a)) + } + return b.String() +} + +// systemdExecLine renders a command line for ExecStart, one literal word each. +func systemdExecLine(argv []string) string { + words := make([]string, 0, len(argv)) + for _, a := range argv { + words = append(words, systemdExecWord(a)) + } + return strings.Join(words, " ") +} diff --git a/internal/crq/autofix_test.go b/internal/crq/autofix_test.go index 618395fc..a79dcb76 100644 --- a/internal/crq/autofix_test.go +++ b/internal/crq/autofix_test.go @@ -3,7 +3,6 @@ package crq import ( "context" "encoding/xml" - "errors" "html" "os" "os/exec" @@ -14,7 +13,6 @@ import ( "testing" "time" - "github.com/kristofferR/coderabbit-queue/internal/dialect" "github.com/kristofferR/coderabbit-queue/internal/engine" ) @@ -27,7 +25,7 @@ func TestInstallAutofixPlansWithoutWriting(t *testing.T) { svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) agent := fakeAgent(t, "claude") - plan, err := svc.InstallAutofix(context.Background(), agent, nil, nil, true) + plan, err := svc.InstallAutofix(context.Background(), agent, nil, nil, true, false) if err != nil { t.Fatal(err) } @@ -40,7 +38,7 @@ func TestInstallAutofixPlansWithoutWriting(t *testing.T) { if len(plan.Repos) != 1 || plan.Repos[0] != "owner/name" { t.Errorf("repos = %v, want the configured fleet", plan.Repos) } - for _, path := range []string{plan.Prompt, plan.Wrapper, plan.Unit, plan.LogDir} { + for _, path := range []string{plan.Prompt, plan.Binary, plan.Unit, plan.LogDir} { if path == "" { t.Fatalf("plan = %+v, want every path named so --dry-run is reviewable", plan) } @@ -68,7 +66,7 @@ func TestAutofixPlanOrdersTheFleetTheSameWayEveryTime(t *testing.T) { want := []string{"owner/alpha", "owner/mike", "owner/zulu"} for i := range 8 { - plan, err := AutofixPlan(cfg, agent, nil, nil, true) + plan, err := AutofixPlan(cfg, agent, nil, nil, true, false) if err != nil { t.Fatal(err) } @@ -78,6 +76,19 @@ func TestAutofixPlanOrdersTheFleetTheSameWayEveryTime(t *testing.T) { } } +func TestAutofixPlanAllowsRepositoriesFromSharedEnrollment(t *testing.T) { + cfg := firingConfig() + cfg.AllowRepos = nil + + plan, err := AutofixPlan(cfg, fakeAgent(t, "claude"), nil, nil, true, false) + if err != nil { + t.Fatalf("planning with no legacy CRQ_REPOS: %v", err) + } + if len(plan.Repos) != 0 { + t.Fatalf("static repos = %v, want the watcher to load shared enrollments", plan.Repos) + } +} + // The rename breaks the state and the CLI, but a break in naming stops nothing // that is already running: a host that ran the pre-rename installer keeps an // enabled crq-drain unit, and installing autofix beside it left two watchers @@ -90,7 +101,7 @@ func TestAutofixPlanRetiresThePreRenameWatcher(t *testing.T) { agent := fakeAgent(t, "claude") // A host that never ran the old installer must not be told to retire it. - clean, err := AutofixPlan(cfg, agent, nil, nil, true) + clean, err := AutofixPlan(cfg, agent, nil, nil, true, false) if err != nil { t.Fatal(err) } @@ -114,7 +125,7 @@ func TestAutofixPlanRetiresThePreRenameWatcher(t *testing.T) { t.Fatal(err) } - plan, err := AutofixPlan(cfg, agent, nil, nil, true) + plan, err := AutofixPlan(cfg, agent, nil, nil, true, false) if err != nil { t.Fatal(err) } @@ -136,7 +147,7 @@ func TestInstallAutofixHonoursConfiguredDryRun(t *testing.T) { cfg.AllowRepos = map[string]bool{"owner/repo": true} svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) - plan, err := svc.InstallAutofix(context.Background(), fakeAgent(t, "claude"), nil, nil, false) + plan, err := svc.InstallAutofix(context.Background(), fakeAgent(t, "claude"), nil, nil, false, false) if err != nil { t.Fatal(err) } @@ -145,114 +156,6 @@ func TestInstallAutofixHonoursConfiguredDryRun(t *testing.T) { } } -func TestInstallAutofixUsesFleetRepositories(t *testing.T) { - cfg := firingConfig() - cfg.AllowRepos = nil - store := NewMemoryStore(cfg) - if _, err := store.Update(context.Background(), func(st *State) error { - st.SetFleetValue("repos", "owner/fleet-repo") - return nil - }); err != nil { - t.Fatal(err) - } - svc := NewService(cfg, newFakeGitHub(), store, nil) - - plan, err := svc.InstallAutofix(context.Background(), fakeAgent(t, "claude"), nil, nil, true) - if err != nil { - t.Fatal(err) - } - if len(plan.Repos) != 1 || plan.Repos[0] != "owner/fleet-repo" { - t.Fatalf("autofix repos = %v, want the fleet repository", plan.Repos) - } - if plan.PolicySource != "fleet" { - t.Fatalf("policy source = %q, want fleet", plan.PolicySource) - } -} - -func TestAutofixUnitKeepsHostFallbacksUnderFleetPolicy(t *testing.T) { - cfg := firingConfig() - cfg.AllowRepos = map[string]bool{"owner/host-repo": true} - cfg.ExcludeRepos = map[string]bool{"owner/host-excluded": true} - store := NewMemoryStore(cfg) - if _, err := store.Update(context.Background(), func(st *State) error { - st.SetFleetValue("repos", "owner/fleet-repo") - st.SetFleetValue("exclude", "owner/fleet-excluded") - return nil - }); err != nil { - t.Fatal(err) - } - svc := NewService(cfg, newFakeGitHub(), store, nil) - - plan, err := svc.InstallAutofix(context.Background(), fakeAgent(t, "claude"), nil, nil, true) - if err != nil { - t.Fatal(err) - } - if len(plan.Repos) != 1 || plan.Repos[0] != "owner/fleet-repo" { - t.Fatalf("preview repos = %v, want current fleet policy", plan.Repos) - } - env := autofixEnvFor(svc.autofixFallbackConfig(nil), plan) - if got := env["CRQ_REPOS"]; got != "owner/host-repo" { - t.Fatalf("unit CRQ_REPOS = %q, want host fallback", got) - } - if got := env["CRQ_EXCLUDE"]; got != "owner/host-excluded" { - t.Fatalf("unit CRQ_EXCLUDE = %q, want host fallback", got) - } -} - -func TestAutofixUnitKeepsExplicitRepositoriesAsHostFallback(t *testing.T) { - cfg := firingConfig() - cfg.AllowRepos = map[string]bool{"owner/configured": true} - svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) - plan := AutofixInstall{Repos: []string{"owner/explicit"}} - - env := autofixEnvFor(svc.autofixFallbackConfig(plan.Repos), plan) - if got := env["CRQ_REPOS"]; got != "owner/explicit" { - t.Fatalf("unit CRQ_REPOS = %q, want explicit install repository", got) - } -} - -func TestAutofixWrapperKeepsExplicitRepositoriesAuthoritative(t *testing.T) { - got := autofixWrapper("/usr/bin/crq", "'agent' 'prompt'", []string{"owner/explicit", "owner/with space"}) - if !strings.Contains(got, "watch 'owner/explicit' 'owner/with space' --") { - t.Fatalf("wrapper lost explicit repositories:\n%s", got) - } - if fallback := autofixWrapper("/usr/bin/crq", "'agent' 'prompt'", nil); !strings.Contains(fallback, "watch --") { - t.Fatalf("wrapper without explicit repositories does not use runtime fleet policy:\n%s", fallback) - } -} - -// A state ref this host cannot read must not turn the documented preview into an -// error — --dry-run is what somebody runs to inspect the setup before finishing -// it. The plan says which policy it could see, so it cannot be mistaken for the -// fleet's; a real install has no such fallback. -func TestInstallAutofixPreviewsFromTheHostWhenFleetStateIsUnreadable(t *testing.T) { - cfg := firingConfig() - cfg.AllowRepos = map[string]bool{"owner/host-repo": true} - svc := NewService(cfg, newFakeGitHub(), unreadableStore{NewMemoryStore(cfg)}, nil) - - plan, err := svc.InstallAutofix(context.Background(), fakeAgent(t, "claude"), nil, nil, true) - if err != nil { - t.Fatal(err) - } - if plan.PolicySource != "host" || plan.Warning == "" { - t.Fatalf("plan = %+v, want a labelled host-only preview", plan) - } - if len(plan.Repos) != 1 || plan.Repos[0] != "owner/host-repo" { - t.Fatalf("preview repos = %v, want this host's own", plan.Repos) - } - if _, err := svc.InstallAutofix(context.Background(), fakeAgent(t, "claude"), nil, nil, false); err == nil { - t.Fatal("an install wrote a unit from policy it could not check against the fleet's") - } -} - -// unreadableStore is a host that cannot reach the state ref: no access to the -// gate repository, or no network to reach it over. -type unreadableStore struct{ StateStore } - -func (unreadableStore) Load(context.Context) (State, Revision, error) { - return State{}, Revision{}, errors.New("state ref unreadable") -} - // A missing agent must fail loudly at install time. Discovering it at the first // dispatch means an autofix watcher that looks installed and fixes nothing. func TestInstallAutofixRefusesWithoutAnAgent(t *testing.T) { @@ -260,7 +163,7 @@ func TestInstallAutofixRefusesWithoutAnAgent(t *testing.T) { cfg.AllowRepos = map[string]bool{"owner/name": true} svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) - _, err := svc.InstallAutofix(context.Background(), "definitely-not-a-real-binary", nil, nil, true) + _, err := svc.InstallAutofix(context.Background(), "definitely-not-a-real-binary", nil, nil, true, false) if err == nil { t.Skip("a binary by that name exists on this machine") } @@ -327,7 +230,7 @@ func TestAutofixUnitCarriesTheConfigurationTheInstallRead(t *testing.T) { cfg.CalibrationPR = 1 svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) - plan, err := svc.InstallAutofix(context.Background(), fakeAgent(t, "claude"), nil, nil, true) + plan, err := svc.InstallAutofix(context.Background(), fakeAgent(t, "claude"), nil, nil, true, false) if err != nil { t.Fatal(err) } @@ -364,8 +267,6 @@ func TestAutofixUnitCarriesEffectiveReviewerConfiguration(t *testing.T) { cfg.SkipMarker = "" cfg.Bot = "custom-reviewer[bot]" cfg.RequiredBots = []string{"custom-reviewer[bot]", "cursor[bot]"} - // Named by the operator: it reaches past who reviews, which only an explicit - // CRQ_FEEDBACK_BOTS can do — and only an explicit one travels. cfg.FeedbackBots = []string{"custom-reviewer[bot]", "cursor[bot]", "observer[bot]"} cfg.FeedbackBotsExplicit = true cfg.ReviewCommand = "@custom review this" @@ -381,7 +282,7 @@ func TestAutofixUnitCarriesEffectiveReviewerConfiguration(t *testing.T) { Required: true, SelfHealGrace: 4 * time.Minute, }} svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) - plan := AutofixInstall{Platform: "linux", Wrapper: "/tmp/crq autofix", LogDir: "/tmp/crq logs"} + plan := AutofixInstall{Platform: "linux", Binary: "/tmp/crq autofix", LogDir: "/tmp/crq logs"} unit := svc.autofixUnit(plan) for _, want := range []string{ @@ -409,87 +310,41 @@ func TestAutofixUnitCarriesEffectiveReviewerConfiguration(t *testing.T) { } } -// An implicit trigger must install as unset. Freezing the resolved mode into the -// unit makes the service read it as an operator's choice, and the registry -// default for a bot the fleet later REQUIRES can then never be recomputed — so a -// required Codex would be waited for and never commanded. -func TestAutofixEnvInstallsAnImplicitTriggerAsUnset(t *testing.T) { - cfg := firingConfig() - cfg.CoBots = []CoBotConfig{{ - Name: "codex", Login: dialect.CodexBotLogin, Command: "@codex review", - Trigger: engine.TriggerNever, SelfHealGrace: 10 * time.Minute, - }} - svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) - - env := svc.autofixEnv(AutofixInstall{}) - trigger, ok := env["CRQ_COBOT_CODEX_TRIGGER"] - if !ok || trigger != "" { - t.Fatalf("CRQ_COBOT_CODEX_TRIGGER = %q, present=%t; want an explicit empty value", trigger, ok) - } - // The installed service reloads from this environment: the mode has to come - // back implicit, so requiring the bot still promotes it to always. - reloaded := resolveCoBot(env, mustCoReviewer(t, "codex"), true) - if reloaded.TriggerExplicit { - t.Fatal("reloaded trigger is explicit; the installing host had no such setting") - } - if reloaded.Trigger != engine.TriggerAlways { - t.Fatalf("required codex reloaded with trigger %q, want always", reloaded.Trigger) +func TestAutofixEnvKeepsDerivedReviewerSettingsDynamic(t *testing.T) { + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_HOST": "installer", + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": "coderabbitai", + }) + if err != nil { + t.Fatal(err) } -} - -// A derived surfaced set must install as unset, for the same reason as the -// implicit trigger: written out as a value, the service reads it back as the -// operator's own list and stops recomputing it, so a co-reviewer the fleet -// enables later never has its findings surfaced. -func TestAutofixEnvInstallsDerivedFeedbackBotsAsUnset(t *testing.T) { - cfg := firingConfig() - cfg.FeedbackBots = []string{cfg.Bot, dialect.CodexBotLogin} - cfg.FeedbackBotsExplicit = false svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) - env := svc.autofixEnv(AutofixInstall{}) - bots, ok := env["CRQ_FEEDBACK_BOTS"] - if !ok || bots != "" { - t.Fatalf("CRQ_FEEDBACK_BOTS = %q, present=%t; want an explicit empty value", bots, ok) - } -} - -// The per-bot settings of a co-reviewer this host has switched OFF still travel. -// They are what a repository override — or a fleet `cobots` change that names -// the bot without naming its keys — picks the bot up with, and the installing -// shell's own preview uses them. -func TestAutofixEnvCarriesDisabledCoBotFallbacks(t *testing.T) { - cfg := firingConfig() - cfg.CoBots = nil - cfg.KnownCoBots = []CoBotConfig{{ - Name: "bugbot", Login: "cursor[bot]", Command: "bugbot run please", - Trigger: engine.TriggerAlways, TriggerExplicit: true, - SelfHealGrace: 42 * time.Minute, - }} - svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) - env := svc.autofixEnv(AutofixInstall{}) - if got := env["CRQ_COBOTS"]; got != "" { - t.Fatalf("CRQ_COBOTS = %q, want the enabled set to stay empty", got) + if got := env["CRQ_FEEDBACK_BOTS"]; got != "" { + t.Fatalf("derived feedback bots were frozen into the unit as %q", got) } - for key, want := range map[string]string{ - "CRQ_COBOT_BUGBOT_CMD": "bugbot run please", - "CRQ_COBOT_BUGBOT_TRIGGER": "always", - "CRQ_COBOT_BUGBOT_GRACE": "42m0s", - } { - if got := env[key]; got != want { - t.Errorf("%s = %q, want %q", key, got, want) - } + if got := env["CRQ_COBOT_CODEX_TRIGGER"]; got != "" { + t.Fatalf("implicit Codex trigger was frozen into the unit as %q", got) } -} -func mustCoReviewer(t *testing.T, name string) dialect.CoReviewer { - t.Helper() - co, ok := dialect.CoReviewerByName(name) - if !ok { - t.Fatalf("unknown co-reviewer %q", name) + // A later fleet change can make Codex required. Restarting from the unit + // must then re-derive both visibility and the required `always` trigger. + env["CRQ_REQUIRED_BOTS"] = "coderabbitai,chatgpt-codex-connector[bot]" + reloaded, err := BuildConfig(env) + if err != nil { + t.Fatal(err) + } + if reloaded.FeedbackBotsExplicit || + !hasLogin(reloaded.FeedbackBots, "chatgpt-codex-connector[bot]") { + t.Fatalf("feedback bots stayed frozen after restart: %v", reloaded.FeedbackBots) + } + if len(reloaded.CoBots) != 1 || reloaded.CoBots[0].Trigger != engine.TriggerAlways || + reloaded.CoBots[0].TriggerExplicit { + t.Fatalf("Codex trigger did not follow requiredness after restart: %+v", reloaded.CoBots) } - return co } func TestAutofixEnvCarriesAnIntentionallyEmptySkipMarker(t *testing.T) { @@ -523,7 +378,7 @@ func TestInstallAutofixMakesRelativeWorkspaceAbsolute(t *testing.T) { cfg.WorkspaceRoot = "relative workspace" svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) - plan, err := svc.InstallAutofix(context.Background(), fakeAgent(t, "claude"), nil, nil, true) + plan, err := svc.InstallAutofix(context.Background(), fakeAgent(t, "claude"), nil, nil, true, false) if err != nil { t.Fatal(err) } @@ -539,7 +394,7 @@ func TestLaunchdUnitEscapesXMLValues(t *testing.T) { svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) plan := AutofixInstall{ Platform: "darwin", - Wrapper: "/tmp/a&b/crq-autofix", + Binary: "/tmp/a&b/crq", LogDir: "/tmp/a, so a value outside those rules is -// one no account can ever have — and recorded for the fleet, a single such -// entry fails the whole scan on every host at once, which is precisely the -// mistake worth catching at `crq config set` instead. func validOwnerLogin(login string) bool { if login == "" || len(login) > maxOwnerLogin || strings.HasPrefix(login, "-") || strings.HasSuffix(login, "-") || @@ -134,15 +139,6 @@ func validOwnerLogin(login string) bool { return true } -// validNameSegment reports whether part is one segment a GitHub owner or -// repository name could be. -// -// Same segment rules the workspace package applies to a path: "." and ".." are -// not names. The character class is GitHub's: an owner or repository name is -// letters, digits, hyphen, underscore and dot, so a segment holding anything -// else — a space, a slash, a control character — is one no scan result can ever -// normalize to. Recorded, it reads as a rule covering something while covering -// nothing at all. func validNameSegment(part string) bool { if part == "" || part == "." || part == ".." { return false diff --git a/internal/crq/autofixswitch_test.go b/internal/crq/autofixswitch_test.go index ade4a9e4..37fc1d13 100644 --- a/internal/crq/autofixswitch_test.go +++ b/internal/crq/autofixswitch_test.go @@ -45,9 +45,12 @@ func TestAutofixIsOnUnlessARepositorySaysOtherwise(t *testing.T) { t.Error("turning one repository off turned another off too") } - // Back to the default is distinguishable from an explicit on. - if cleared, err := svc.ClearAutofixEnabled(ctx, "owner/one"); err != nil || !cleared { + // Back to the default is distinguishable from an explicit on, and the answer + // it reports is the one the repository ends up with. + if setting, cleared, err := svc.ClearAutofixEnabled(ctx, "owner/one"); err != nil || !cleared { t.Fatalf("clear = %v %v, want it to report the setting it removed", cleared, err) + } else if !setting.Enabled || !setting.Default { + t.Errorf("clear reported %+v, want the resolved default", setting) } st, _, _ = store.Load(ctx) if !st.AutofixEnabled("owner/one") { @@ -55,25 +58,62 @@ func TestAutofixIsOnUnlessARepositorySaysOtherwise(t *testing.T) { } } -func TestAutofixSettingsListsTheFleetRepositoryAllowlist(t *testing.T) { +// The watcher builds its targets from CRQ_REPOS AND the enrollment records, so a +// repository enrolled from the dashboard is being fixed here under the fleet +// default. Listing only what env names — and what somebody has ruled on — left +// that repository out of the one screen that says whether crq may touch it. +func TestAutofixSettingsListEnrolledRepositories(t *testing.T) { ctx := context.Background() cfg := firingConfig() - cfg.AllowRepos = map[string]bool{"owner/host-only": true} + cfg.AllowRepos = map[string]bool{"owner/env": true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if _, err := svc.SetEnrollment(ctx, "owner/added", true, ""); err != nil { + t.Fatal(err) + } + settings, err := svc.AutofixSettings(ctx) + if err != nil { + t.Fatal(err) + } + listed := map[string]AutofixSetting{} + for _, s := range settings { + listed[s.Repo] = s + } + added, ok := listed["owner/added"] + if !ok { + t.Fatalf("settings = %+v, want the enrolled repository the watcher will fix", settings) + } + if !added.Enabled || !added.Default { + t.Errorf("owner/added = %+v, want it reported as on by the fleet default", added) + } + if _, ok := listed["owner/env"]; !ok { + t.Errorf("settings = %+v, want this host's own repositories still listed", settings) + } +} + +func TestAutofixSettingsUseFleetResolvedAllowList(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_REPOS": "startup/old", + }) + if err != nil { + t.Fatal(err) + } store := NewMemoryStore(cfg) if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("repos", "owner/fleet-only") + st.Fleet.Env = map[string]string{"CRQ_REPOS": "fleet/new"} return nil }); err != nil { t.Fatal(err) } - svc := NewService(cfg, newFakeGitHub(), store, nil) - - settings, err := svc.AutofixSettings(ctx) + settings, err := NewService(cfg, newFakeGitHub(), store, nil).AutofixSettings(ctx) if err != nil { t.Fatal(err) } - if len(settings) != 1 || settings[0].Repo != "owner/fleet-only" { - t.Fatalf("settings = %+v, want the effective fleet repository", settings) + if len(settings) != 1 || settings[0].Repo != "fleet/new" { + t.Fatalf("settings = %+v, want only the fleet-resolved repository", settings) } } @@ -85,12 +125,12 @@ func TestAutofixSwitchRejectsMalformedRepositoryNames(t *testing.T) { for _, repo := range []string{ "owner", "owner/repo/", "/repo", "owner/repo/extra", "../repo", - "owner_name/repo", ".owner/repo", "owner--name/repo", + "owner/bad repo", "owner/control\nname", "bad_owner/repo", "-owner/repo", } { if _, err := svc.SetAutofixEnabled(ctx, repo, false, "operator stop"); err == nil { t.Errorf("SetAutofixEnabled(%q) succeeded", repo) } - if _, err := svc.ClearAutofixEnabled(ctx, repo); err == nil { + if _, _, err := svc.ClearAutofixEnabled(ctx, repo); err == nil { t.Errorf("ClearAutofixEnabled(%q) succeeded", repo) } } diff --git a/internal/crq/calibration_test.go b/internal/crq/calibration_test.go index 7a7627de..b1eeef9d 100644 --- a/internal/crq/calibration_test.go +++ b/internal/crq/calibration_test.go @@ -2,12 +2,140 @@ package crq import ( "context" + "encoding/json" "testing" "time" ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) +type beforeUpdateStore struct { + StateStore + before func() + fired bool +} + +func (s *beforeUpdateStore) Update(ctx context.Context, mutate func(*State) error) (State, error) { + if s.before != nil && !s.fired { + s.fired = true + s.before() + } + return s.StateStore.Update(ctx, mutate) +} + +func TestRefreshQuotaUsesFleetResolvedPrimary(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "o/state", + "CRQ_CAL_PR": "77", + "CRQ_SCOPE": "startup-owner", + "CRQ_CALIBRATE_TTL": "1m", + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + gh := newFakeGitHub() + reply := ghapi.IssueComment{ + Body: "auto-generated reply by CodeRabbit\nYou have 3 reviews remaining.", + CreatedAt: now.Add(-10 * time.Second), + UpdatedAt: now.Add(-10 * time.Second), + } + reply.User.Login = "chatgpt-codex-connector[bot]" + gh.comments[fakeKey(cfg.GateRepo, cfg.CalibrationPR)] = []ghapi.IssueComment{reply} + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.Fleet.Env = map[string]string{ + "CRQ_BOT": "chatgpt-codex-connector[bot]", + "CRQ_SCOPE": "fleet-owner", + } + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, gh, store, nil) + svc.now = func() time.Time { return now } + + got, err := svc.RefreshQuota(ctx) + if err != nil { + t.Fatal(err) + } + if got.Account.Remaining == nil || *got.Account.Remaining != 3 { + t.Fatalf("remaining = %v, want the fleet primary's calibration reply", got.Account.Remaining) + } + if got.Account.Scope != "fleet-owner" { + t.Fatalf("scope = %q, want the fleet-resolved scope", got.Account.Scope) + } + if len(gh.posted) != 0 { + t.Fatalf("a matching fleet-primary reply should not post a new probe: %v", gh.posted) + } +} + +func TestRefreshQuotaDryRunHasNoSideEffects(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.CalibrationPR = 77 + cfg.DryRun = true + gh := newFakeGitHub() + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + + got, err := svc.RefreshQuota(ctx) + if err != nil { + t.Fatal(err) + } + if len(gh.posted) != 0 { + t.Fatalf("dry-run refresh posted a calibration probe: %v", gh.posted) + } + if got.Account.CheckedAt != nil || got.Account.CalibAskedAt != nil { + t.Fatalf("dry-run refresh mutated quota state: %+v", got.Account) + } +} + +func TestRefreshQuotaDropsAReadingWhenFleetPolicyChanges(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "o/state", + "CRQ_CAL_PR": "77", + "CRQ_CALIBRATE_TTL": "10m", + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + gh := newFakeGitHub() + reply := ghapi.IssueComment{ + Body: "auto-generated reply by CodeRabbit\nYou have 3 reviews remaining.", + CreatedAt: now.Add(-5 * time.Minute), + UpdatedAt: now.Add(-5 * time.Minute), + } + reply.User.Login = cfg.Bot + gh.comments[fakeKey(cfg.GateRepo, cfg.CalibrationPR)] = []ghapi.IssueComment{reply} + base := NewMemoryStore(cfg) + store := &beforeUpdateStore{StateStore: base} + store.before = func() { + _, updateErr := base.Update(ctx, func(st *State) error { + fd := st.Fleet + fd.Env = map[string]string{"CRQ_CALIBRATE_TTL": "1m"} + st.SetFleetDefaults(fd, "other-host", now) + return nil + }) + if updateErr != nil { + t.Fatal(updateErr) + } + } + svc := NewService(cfg, gh, store, nil) + svc.now = func() time.Time { return now } + + got, err := svc.RefreshQuota(ctx) + if err != nil { + t.Fatal(err) + } + if got.Account.Remaining != nil || got.Account.CheckedAt != nil { + t.Fatalf("stale in-flight calibration was committed after the TTL changed: %+v", got.Account) + } +} + // The calibration probe is the one writer that replaced the whole quota, so it // was also the one that could SHORTEN a standing block. That matters more now // that a PR's own rate-limit notice records one: a probe whose reply carries no @@ -76,6 +204,60 @@ func TestCalibrationNeverShortensAStandingBlock(t *testing.T) { } } +func TestCalibrationPreservesRollingFireHistory(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.CalibrationPR = 77 + cfg.GateRepo = "o/state" + cfg.CalibrationTTL = time.Minute + gh := newFakeGitHub() + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + now := time.Now().UTC() + svc.now = func() time.Time { return now } + + reply := ghapi.IssueComment{ID: 5, Body: "auto-generated reply by CodeRabbit\nYou have 3 reviews remaining.", + CreatedAt: now.Add(-10 * time.Second), UpdatedAt: now.Add(-10 * time.Second)} + reply.User.Login = cfg.Bot + gh.comments[fakeKey(cfg.GateRepo, 77)] = []ghapi.IssueComment{reply} + fired := now.Add(-time.Hour) + coverage := now.Add(-24 * time.Hour) + var carried AccountQuota + if err := json.Unmarshal([]byte(`{"future_quota_policy":{"mode":"audit"}}`), &carried); err != nil { + t.Fatal(err) + } + carried.Fires = []time.Time{fired} + carried.FiresFrom = &coverage + if _, err := store.Update(ctx, func(st *State) error { + st.Account = carried + return nil + }); err != nil { + t.Fatal(err) + } + + got, err := svc.RefreshQuota(ctx) + if err != nil { + t.Fatal(err) + } + if len(got.Account.Fires) != 1 || !got.Account.Fires[0].Equal(fired) { + t.Fatalf("fires = %v, want calibration to preserve the rolling usage log", got.Account.Fires) + } + if got.Account.FiresFrom == nil || !got.Account.FiresFrom.Equal(coverage) { + t.Fatalf("fires_from = %v, want calibration to preserve coverage", got.Account.FiresFrom) + } + raw, err := json.Marshal(got.Account) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + if _, ok := fields["future_quota_policy"]; !ok { + t.Fatalf("calibration dropped a newer binary's account member: %s", raw) + } +} + // Not shortening a block must not become refusing to end one. A reply that // states reviews are left is the account reporting itself available — keeping // the old window over it leaves state saying "reviews remaining" and "blocked" diff --git a/internal/crq/cliquota.go b/internal/crq/cliquota.go index 755de9da..063f6eb4 100644 --- a/internal/crq/cliquota.go +++ b/internal/crq/cliquota.go @@ -2,7 +2,6 @@ package crq import ( "context" - "errors" "strings" "time" @@ -62,15 +61,16 @@ func (s *Service) RecordCLIQuota(ctx context.Context, report PreflightReport, cl if err := s.cfg.RequireState(); err != nil { return CLIQuotaResult{Reason: "no crq state configured, so there is no shared quota to update"}, nil } - st, _, err := s.store.Load(ctx) + state, _, err := s.store.Load(ctx) if err != nil { - return CLIQuotaResult{}, err + return CLIQuotaResult{Reason: "shared quota state could not be read, so the account behind the block cannot be confirmed"}, nil } - cfg := s.fleetCfg(st) + cfg := s.cfg.WithFleet(state.Fleet) if !cliOrgMatches(cfg, cliOrg) { return CLIQuotaResult{ Reason: "the coderabbit cli is authenticated to " + orDash(cliOrg) + ", which is not the account crq queues for (" + strings.Join(cfg.Scope, ",") + ")", + Until: state.Account.BlockedUntil, }, nil } @@ -81,6 +81,11 @@ func (s *Service) RecordCLIQuota(ctx context.Context, report PreflightReport, cl // conservative fallback is right: treating an unreadable window as "not // blocked" is what let the daemon re-fire every couple of minutes against // a limit measured in tens of minutes. + // + // The FLEET's window, when one is recorded. CRQ_RL_FALLBACK is a fleet + // setting, and reading this host's startup value recorded a shorter block + // than the settings page said was in force — resuming metered fires early + // against the number the dashboard was showing. fallback := now.Add(cliQuotaFallback(cfg.RateLimitFallback)) until = &fallback } @@ -91,14 +96,15 @@ func (s *Service) RecordCLIQuota(ctx context.Context, report PreflightReport, cl return result, nil } - applied, standing, err := s.applyAccountBlock(ctx, *until, "coderabbit-cli", cfg, cliOrg) - if errors.Is(err, errFleetQuotaChanged) { - result.Reason = "fleet policy changed while recording the block; run preflight again" - return result, nil - } + applied, standing, matched, err := s.applyAccountBlock(ctx, *until, "coderabbit-cli", cfg, cliOrg) if err != nil { return result, err } + if !matched { + result.Reason = "the fleet account changed before the block could be recorded" + result.Until = standing + return result, nil + } result.Applied = applied if !applied { result.Reason = "a longer account block is already recorded" @@ -107,8 +113,6 @@ func (s *Service) RecordCLIQuota(ctx context.Context, report PreflightReport, cl return result, nil } -var errFleetQuotaChanged = errors.New("fleet policy changed while recording cli quota") - // cliOrgMatches reports whether the CLI's current organisation is the account // crq queues for. An empty org fails closed: without knowing whose limit this is, // applying it fleet-wide is the more expensive mistake. diff --git a/internal/crq/cliquota_test.go b/internal/crq/cliquota_test.go index 4a00e033..8f82d6dd 100644 --- a/internal/crq/cliquota_test.go +++ b/internal/crq/cliquota_test.go @@ -3,7 +3,6 @@ package crq import ( "context" "encoding/json" - "errors" "os" "path/filepath" "testing" @@ -99,84 +98,6 @@ func TestRecordCLIQuotaRefusesAnotherAccount(t *testing.T) { } } -func TestRecordCLIQuotaUsesFleetScopeAndFallback(t *testing.T) { - now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) - svc, store := cliQuotaService(t, now) - if _, err := store.Update(context.Background(), func(st *State) error { - st.SetFleetValue("scope", "fleet-org") - st.SetFleetValue("rate-limit-fallback", "47m") - return nil - }); err != nil { - t.Fatal(err) - } - got, err := svc.RecordCLIQuota(context.Background(), blockedReport(t, "soon"), "fleet-org") - if err != nil { - t.Fatal(err) - } - want := now.Add(47 * time.Minute) - if !got.Applied || got.Until == nil || !got.Until.Equal(want) { - t.Fatalf("fleet quota result = %+v, want applied until %s", got, want) - } -} - -func TestRecordCLIQuotaRefusesAChangedFleetAccountAtCommit(t *testing.T) { - now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) - svc, store := cliQuotaService(t, now) - st, _, err := store.Load(context.Background()) - if err != nil { - t.Fatal(err) - } - snapshot := svc.fleetCfg(st) - if _, err := store.Update(context.Background(), func(st *State) error { - st.SetFleetValue("scope", "other-org") - return nil - }); err != nil { - t.Fatal(err) - } - - _, _, err = svc.applyAccountBlock( - context.Background(), now.Add(time.Hour), "coderabbit-cli", snapshot, "kristofferR", - ) - if !errors.Is(err, errFleetQuotaChanged) { - t.Fatalf("stale quota write = %v, want fleet-account refusal", err) - } - st, _, _ = store.Load(context.Background()) - if st.Account.BlockedUntil != nil { - t.Fatalf("stale CLI account block was recorded: %+v", st.Account) - } -} - -// Only the account the evidence belongs to can invalidate it. Comparing the -// whole fleet revision meant any unrelated setting moving between the read and -// the write refused an explicit, organisation-attributed block: the operator was -// told to run preflight again while the shared quota stayed open, and the daemon -// could post a metered review inside the window the CLI had just reported. -func TestRecordCLIQuotaSurvivesAnUnrelatedFleetChange(t *testing.T) { - now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) - svc, store := cliQuotaService(t, now) - st, _, err := store.Load(context.Background()) - if err != nil { - t.Fatal(err) - } - snapshot := svc.fleetCfg(st) - if _, err := store.Update(context.Background(), func(st *State) error { - st.SetFleetValue("settle", "9m") - return nil - }); err != nil { - t.Fatal(err) - } - - applied, standing, err := svc.applyAccountBlock( - context.Background(), now.Add(time.Hour), "coderabbit-cli", snapshot, "kristofferR", - ) - if err != nil { - t.Fatal(err) - } - if !applied || standing == nil || !standing.Equal(now.Add(time.Hour)) { - t.Fatalf("applied=%v standing=%v, want the block recorded across the unrelated change", applied, standing) - } -} - // A window read from a PR comment is authoritative about the whole account; a // local reading may be a narrower limit. Extending is safe, shortening is not. func TestRecordCLIQuotaNeverShortensAStandingBlock(t *testing.T) { @@ -393,3 +314,118 @@ func TestRecordCLIQuotaClearsAPendingCalibration(t *testing.T) { t.Errorf("a pending calibration must not outlive the block that replaced it: %s", st.Account.CalibAskedAt) } } + +// The fallback window is a fleet setting, so the block recorded here has to be +// the one the settings page says is in force. Read from this host's startup +// value, a fleet-lengthened window recorded the host's shorter one instead — +// resuming metered fires early, against the number the dashboard was showing. +func TestRecordCLIQuotaUsesTheFleetFallbackWindow(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + // Built from an env map, because the fleet's generic settings are applied by + // re-parsing the configuration crq was built from — which is what a host has. + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "kristofferR/crq-state", "CRQ_HOST": "testhost", + "CRQ_SCOPE": "kristofferR", "CRQ_COBOTS": "", "CRQ_RL_FALLBACK": "15m", + }) + if err != nil { + t.Fatal(err) + } + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + svc.now = func() time.Time { return now } + + if _, err := svc.SetEnv(ctx, "CRQ_RL_FALLBACK", "1h", false); err != nil { + t.Fatal(err) + } + got, rerr := svc.RecordCLIQuota(ctx, blockedReport(t, "soon"), "kristofferR") + if rerr != nil { + t.Fatal(rerr) + } + if !got.Applied { + t.Fatalf("an unreadable window must still block: %+v", got) + } + st, _, _ := store.Load(ctx) + if st.Account.BlockedUntil == nil || !st.Account.BlockedUntil.Equal(now.Add(time.Hour)) { + t.Errorf("BlockedUntil = %v, want the fleet's hour rather than this host's 15 minutes", + st.Account.BlockedUntil) + } +} + +func TestRecordCLIQuotaUsesTheFleetResolvedScope(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/crq-state", + "CRQ_SCOPE": "startup-account", + }) + if err != nil { + t.Fatal(err) + } + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.Fleet.Env = map[string]string{"CRQ_SCOPE": "fleet-account"} + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, newFakeGitHub(), store, nil) + svc.now = func() time.Time { return now } + + got, err := svc.RecordCLIQuota(ctx, blockedReport(t, "32 minutes"), "fleet-account") + if err != nil { + t.Fatal(err) + } + if !got.Applied { + t.Fatalf("fleet account's block was rejected: %+v", got) + } + st, _, _ := store.Load(ctx) + if st.Account.Scope != "fleet-account" { + t.Fatalf("recorded scope = %q, want fleet-account", st.Account.Scope) + } +} + +func TestRecordCLIQuotaDropsABlockWhenFleetAccountChanges(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/crq-state", + "CRQ_SCOPE": "startup-account", + }) + if err != nil { + t.Fatal(err) + } + base := NewMemoryStore(cfg) + if _, err := base.Update(ctx, func(st *State) error { + st.Fleet.Env = map[string]string{"CRQ_SCOPE": "fleet-account"} + return nil + }); err != nil { + t.Fatal(err) + } + store := &beforeUpdateStore{StateStore: base} + store.before = func() { + _, updateErr := base.Update(ctx, func(st *State) error { + fd := st.Fleet + fd.Env = map[string]string{"CRQ_SCOPE": "replacement-account"} + st.SetFleetDefaults(fd, "other-host", now) + return nil + }) + if updateErr != nil { + t.Fatal(updateErr) + } + } + svc := NewService(cfg, newFakeGitHub(), store, nil) + svc.now = func() time.Time { return now } + + got, err := svc.RecordCLIQuota(ctx, blockedReport(t, "32 minutes"), "fleet-account") + if err != nil { + t.Fatal(err) + } + if got.Applied || got.Reason == "" { + t.Fatalf("block for the replaced account was not refused: %+v", got) + } + st, _, _ := base.Load(ctx) + if st.Account.BlockedUntil != nil { + t.Fatalf("block for the prior fleet account was committed: %+v", st.Account) + } +} diff --git a/internal/crq/codex_replay_test.go b/internal/crq/codex_replay_test.go index e5b0944d..17a7b850 100644 --- a/internal/crq/codex_replay_test.go +++ b/internal/crq/codex_replay_test.go @@ -162,10 +162,9 @@ func TestCodexReplayRequiredFiresOnceAndGatesCompletion(t *testing.T) { } } -// (ii) Codex configured-required but auto-review is active (a Codex review -// exists that no `@codex review` command preceded): crq must never post the -// Codex command; the round still gates on Codex's own review. -func TestCodexReplayAutoActiveSuppressesCommand(t *testing.T) { +// (ii) Codex configured-required in always mode: historical auto-review +// activity must not override the explicit trigger policy for the new head. +func TestCodexReplayAlwaysModeOverridesHistoricalAutoActivity(t *testing.T) { base := time.Date(2026, 7, 17, 9, 0, 0, 0, time.UTC) f := newCodexReplayFixture(t, base, func(cfg *Config) { cfg.RequiredBots = []string{cfg.Bot, codexLogin} @@ -174,15 +173,16 @@ func TestCodexReplayAutoActiveSuppressesCommand(t *testing.T) { oldHead, head := "1111222233334444", "5555666677778888" f.openPull(repo, pr, head) f.setCommitDate(head, base.Add(-time.Hour)) - // History: Codex reviewed the previous head unprompted → auto-review is on. + // History: Codex reviewed the previous head unprompted. That may predict + // another automatic review, but "always" still commands this head. f.codexReview(repo, pr, 400, oldHead, base.Add(-2*time.Hour)) f.enqueue(repo, pr) if res := f.pump(); res.Action != "fired" { t.Fatalf("expected fire, got %+v", res) } - if got := f.codexPosted(repo, pr); got != 0 { - t.Fatalf("auto-active codex must never be commanded, got %d posts", got) + if got := f.codexPosted(repo, pr); got != 1 { + t.Fatalf("always-mode codex must be commanded once, got %d posts", got) } // CodeRabbit finishes; the round still waits for Codex's auto review. @@ -192,8 +192,8 @@ func TestCodexReplayAutoActiveSuppressesCommand(t *testing.T) { if r := f.round(repo, pr); r == nil || r.Phase != PhaseReviewing { t.Fatalf("round must wait for codex auto review, got %+v", r) } - if got := f.codexPosted(repo, pr); got != 0 { - t.Fatalf("waiting must not trigger a codex post, got %d", got) + if got := f.codexPosted(repo, pr); got != 1 { + t.Fatalf("waiting must not duplicate the codex post, got %d", got) } // Codex auto-reviews the head (clean) — round completes. @@ -777,3 +777,114 @@ func TestCodexReplayAdoptRecordsExistingCodexCommand(t *testing.T) { t.Fatalf("self-heal must not repost the recorded codex command, got %d", got) } } + +// A round can go straight from fired to completed on one observation, and a +// completed round is never looked at again. The co-reviewer bookkeeping was +// only done in the reviewing sweep, so the evidence that Codex answered was +// lost with the round that carried it — and the bot guide showed a bot that had +// just reviewed as one nobody had heard from. +func TestCodexReplayRecordsAnswerWhenTheSlotRoundCompletes(t *testing.T) { + base := time.Date(2026, 7, 17, 9, 0, 0, 0, time.UTC) + f := newCodexReplayFixture(t, base, func(cfg *Config) { + cfg.RequiredBots = []string{cfg.Bot, codexLogin} + }) + repo, pr := "o/r", 12 + head := "1111222233334444" + f.openPull(repo, pr, head) + f.setCommitDate(head, base.Add(-time.Hour)) + + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected fire, got %+v", res) + } + // Both reviews land before the next observation, so the slot round is + // progressed once, all the way to completed. + f.botReview(repo, pr, 501, head, f.clk.now().Add(time.Minute)) + f.codexReview(repo, pr, 502, head, f.clk.now().Add(time.Minute)) + f.clk.advance(2 * time.Minute) + f.pump() + + r := f.round(repo, pr) + if r == nil || r.Phase != PhaseCompleted { + t.Fatalf("round must complete once both reviewers answered, got %+v", r) + } + if r.Co(codexLogin).AnsweredAt == nil { + t.Error("the round completed without recording that codex answered — the only field that tells a working bot from a silent one") + } +} + +// The primary's answer is recorded the same way a co-reviewer's is, from the +// same observation — and the round's PHASE is not allowed to stand in for it. +// +// A required set that omits the primary completes as soon as the co-reviewers +// answer and the primary merely acknowledges the metered command, so reading a +// completed round as review evidence labelled a reviewer as working on the +// strength of its own acknowledgement. +func TestReplayRecordsThePrimaryAnswerFromEvidenceNotThePhase(t *testing.T) { + base := time.Date(2026, 7, 17, 9, 0, 0, 0, time.UTC) + f := newCodexReplayFixture(t, base, func(cfg *Config) { + // Codex alone gates convergence, so the round can complete without the + // primary having reviewed anything. + cfg.RequiredBots = []string{codexLogin} + }) + repo, pr := "o/r", 21 + head := "1111222233334444" + f.openPull(repo, pr, head) + f.setCommitDate(head, base.Add(-time.Hour)) + + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected fire, got %+v", res) + } + // Only Codex answers. The primary has been asked and has done nothing. + f.codexReview(repo, pr, 502, head, f.clk.now().Add(time.Minute)) + f.clk.advance(2 * time.Minute) + f.pump() + + r := f.round(repo, pr) + if r == nil { + t.Fatal("the round vanished") + } + if r.FiredAt == nil { + t.Error("the round must still record that the primary was asked") + } + if r.PrimaryAnsweredAt != nil { + t.Errorf("the primary was recorded as having answered at %s, but it only ever acknowledged", r.PrimaryAnsweredAt) + } + + // Once it actually reviews the head, that IS the evidence. + f.botReview(repo, pr, 503, head, f.clk.now().Add(time.Minute)) + f.clk.advance(2 * time.Minute) + f.pump() + if r = f.round(repo, pr); r == nil || r.PrimaryAnsweredAt == nil { + t.Errorf("a review at the head must record the primary's answer, got %+v", r) + } +} + +func TestReplayRecordsCleanPrimarySummaryAsAnAnswer(t *testing.T) { + base := time.Date(2026, 7, 17, 9, 0, 0, 0, time.UTC) + f := newCodexReplayFixture(t, base, func(cfg *Config) { + cfg.RequiredBots = []string{cfg.Bot} + }) + repo, pr, head := "o/r", 22, "5555666677778888" + f.openPull(repo, pr, head) + f.setCommitDate(head, base.Add(-time.Hour)) + + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected fire, got %+v", res) + } + f.botComment(repo, pr, 504, + corpusMessage(t, "coderabbit/no-actionable-comments.md"), + f.clk.now().Add(time.Minute)) + f.clk.advance(2 * time.Minute) + f.pump() + + r := f.round(repo, pr) + if r == nil || r.Phase != PhaseCompleted { + t.Fatalf("clean primary summary must complete the round, got %+v", r) + } + if r.PrimaryAnsweredAt == nil { + t.Fatalf("clean primary summary completed the round without recording an answer: %+v", r) + } +} diff --git a/internal/crq/config.go b/internal/crq/config.go index 7175c257..ec194c5d 100644 --- a/internal/crq/config.go +++ b/internal/crq/config.go @@ -4,6 +4,7 @@ import ( "bufio" "errors" "fmt" + "maps" "os" "path/filepath" "strconv" @@ -54,12 +55,37 @@ type Config struct { // Bot / RequiredBots / FeedbackBots / CoBots above are DERIVED from it and // kept only so existing consumers keep compiling; new code should read this. Reviewers []Reviewer + // PrimaryOff is set by ForRepo when a repository turned the metered primary + // off. It is never a fleet setting: CRQ_BOT always names one, and "the fleet + // has no primary" is expressed by requiring nobody, not by this. + PrimaryOff bool // WatchInterval paces `crq watch`; DispatchCommand is the fix session it // runs with --dispatch, argv-style; DispatchMaxAttempts bounds dispatches per // head so a fix that keeps not working stops. - WatchInterval time.Duration - DispatchCommand []string + WatchInterval time.Duration + DispatchCommand []string + // FixAgent is the agent binary a fix session execs, chosen at install time + // and exported to the autofix unit as CRQ_FIX_AGENT. It is per HOST, not per + // repository: switching between claude and codex is a different command line + // rather than a different flag. Empty on a machine that runs no fix sessions, + // which is not the same as any particular agent. + FixAgent string DispatchMaxAttempts int + // FixModels/FixEffort/FixPrompt are the per-repository solver settings, put + // into the fix session's ENVIRONMENT rather than its argv. Argv is fixed + // when the watcher starts; the environment is built per dispatch, which is + // the only layer that can differ between two repositories the same watcher + // is handling. + FixModels []string + // FixModel is the preferred entry, retained for the session environment and + // older callers that only understand one model. + FixModel string + FixEffort string + FixPrompt string + // FixSeverities is the set of finding severities a session may act on. + // FixAskMode controls when it should stop and surface a clarification. + FixSeverities map[string]bool + FixAskMode string // DispatchForks allows fix sessions on pull requests whose head branch lives // in another repository. Off by default: a session runs an agent over that // branch's code with approvals bypassed and a write token in reach. @@ -84,16 +110,13 @@ type Config struct { // OverrideAt is when the per-repo reviewer override this configuration was // built from was last written, so a fire can tell whether it still holds. OverrideAt *time.Time - // FleetRevision identifies the fleet policy snapshot this configuration was - // built from. Like OverrideAt, it is revalidated inside the CAS mutation - // that commits a decision. - FleetRevision string - // ExplicitFleetEnv records fleet-owned variables supplied by either the - // config file or the process environment, under the name the host actually - // used — a legacy alias or per-bot key counts, since the fleet overrides it - // just the same. LoadConfig does not export normal config-file values, so - // os.Getenv alone cannot detect that divergence. - ExplicitFleetEnv map[string]bool + // FleetAt is the same fact one layer up: when the fleet defaults this + // configuration was built from were last written. Both are needed, because + // either layer can name the primary, the co-reviewers or the required set — + // and a fire revalidating only the repository override would post the old + // commands on the authority of fleet settings that were replaced while it + // was deciding. + FleetAt *time.Time RateLimitCommand string RateLimitMarker string CalibrationMarker string @@ -112,6 +135,11 @@ type Config struct { RateLimitFallback time.Duration AutoReviewPoll time.Duration AutoReviewMaxScan int + // WeeklyReviewLimit is the vendor's weekly fair-use threshold, past which + // reviews are throttled to roughly one an hour. Configurable because it is + // plan-dependent (60 on Pro, 90 on Pro+) and no API reports it; 0 disables + // the forecast without disabling the count. + WeeklyReviewLimit int LeaderTTL time.Duration FiredMax int // Tidy removes crq's own spent review-trigger comments as rounds progress. @@ -131,6 +159,34 @@ type Config struct { // (return their findings promptly, keep CodeRabbit queued for the window) // instead of waiting the block out. CRQ_RL_CO_DEGRADE, default on. RateLimitCoDegrade bool + + // env is the map this configuration was parsed from, so WithFleet can + // re-parse it with the fleet's overrides layered on. + env map[string]string +} + +// fixAgent is the agent this host's fix sessions would exec: the one the +// autofix install chose, or the first word of a legacy CRQ_DISPATCH_CMD. Empty +// on a machine that runs none. +func (c Config) fixAgent() string { + if c.FixAgent != "" { + return c.FixAgent + } + if len(c.DispatchCommand) > 0 { + return c.DispatchCommand[0] + } + return "" +} + +// Env is a copy of the environment this configuration was parsed from, for +// callers that need to show or compare it. A copy, because handing out the map +// would let a caller change a parsed configuration by mutating its input. +func (c Config) Env() map[string]string { + out := make(map[string]string, len(c.env)) + for k, v := range c.env { + out[k] = v + } + return out } // ConfigPath is the file crq reads its settings from: CRQ_CONFIG, or @@ -191,7 +247,17 @@ func LoadConfig() (Config, error) { env[k] = v } } + return BuildConfig(env) +} +// BuildConfig parses one env map into a configuration. +// +// Split out of LoadConfig so the SAME parse can be re-run over a merged map: +// this host's environment with the fleet's recorded overrides layered on top. +// Re-parsing rather than patching individual fields is what makes any setting +// fleet-settable without each one needing its own plumbing — and what stops the +// two paths ever disagreeing about what a value means. +func BuildConfig(env map[string]string) (Config, error) { host, _ := os.Hostname() bot := stringEnv(env, "CRQ_BOT", "coderabbitai[bot]") requiredBots := listEnv(env, "CRQ_REQUIRED_BOTS", bot) @@ -225,41 +291,59 @@ func LoadConfig() (Config, error) { for _, cb := range coBots { coLogins = append(coLogins, cb.Login) } + fixSeverities := severitySet(stringEnv(env, "CRQ_FIX_SEVERITIES", strings.Join(dialect.KnownSeverities(), ","))) + for severity := range fixSeverities { + if !dialect.IsSeverity(severity) { + return Config{}, fmt.Errorf("CRQ_FIX_SEVERITIES: severity %q is not one of %s", + severity, strings.Join(dialect.KnownSeverities(), ", ")) + } + } cfg := Config{ - GateRepo: env["CRQ_REPO"], - DashboardIssue: intEnv(env, "CRQ_ISSUE", 0), - CalibrationPR: intEnv(env, "CRQ_CAL_PR", 0), - Scope: listEnv(env, "CRQ_SCOPE", ownerOf(env["CRQ_REPO"])), - AllowRepos: repoSet(env["CRQ_REPOS"]), - ExcludeRepos: repoSet(env["CRQ_EXCLUDE"]), - SkipAuthors: authorSet(stringEnvAllowEmpty(env, "CRQ_AUTOREVIEW_SKIP_AUTHORS", "dependabot[bot]")), - SkipMarker: stringEnvAllowEmpty(env, "CRQ_AUTOREVIEW_SKIP_MARKER", ""), - StateRef: stringEnv(env, "CRQ_STATE_REF", "crq-state-v3"), - Bot: bot, - RequiredBots: requiredBots, - CoBots: coBots, - FeedbackBots: listEnv(env, "CRQ_FEEDBACK_BOTS", strings.Join(unionBots(requiredBots, coLogins), ",")), - ReviewCommand: stringEnv(env, "CRQ_REVIEW_CMD", "@coderabbitai review"), - RateLimitCommand: stringEnv(env, "CRQ_RATELIMIT_CMD", dialect.DefaultRateLimitCommand), - RateLimitMarker: stringEnv(env, "CRQ_RL_MARKER", dialect.DefaultRateLimitMarker), - CalibrationMarker: stringEnv(env, "CRQ_CAL_REPLY_MARKER", "auto-generated reply by CodeRabbit"), - ReviewDoneMarker: stringEnv(env, "CRQ_REVIEW_DONE_MARKER", "summarize by coderabbit.ai"), - CompletionMarker: stringEnvAllowEmpty(env, "CRQ_COMPLETION_MARKER", "Review finished"), - Host: stringEnv(env, "CRQ_HOST", host), - Timezone: env["CRQ_TZ"], - MinInterval: durationEnv(env, "CRQ_MIN_INTERVAL", 90*time.Second), - InflightTimeout: durationEnv(env, "CRQ_INFLIGHT_TIMEOUT", 15*time.Minute), - PollInterval: durationEnv(env, "CRQ_POLL", 15*time.Second), - WaitTimeout: durationEnv(env, "CRQ_WAIT_TIMEOUT", 0), - CalibrationTTL: durationEnv(env, "CRQ_CALIBRATE_TTL", 2*time.Minute), - RateLimitFallback: durationEnv(env, "CRQ_RL_FALLBACK", 15*time.Minute), - AutoReviewPoll: durationEnv(env, "CRQ_AUTOREVIEW_POLL", time.Minute), - AutoReviewMaxScan: intEnv(env, "CRQ_AUTOREVIEW_MAX_SCAN", 400), + GateRepo: env["CRQ_REPO"], + DashboardIssue: intEnv(env, "CRQ_ISSUE", 0), + CalibrationPR: intEnv(env, "CRQ_CAL_PR", 0), + Scope: listEnv(env, "CRQ_SCOPE", ownerOf(env["CRQ_REPO"])), + AllowRepos: repoSet(env["CRQ_REPOS"]), + ExcludeRepos: repoSet(env["CRQ_EXCLUDE"]), + SkipAuthors: authorSet(stringEnvAllowEmpty(env, "CRQ_AUTOREVIEW_SKIP_AUTHORS", "dependabot[bot]")), + SkipMarker: stringEnvAllowEmpty(env, "CRQ_AUTOREVIEW_SKIP_MARKER", ""), + StateRef: stringEnv(env, "CRQ_STATE_REF", "crq-state-v3"), + Bot: bot, + RequiredBots: requiredBots, + CoBots: coBots, + FeedbackBots: listEnv(env, "CRQ_FEEDBACK_BOTS", strings.Join(unionBots(requiredBots, coLogins), ",")), + ReviewCommand: stringEnv(env, "CRQ_REVIEW_CMD", "@coderabbitai review"), + RateLimitCommand: stringEnv(env, "CRQ_RATELIMIT_CMD", dialect.DefaultRateLimitCommand), + RateLimitMarker: stringEnv(env, "CRQ_RL_MARKER", dialect.DefaultRateLimitMarker), + CalibrationMarker: stringEnv(env, "CRQ_CAL_REPLY_MARKER", "auto-generated reply by CodeRabbit"), + ReviewDoneMarker: stringEnv(env, "CRQ_REVIEW_DONE_MARKER", "summarize by coderabbit.ai"), + CompletionMarker: stringEnvAllowEmpty(env, "CRQ_COMPLETION_MARKER", "Review finished"), + Host: stringEnv(env, "CRQ_HOST", host), + Timezone: env["CRQ_TZ"], + MinInterval: durationEnv(env, "CRQ_MIN_INTERVAL", 90*time.Second), + InflightTimeout: durationEnv(env, "CRQ_INFLIGHT_TIMEOUT", 15*time.Minute), + PollInterval: durationEnv(env, "CRQ_POLL", 15*time.Second), + WaitTimeout: durationEnv(env, "CRQ_WAIT_TIMEOUT", 0), + CalibrationTTL: durationEnv(env, "CRQ_CALIBRATE_TTL", 2*time.Minute), + RateLimitFallback: durationEnv(env, "CRQ_RL_FALLBACK", 15*time.Minute), + AutoReviewPoll: durationEnv(env, "CRQ_AUTOREVIEW_POLL", time.Minute), + AutoReviewMaxScan: intEnv(env, "CRQ_AUTOREVIEW_MAX_SCAN", 400), + // The vendor's weekly fair-use threshold, past which reviews are + // throttled to roughly one an hour. Configurable because it is + // plan-dependent (60 on Pro, 90 on Pro+) and there is no API to ask. + WeeklyReviewLimit: intEnv(env, "CRQ_WEEKLY_LIMIT", 60), LeaderTTL: durationEnv(env, "CRQ_LEADER_TTL", 3*time.Minute), FiredMax: intEnv(env, "CRQ_FIRED_MAX", 500), WatchInterval: durationEnv(env, "CRQ_WATCH_INTERVAL", 2*time.Minute), DispatchCommand: SplitArgv(env["CRQ_DISPATCH_CMD"]), - DispatchMaxAttempts: positiveIntEnv(env, "CRQ_DISPATCH_MAX_ATTEMPTS", 3), + FixAgent: strings.TrimSpace(env["CRQ_FIX_AGENT"]), + FixModels: configuredModels(env["CRQ_FIX_MODELS"], env["CRQ_FIX_MODEL"]), + FixModel: strings.TrimSpace(env["CRQ_FIX_MODEL"]), + FixEffort: strings.TrimSpace(env["CRQ_FIX_EFFORT"]), + FixPrompt: strings.TrimSpace(env["CRQ_FIX_PROMPT"]), + FixSeverities: fixSeverities, + FixAskMode: askModeEnv(env["CRQ_FIX_ASK"]), + DispatchMaxAttempts: positiveIntEnv(env, "CRQ_DISPATCH_MAX_ATTEMPTS", 5), DispatchForks: boolEnv(env, "CRQ_DISPATCH_FORKS", false), DispatchConcurrency: intEnv(env, "CRQ_DISPATCH_CONCURRENCY", 0), WorkspaceRoot: env["CRQ_WORKSPACE"], @@ -271,21 +355,16 @@ func LoadConfig() (Config, error) { RateLimitCoDegrade: stringEnv(env, "CRQ_RL_CO_DEGRADE", stringEnv(env, "CRQ_RL_CODEX_DEGRADE", "1")) != "0", } - cfg.ExplicitFleetEnv = map[string]bool{} - for _, setting := range fleetSettings() { - for _, name := range setting.envNames() { - if _, ok := env[name]; ok { - cfg.ExplicitFleetEnv[name] = true - } - } - } + // Kept so a fleet record can be layered over it and the whole thing + // re-parsed. Unexported: it is an input, not part of the configuration. + cfg.env = maps.Clone(env) if len(cfg.Scope) == 0 && cfg.GateRepo != "" { cfg.Scope = []string{ownerOf(cfg.GateRepo)} } // Built here, after the command is resolved, because the primary's trigger is // part of describing it. cfg.KnownCoBots = parseAllCoBots(env) - cfg.Reviewers = buildReviewers(cfg.Bot, cfg.ReviewCommand, requiredBots, coBots) + cfg.Reviewers = buildReviewers(cfg.Bot, cfg.ReviewCommand, requiredBots, coBots, false) // The legacy lists are now VIEWS of cfg.Reviewers rather than parallel // parses, so they cannot answer differently from it. An explicit // CRQ_FEEDBACK_BOTS still wins: it is the one list an operator may widen @@ -297,15 +376,57 @@ func LoadConfig() (Config, error) { explicitFeedback := strings.TrimSpace(env["CRQ_FEEDBACK_BOTS"]) != "" cfg.FeedbackBotsExplicit = explicitFeedback if !explicitFeedback { - // Everyone except a primary the operator deliberately left out of - // CRQ_REQUIRED_BOTS. That omission is how you say "do not wait for - // CodeRabbit here", and surfacing its findings anyway would put the round - // back to work over a reviewer nobody asked for. - cfg.FeedbackBots = cfg.reviewerLogins(func(r Reviewer) bool { return r.Required || !r.Metered() }) + // Requiredness decides convergence, not visibility. An optional reviewer + // still ran and its unresolved findings are still work; hiding them made + // the dashboard claim two open threads on a PR where GitHub had seventy. + // PrimaryOff and co-reviewer selection remove reviewers from this list + // structurally, while an explicit CRQ_FEEDBACK_BOTS remains the escape + // hatch for fleets that intentionally want a narrower feed. + cfg.FeedbackBots = cfg.reviewerLogins(func(Reviewer) bool { return true }) + } + if len(cfg.FixModels) > 0 { + cfg.FixModel = cfg.FixModels[0] } return cfg, nil } +func severitySet(raw string) map[string]bool { + out := map[string]bool{} + for _, severity := range strings.Split(raw, ",") { + severity = strings.ToLower(strings.TrimSpace(severity)) + if severity != "" { + out[severity] = true + } + } + return out +} + +func askModeEnv(raw string) string { + switch mode := strings.ToLower(strings.TrimSpace(raw)); mode { + case "uncertain", "ambiguous": + return mode + default: + return "blocked" + } +} + +func configuredModels(ranked, legacy string) []string { + raw := ranked + if strings.TrimSpace(raw) == "" { + raw = legacy + } + seen := map[string]bool{} + out := []string{} + for _, model := range strings.Split(raw, ",") { + model = strings.TrimSpace(model) + if model != "" && !seen[model] { + seen[model] = true + out = append(out, model) + } + } + return out +} + func (c Config) RequireState() error { if c.GateRepo == "" { return errors.New("CRQ_REPO is not set (run 'crq init' or configure ~/.config/crq/env)") @@ -487,7 +608,18 @@ func parseAllCoBots(env map[string]string) []CoBotConfig { func parseCoBots(env map[string]string, requiredBots []string) ([]CoBotConfig, error) { enabled := map[string]bool{} var unknown []string - for _, item := range splitList(stringEnvAllowEmpty(env, "CRQ_COBOTS", "codex,bugbot,macroscope")) { + // Preserve the pre-registry contract: absent means every built-in + // co-reviewer, while explicitly empty disables all. Treating both as empty + // silently disabled working reviewers on upgrade. + items := []string{} + if raw, exists := env["CRQ_COBOTS"]; exists { + items = splitList(raw) + } else { + for _, co := range dialect.KnownCoReviewers() { + items = append(items, co.Name) + } + } + for _, item := range items { co, ok := dialect.CoReviewerByName(item) if !ok { // Refuse rather than skip: silently dropping a typo disables the @@ -671,6 +803,38 @@ func SplitArgv(value string) []string { return argv } +// JoinArgv renders argv as the single string SplitArgv reads back UNCHANGED. +// +// It exists because argv makes a round trip through a service unit's +// environment: an install parses the operator's `--agent-args` into arguments, +// writes them into the unit as one value, and the session splits them again. +// Joining on spaces loses the boundaries — `--config "value with space"` came +// back as three arguments — so every installed session either failed on its +// first flag or ran with the wrong option. +func JoinArgv(argv []string) string { + parts := make([]string, 0, len(argv)) + for _, arg := range argv { + parts = append(parts, quoteArg(arg)) + } + return strings.Join(parts, " ") +} + +// quoteArg wraps one argument so SplitArgv yields it whole. +// +// SplitArgv has no escape character — a quote merely toggles — so an argument +// containing both quote styles is emitted as adjacent quoted runs with nothing +// between them, which SplitArgv concatenates back into one argument. +func quoteArg(arg string) string { + if arg != "" && !strings.ContainsAny(arg, " \t\n\r\"'") { + return arg + } + parts := strings.Split(arg, `"`) + for i, part := range parts { + parts[i] = `"` + part + `"` + } + return strings.Join(parts, `'"'`) +} + // processRun distinguishes this RUN of crq from an earlier one that happened to // get the same pid. Host and pid do not identify a process over time — a // containerized daemon restarts as pid 1, and ordinary pids are reused — so diff --git a/internal/crq/config_test.go b/internal/crq/config_test.go index 97fd5545..4e3c6c58 100644 --- a/internal/crq/config_test.go +++ b/internal/crq/config_test.go @@ -11,6 +11,22 @@ import ( "github.com/kristofferR/coderabbit-queue/internal/engine" ) +func TestBuildConfigRetainsACopyOfTheInputEnvironment(t *testing.T) { + env := map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_BOT": "original-reviewer[bot]", + } + cfg, err := BuildConfig(env) + if err != nil { + t.Fatal(err) + } + + env["CRQ_BOT"] = "mutated-reviewer[bot]" + if got := cfg.WithFleet(FleetDefaults{}).Bot; got != "original-reviewer[bot]" { + t.Fatalf("fleet rebuild used caller-mutated env: bot = %q", got) + } +} + func TestLoadConfigDefaultsToCodeRabbitRequiredBot(t *testing.T) { t.Setenv("CRQ_CONFIG", filepath.Join(t.TempDir(), "missing-env")) t.Setenv("CRQ_REQUIRED_BOTS", "") @@ -46,8 +62,12 @@ func TestLoadConfigFeedbackBotsIncludeCodexByDefault(t *testing.T) { if has(cfg.RequiredBots, "chatgpt-codex-connector[bot]") { t.Fatalf("Codex must not be a required (convergence-gating) bot, got %#v", cfg.RequiredBots) } - if !has(cfg.FeedbackBots, "coderabbitai[bot]") || !has(cfg.FeedbackBots, "chatgpt-codex-connector[bot]") { - t.Fatalf("feedback bots should include CodeRabbit and Codex by default, got %#v", cfg.FeedbackBots) + // The primary and the documented default co-reviewers surface findings. + if !has(cfg.FeedbackBots, "coderabbitai[bot]") { + t.Fatalf("feedback bots should include the primary, got %#v", cfg.FeedbackBots) + } + if !has(cfg.FeedbackBots, "chatgpt-codex-connector[bot]") { + t.Fatalf("default Codex findings must remain enabled across upgrades, got %#v", cfg.FeedbackBots) } } @@ -249,12 +269,35 @@ func TestLoadConfigDispatchMaxAttemptsStaysBounded(t *testing.T) { if err != nil { t.Fatal(err) } - if cfg.DispatchMaxAttempts != 3 { - t.Errorf("DispatchMaxAttempts = %d, want the bounded default 3", cfg.DispatchMaxAttempts) + if cfg.DispatchMaxAttempts != 5 { + t.Errorf("DispatchMaxAttempts = %d, want the bounded default 5", cfg.DispatchMaxAttempts) } }) } } + +func TestBuildConfigRejectsUnknownFixSeverity(t *testing.T) { + _, err := BuildConfig(map[string]string{"CRQ_FIX_SEVERITIES": "major,majro"}) + if err == nil || !strings.Contains(err.Error(), `severity "majro"`) { + t.Fatalf("BuildConfig error = %v, want unknown severity rejected", err) + } +} + +func TestLoadConfigRankedModels(t *testing.T) { + t.Setenv("CRQ_CONFIG", filepath.Join(t.TempDir(), "missing-env")) + t.Setenv("CRQ_FIX_MODELS", "opus, sonnet, opus") + t.Setenv("CRQ_FIX_MODEL", "ignored-legacy") + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if got := strings.Join(cfg.FixModels, ","); got != "opus,sonnet" { + t.Fatalf("models = %q, want ordered and deduplicated", got) + } + if cfg.FixModel != "opus" { + t.Fatalf("preferred model = %q, want the first ranked model", cfg.FixModel) + } +} func TestLoadConfigTidyIsOptIn(t *testing.T) { t.Setenv("CRQ_CONFIG", filepath.Join(t.TempDir(), "missing-env")) t.Setenv("CRQ_TIDY", "restore-after-test") @@ -302,15 +345,27 @@ func TestLoadConfigCoBotsDefaults(t *testing.T) { for _, key := range []string{"CRQ_REQUIRED_BOTS", "CRQ_FEEDBACK_BOTS", "CRQ_COBOTS", "CRQ_CODEX_CMD"} { t.Setenv(key, "") } - os.Unsetenv("CRQ_COBOTS") // "" would disable all; the default is all known + os.Unsetenv("CRQ_COBOTS") os.Unsetenv("CRQ_CODEX_CMD") cfg, err := LoadConfig() if err != nil { t.Fatal(err) } + // Preserve the documented pre-registry default on upgrade. Explicit empty + // below remains the way to disable all. + if len(cfg.CoBots) != len(dialect.KnownCoReviewers()) { + t.Fatalf("default CoBots = %#v, want every built-in co-reviewer", cfg.CoBots) + } + + // Naming one enables it, with the registry's own defaults. + t.Setenv("CRQ_COBOTS", "codex,bugbot,macroscope") + cfg, err = LoadConfig() + if err != nil { + t.Fatal(err) + } if len(cfg.CoBots) != 3 { - t.Fatalf("default CoBots = %#v, want codex+bugbot+macroscope", cfg.CoBots) + t.Fatalf("CoBots = %#v, want the three that were named", cfg.CoBots) } codex, _ := coBotByName(cfg, "codex") if codex.Trigger != engine.TriggerNever || codex.Command != "@codex review" || codex.Required { @@ -318,6 +373,7 @@ func TestLoadConfigCoBotsDefaults(t *testing.T) { } bugbot, _ := coBotByName(cfg, "bugbot") + if bugbot.Trigger != engine.TriggerSelfHeal || bugbot.Command != "bugbot run" || bugbot.SelfHealGrace != 10*time.Minute { t.Fatalf("bugbot defaults wrong: %+v", bugbot) } @@ -416,6 +472,9 @@ func TestLoadConfigRequiredBotsListingEnablesCoBot(t *testing.T) { func TestLoadConfigCoBotCmdAliasesAndOverrides(t *testing.T) { t.Setenv("CRQ_CONFIG", filepath.Join(t.TempDir(), "missing-env")) t.Setenv("CRQ_REQUIRED_BOTS", "") + // Enabled explicitly: per-bot settings describe HOW a co-reviewer runs, not + // whether it does, and a co-reviewer is opt-in. + t.Setenv("CRQ_COBOTS", "codex,bugbot,macroscope") t.Setenv("CRQ_CODEX_CMD", "@codex ship it") t.Setenv("CRQ_COBOT_BUGBOT_TRIGGER", "always") t.Setenv("CRQ_COBOT_MACROSCOPE_CMD", "") @@ -513,6 +572,37 @@ func TestSplitArgvKeepsQuotedArgumentsWhole(t *testing.T) { } } +// argv makes a round trip through the service unit's environment: the install +// parses it, writes it as one value, and the session splits it again. Joining on +// spaces lost the boundaries, so an argument the operator quoted once reached +// the agent as several words — and every installed session ran with the wrong +// option, or died on it. +func TestJoinArgvSurvivesSplitArgv(t *testing.T) { + for _, argv := range [][]string{ + {"--config", "value with space"}, + {"-p", `he said "hi"`, "--dir", "/tmp/with space"}, + {"--mixed", `a"b'c`}, + {"--empty", "", "--after"}, + {"plain", "--flag"}, + } { + joined := JoinArgv(argv) + got := SplitArgv(joined) + if len(got) != len(argv) { + t.Errorf("SplitArgv(JoinArgv(%q)) = %q via %q, want the same arguments", argv, got, joined) + continue + } + for i := range argv { + if got[i] != argv[i] { + t.Errorf("argv[%d] = %q, want %q (joined as %q)", i, got[i], argv[i], joined) + } + } + } + // A word needing no quoting is left alone, so the unit stays readable. + if got := JoinArgv([]string{"--model", "opus"}); got != "--model opus" { + t.Errorf("JoinArgv = %q, want plain words unquoted", got) + } +} + func TestLoadConfigParsesDispatchForksAsABoolean(t *testing.T) { t.Setenv("CRQ_CONFIG", filepath.Join(t.TempDir(), "missing-env")) t.Setenv("CRQ_DISPATCH_FORKS", "false") diff --git a/internal/crq/coreview_replay_test.go b/internal/crq/coreview_replay_test.go index f8af0464..2f9f5664 100644 --- a/internal/crq/coreview_replay_test.go +++ b/internal/crq/coreview_replay_test.go @@ -27,9 +27,12 @@ const ( // an empty environment. Duplicating the defaults here let them drift silently: // a new co-reviewer, or a changed trigger default, would leave replay coverage // asserting a world that no longer exists. +// defaultCoBots is every registry co-reviewer with its own defaults. Named +// explicitly because a co-reviewer is opt-in: these replays are ABOUT the +// co-reviewers, so they enable them the way an operator would. func defaultCoBots(t *testing.T) []CoBotConfig { t.Helper() - co, err := parseCoBots(map[string]string{}, nil) + co, err := parseCoBots(map[string]string{"CRQ_COBOTS": "codex,bugbot,macroscope"}, nil) if err != nil { t.Fatalf("parseCoBots defaults: %v", err) } diff --git a/internal/crq/cost.go b/internal/crq/cost.go new file mode 100644 index 00000000..a17ad28d --- /dev/null +++ b/internal/crq/cost.go @@ -0,0 +1,178 @@ +package crq + +import ( + "context" + "fmt" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/engine" +) + +// CostEstimate is one reviewer's price for one head, as JSON the dashboard and +// any script can read. It mirrors dialect.CostEstimate rather than reusing it so +// the wire shape is owned here, where the rest of the CLI's shapes live. +type CostEstimate struct { + Bot string `json:"bot"` + Low float64 `json:"low"` + High float64 `json:"high"` + // Metered says this review spends CodeRabbit's account allowance. + Metered bool `json:"metered"` + // Exact says low and high are the same figure and it is not a guess. + Exact bool `json:"exact,omitempty"` + // Unknown says crq has no basis to price this reviewer. Renderers must show + // that rather than $0.00, which reads as free. + Unknown bool `json:"unknown,omitempty"` + Basis string `json:"basis"` +} + +// RoundCost is what one more review round on a pull request would cost. +// +// Everything here is an ESTIMATE and the shape says so: a range, a per-reviewer +// breakdown that keeps its own reasoning, and the date the prices behind it were +// last checked against the vendors. A single confident figure would be the one +// output guaranteed to be wrong. +type RoundCost struct { + Repo string `json:"repo"` + PR int `json:"pr"` + Head string `json:"head"` + + Low float64 `json:"low"` + High float64 `json:"high"` + // Exact is true only when every reviewer's figure is exact. + Exact bool `json:"exact,omitempty"` + // Unpriced names reviewers crq could not price at all, so a reader knows the + // total is a floor rather than an answer. + Unpriced []string `json:"unpriced,omitempty"` + + Diff struct { + Additions int `json:"additions"` + Deletions int `json:"deletions"` + ChangedFiles int `json:"changed_files"` + } `json:"diff"` + + Reviewers []CostEstimate `json:"reviewers"` + // Summary is the one line a UI can show without unpacking anything. + Summary string `json:"summary"` + // PricesCheckedAt is when the published figures behind this were verified. + PricesCheckedAt string `json:"prices_checked_at"` + // PricingNote is the vendor-owned billing disclosure from dialect. + PricingNote string `json:"pricing_note"` +} + +// Cost estimates the next round on repo#pr, using the repository's effective +// reviewers and the account's remaining allowance. +// +// It costs one pull-request read: the diff stat comes from the same object crq +// already fetches to learn the head, so this is not a new class of expense — but +// it IS one call per pull request, which is why the overview does not show a +// cost per queue row and this is asked for a PR at a time. +func (s *Service) Cost(ctx context.Context, repo string, pr int) (RoundCost, error) { + repo = NormalizeRepo(repo) + if err := checkRepoShape(repo); err != nil { + return RoundCost{}, err + } + pull, err := s.gh.GetPull(ctx, repo, pr) + if err != nil { + return RoundCost{}, err + } + st, _, err := s.store.Load(ctx) + if err != nil { + return RoundCost{}, err + } + return s.costFrom(st, repo, pr, pull.Head.SHA, dialect.DiffStat{ + Additions: pull.Additions, + Deletions: pull.Deletions, + ChangedFiles: pull.ChangedFiles, + }), nil +} + +// costFrom is the pure half, so a caller that already holds the state and the +// diff stat does not pay for either again. +func (s *Service) costFrom(st State, repo string, pr int, head string, d dialect.DiffStat) RoundCost { + return s.costWith(st, repo, pr, head, d, accountAllowance(st)) +} + +// accountAllowance is what the account last told crq about its included +// reviews. Absent stays absent: RemainingKnown false is "crq has not seen a +// count", which prices differently from having seen a zero. +func accountAllowance(st State) dialect.Allowance { + a := dialect.Allowance{} + if st.Account.Remaining != nil { + a.Remaining, a.RemainingKnown = *st.Account.Remaining, true + } + return a +} + +// costWith prices one head against an allowance the caller supplies, so a +// caller pricing a BACKLOG can spend it down across the pull requests. Pricing +// every one of them against the same unchanged count reported a 20-deep backlog +// as entirely included when the account had one review left. +func (s *Service) costWith(st State, repo string, pr int, head string, d dialect.DiffStat, allowance dialect.Allowance) RoundCost { + cfg := s.cfgFor(st, repo) + out := RoundCost{ + Repo: repo, PR: pr, Head: head, Exact: true, + PricesCheckedAt: dialect.PricesCheckedAt, PricingNote: dialect.PricingDisclosure, + } + out.Diff.Additions, out.Diff.Deletions, out.Diff.ChangedFiles = d.Additions, d.Deletions, d.ChangedFiles + + // Usage-based billing is only ever learned from the CLI's own guidance, and + // crq does not persist it yet — so it stays UNKNOWN rather than "off". The + // two are not interchangeable: "off" is a claim that an exhausted allowance + // costs nothing, and stating it on no evidence would show an account with + // overages enabled "no per-review cost" for a backlog it is about to be + // billed for. Unknown says so instead of guessing. + for _, r := range cfg.Reviewers { + est := dialect.EstimateCost(r.Login, cfg.Bot, d, allowance) + // A non-primary reviewer that crq does not always command may review + // automatically, or may not participate in this round at all. Its + // published price is therefore an upper bound, never a guaranteed + // minimum. + if !est.Metered && r.Trigger != engine.TriggerAlways { + est.Low = 0 + if est.High > 0 { + est.Exact = false + } + est.Basis = "may not participate; " + est.Basis + } + out.Reviewers = append(out.Reviewers, CostEstimate{ + Bot: est.Bot, Low: est.Low, High: est.High, + Metered: est.Metered, Exact: est.Exact, Unknown: est.Unknown, Basis: est.Basis, + }) + if est.Unknown { + out.Unpriced = append(out.Unpriced, r.Login) + out.Exact = false + continue + } + out.Low += est.Low + out.High += est.High + if !est.Exact { + out.Exact = false + } + } + out.Summary = costSummary(out) + return out +} + +// costSummary renders the figure a person reads first. It never rounds an +// uncertainty away: a range stays a range, and an incomplete total says so. +func costSummary(c RoundCost) string { + if len(c.Reviewers) > 0 && len(c.Unpriced) == len(c.Reviewers) { + return fmt.Sprintf("no basis to price %d reviewer(s)", len(c.Unpriced)) + } + var figure string + switch { + case c.Exact && c.High == 0: + figure = "no per-review cost" + case c.Low == c.High: + figure = fmt.Sprintf("about $%.2f", c.High) + default: + figure = fmt.Sprintf("$%.2f–$%.2f", c.Low, c.High) + } + if len(c.Unpriced) > 0 { + return figure + fmt.Sprintf(", plus %d reviewer(s) crq cannot price", len(c.Unpriced)) + } + if c.Exact { + return figure + } + return "estimated " + figure +} diff --git a/internal/crq/cost_test.go b/internal/crq/cost_test.go new file mode 100644 index 00000000..10077103 --- /dev/null +++ b/internal/crq/cost_test.go @@ -0,0 +1,78 @@ +package crq + +import ( + "strings" + "testing" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" +) + +func TestCostSummaryDoesNotCallAnEntirelyUnpricedRoundFree(t *testing.T) { + cost := RoundCost{ + Reviewers: []CostEstimate{{Bot: "unknown", Unknown: true}}, + Unpriced: []string{"unknown"}, + } + if got := costSummary(cost); got != "no basis to price 1 reviewer(s)" { + t.Fatalf("costSummary = %q", got) + } + + cost.Reviewers = append(cost.Reviewers, CostEstimate{Bot: "free", Exact: true}) + if got := costSummary(cost); !strings.Contains(got, "$0.00") { + t.Fatalf("a known free reviewer should keep its figure, got %q", got) + } +} + +func TestCostUsesZeroLowerBoundForConditionalPaidReviewer(t *testing.T) { + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "o/gate", + "CRQ_COBOTS": "macroscope", + }) + if err != nil { + t.Fatal(err) + } + svc := &Service{cfg: cfg} + cost := svc.costWith(DefaultState(cfg), "o/repo", 1, "abcdef123", dialect.DiffStat{ + Additions: 10, ChangedFiles: 1, + }, dialect.Allowance{}) + for _, estimate := range cost.Reviewers { + if !strings.Contains(strings.ToLower(estimate.Bot), "macroscope") { + continue + } + if estimate.Low != 0 || estimate.High <= 0 || estimate.Exact { + t.Fatalf("conditional reviewer estimate = %+v, want a zero-to-price range", estimate) + } + return + } + t.Fatal("macroscope estimate was not included") +} + +func TestCostMarksAllowanceUseByVendorInsteadOfPrimaryRole(t *testing.T) { + codeRabbitCfg, err := BuildConfig(map[string]string{"CRQ_REPO": "o/gate"}) + if err != nil { + t.Fatal(err) + } + codeRabbitCost := (&Service{cfg: codeRabbitCfg}).costWith( + DefaultState(codeRabbitCfg), "o/repo", 1, "abcdef123", + dialect.DiffStat{}, dialect.Allowance{Remaining: 1, RemainingKnown: true}, + ) + if len(codeRabbitCost.Reviewers) == 0 || !codeRabbitCost.Reviewers[0].Metered { + t.Fatalf("CodeRabbit estimate = %+v, want shared allowance marked", codeRabbitCost.Reviewers) + } + + registryCfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "o/gate", + "CRQ_BOT": "chatgpt-codex-connector[bot]", + "CRQ_REVIEW_CMD": "@codex review", + "CRQ_COBOTS": "", + }) + if err != nil { + t.Fatal(err) + } + registryCost := (&Service{cfg: registryCfg}).costWith( + DefaultState(registryCfg), "o/repo", 1, "abcdef123", + dialect.DiffStat{}, dialect.Allowance{Remaining: 1, RemainingKnown: true}, + ) + if len(registryCost.Reviewers) == 0 || registryCost.Reviewers[0].Metered { + t.Fatalf("registry-primary estimate = %+v, want no CodeRabbit allowance use", registryCost.Reviewers) + } +} diff --git a/internal/crq/enrollment.go b/internal/crq/enrollment.go new file mode 100644 index 00000000..d0710f2d --- /dev/null +++ b/internal/crq/enrollment.go @@ -0,0 +1,641 @@ +package crq + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" +) + +// EnrollmentView is one repository's enrollment as it will actually be applied. +type EnrollmentView struct { + Repo string `json:"repo"` + // Source is where the answer comes from, which matters more than the answer: + // a repository forced on by a host's env cannot be turned off from here, and + // saying "managed" would invite someone to try. + // + // state — a record in shared state decided it + // env — this host's CRQ_REPOS lists it, with no record either way + // excluded — this host's CRQ_EXCLUDE names it (absolute; a kill switch) + // scope — no allow-list at all, so everything in CRQ_SCOPE is reviewed + // off — no record, no env mention, and an allow-list that omits it + Source string `json:"source"` + Enabled bool `json:"enabled"` + // EnvConflict says the host's env and the shared record disagree. The record + // wins, but silently overriding a file someone edited is how a fleet grows a + // mystery, so it is reported. + EnvConflict bool `json:"env_conflict,omitempty"` + // ClearEnables says removing this shared record hands the repository to an + // env/scope policy that reviews it. The dashboard previews that clear as an + // enable because it can enqueue and spend against the backlog. + ClearEnables bool `json:"clear_enables,omitempty"` + Reason string `json:"reason,omitempty"` + By string `json:"by,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Lagging []string `json:"lagging_hosts,omitempty"` +} + +// Enrollment reports whether crq reviews repo, and why. +func (s *Service) Enrollment(ctx context.Context, repo string) (EnrollmentView, error) { + repo = NormalizeRepo(repo) + if err := checkRepoShape(repo); err != nil { + return EnrollmentView{}, err + } + st, _, err := s.store.Load(ctx) + if err != nil { + return EnrollmentView{}, err + } + return s.enrollmentOf(st, repo), nil +} + +// enrollmentOf resolves one repository against the record and this host's env. +// +// Precedence, and the reasoning for it: CRQ_EXCLUDE is absolute, because it is a +// per-host kill switch and the machine that has one usually has a reason the +// fleet does not know. Otherwise an explicit record wins in BOTH directions — an +// Off switch that does nothing but explain which file to go and edit on another +// machine is not a switch. Env alone still enrolls, so nothing changes for a +// fleet that never touches this. +func (s *Service) enrollmentOf(st State, repo string) EnrollmentView { + repo = NormalizeRepo(repo) + cfg := s.cfg.WithFleet(st.Fleet) + view := EnrollmentView{Repo: repo} + inEnv := cfg.AllowRepos[repo] + switch { + case cfg.ExcludeRepos[repo]: + view.Source, view.Enabled = "excluded", false + return view + case repo == NormalizeRepo(cfg.GateRepo): + // The gate repository holds the queue's own state and dashboard; + // reviewing it would be crq reviewing its own bookkeeping. + view.Source, view.Enabled = "excluded", false + return view + } + if rec, ok := st.Enrollment(repo); ok { + view.Source, view.Enabled = "state", rec.Enabled + view.Reason, view.By = rec.Reason, rec.By + if rec.UpdatedAt != nil { + view.UpdatedAt = rec.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z") + } + // Only one direction is a disagreement: a record that turns off a + // repository a host's CRQ_REPOS lists. A record that turns one ON that + // env never mentioned is the feature working, not a conflict. + view.EnvConflict = inEnv && !rec.Enabled + view.ClearEnables = !rec.Enabled && (inEnv || len(cfg.AllowRepos) == 0) + // The autofix watcher reads this record too, and it holds neither the + // leader lease nor the fire slot — so an old one went on scanning a + // repository the off switch had just abandoned while the save reported + // no lagging host at all. Autoreview needs no naming here: it scans only + // as leader, which is an identity LaggingWriters already covers. + view.Lagging = st.LaggingRoleWriters(CapsEnrollment, s.clock().UTC(), "autofix") + return view + } + switch { + case inEnv: + view.Source, view.Enabled = "env", true + case len(cfg.AllowRepos) == 0: + view.Source, view.Enabled = "scope", true + default: + view.Source, view.Enabled = "off", false + } + return view +} + +// SetEnrollment records whether crq reviews repo. Turning one off needs a +// reason: the repository disappears from every queue, and "why did this stop +// being reviewed" is a question the fleet should be able to answer itself. +func (s *Service) SetEnrollment(ctx context.Context, repo string, enabled bool, reason string) (EnrollmentView, error) { + return s.SetEnrollmentAt(ctx, repo, enabled, reason, nil) +} + +// SetEnrollmentAt records an enrollment only if the shared state still has the +// revision a dashboard preview described. CLI callers use SetEnrollment and +// keep the ordinary latest-state behavior. +func (s *Service) SetEnrollmentAt(ctx context.Context, repo string, enabled bool, reason string, expectedRev *int64) (EnrollmentView, error) { + repo = NormalizeRepo(repo) + if err := checkRepoShape(repo); err != nil { + return EnrollmentView{}, err + } + if !enabled && strings.TrimSpace(reason) == "" { + return EnrollmentView{}, errors.New("turning a repository off needs --reason (every screen that shows it will show why)") + } + if repo == NormalizeRepo(s.cfg.GateRepo) { + return EnrollmentView{}, fmt.Errorf("%s is the gate repository: it holds crq's own state and dashboard", repo) + } + now := s.clock().UTC() + st, err := s.store.Update(ctx, func(st *State) error { + if expectedRev != nil && st.Rev != *expectedRev { + return fmt.Errorf("fleet state moved from revision %d to %d; preview enrollment again", *expectedRev, st.Rev) + } + if enabled && s.cfg.WithFleet(st.Fleet).ExcludeRepos[repo] { + return fmt.Errorf("%s is in the fleet CRQ_EXCLUDE policy — shared enrollment does not override it", repo) + } + if cur, ok := st.Enrollment(repo); ok && cur.Enabled == enabled && cur.Reason == reason { + return ErrNoChange + } + if !enabled && claimedTriggerRepo(st, repo, now) { + return errors.New("a review trigger is already being posted; wait for it to finish before turning the repository off") + } + // Edited, not rebuilt. A record carries the members a NEWER binary wrote + // inside it, and a fresh value starts with none: constructing one here + // made an older binary's toggle erase the newer setting on its next CAS, + // which is exactly what the tolerant round trip exists to stop. + rec, _ := st.Enrollment(repo) + rec.Enabled, rec.Reason, rec.By, rec.UpdatedAt = enabled, reason, s.cfg.Host, &now + st.SetEnrollment(repo, rec) + if !enabled { + s.abandonPendingRounds(st, repo) + } + return nil + }) + if err != nil && !errors.Is(err, ErrNoChange) { + return EnrollmentView{}, err + } + if err == nil { + s.sync(ctx, st) + } else { + st, _, err = s.store.Load(ctx) + if err != nil { + return EnrollmentView{}, err + } + } + return s.enrollmentOf(st, repo), nil +} + +func claimedTriggerRepo(st *State, repo string, now time.Time) bool { + for _, round := range st.Rounds { + if NormalizeRepo(round.Repo) == repo && triggerPostClaimed(&round) { + return true + } + } + for i := range st.Archive { + round := &st.Archive[i] + if NormalizeRepo(round.Repo) == repo && archivedTriggerPostClaimed(round, now) { + return true + } + } + return false +} + +// An archived round has no live queue owner left to clear a crashed poster's +// claim. Keep the network-race guard for one lease, then let administrative +// changes proceed instead of treating the tombstone as an eternal post. +func archivedTriggerPostClaimed(round *Round, now time.Time) bool { + for _, co := range round.CoBots { + if co.ClaimedAt != nil && now.Before(co.ClaimedAt.UTC().Add(triggerClaimTTL)) { + return true + } + } + return false +} + +// abandonPendingRounds drops the rounds a repository being turned off would +// otherwise still fire. +// +// The off switch is advertised as "crq does not go here", and every SCAN path +// honours it — but Pump chooses from Rounds through NextEligible, which asks +// nothing about enrollment. A repository with a queued or awaiting-retry round +// therefore kept its place in the queue and spent the shared allowance on a +// metered review minutes after being stopped. +// +// Only the phases that have not spent anything are touched. A fired or +// reviewing round already posted its command and holds the fire slot; the money +// is gone and the answer is worth having, so it is left to finish. +func (s *Service) abandonPendingRounds(st *State, repo string) { + for _, round := range st.Rounds { + if NormalizeRepo(round.Repo) != NormalizeRepo(repo) { + continue + } + switch round.Phase { + case PhaseQueued, PhaseAwaitingRetry: + default: + continue + } + // EndRound, not a bare Abandon: it archives the round rather than + // leaving it in Rounds as a "this head was dealt with" marker. Turning + // the repository back on then enqueues its current head again, which is + // what an off switch that can be undone has to mean. + st.EndRound(round.Repo, round.PR, "repository turned off") + releaseSlot(st, QueueKey(round.Repo, round.PR), round.Token) + if s.log != nil { + s.log.Printf("enrollment: dropped queued round %s#%d@%s — the repository was turned off", + round.Repo, round.PR, round.Head) + } + } +} + +// ClearEnrollment drops the record, handing the repository back to the hosts' +// env files. +func (s *Service) ClearEnrollment(ctx context.Context, repo string) (EnrollmentView, error) { + return s.ClearEnrollmentAt(ctx, repo, nil) +} + +// ClearEnrollmentAt binds a backlog preview to the state revision it priced. +// CLI callers omit expectedRev and retain latest-state behavior. +func (s *Service) ClearEnrollmentAt(ctx context.Context, repo string, expectedRev *int64) (EnrollmentView, error) { + repo = NormalizeRepo(repo) + if err := checkRepoShape(repo); err != nil { + return EnrollmentView{}, err + } + now := s.clock().UTC() + st, err := s.store.Update(ctx, func(st *State) error { + if expectedRev != nil && st.Rev != *expectedRev { + return fmt.Errorf("fleet state moved from revision %d to %d; preview enrollment again", *expectedRev, st.Rev) + } + if !st.ClearEnrollment(repo) { + return ErrNoChange + } + // Clearing hands the repository back to this host's env, which may well + // not list it: a record that said ON becomes an effective OFF without + // SetEnrollment ever being called. Pump chooses from Rounds without + // rechecking enrollment, so the queued rounds have to go the same way + // they do when the switch is thrown explicitly — resolved from the state + // the write lands on, not from the one before the clear. + if !s.enrollmentOf(*st, repo).Enabled { + if claimedTriggerRepo(st, repo, now) { + return errors.New("a review trigger is already being posted; wait for it to finish before turning the repository off") + } + s.abandonPendingRounds(st, repo) + } + return nil + }) + if err != nil && !errors.Is(err, ErrNoChange) { + return EnrollmentView{}, err + } + if err == nil { + s.sync(ctx, st) + } else { + st, _, err = s.store.Load(ctx) + if err != nil { + return EnrollmentView{}, err + } + } + return s.enrollmentOf(st, repo), nil +} + +// Enrollments lists every repository this host would act on, plus every one a +// record mentions — including the ones turned off, because an "off" nobody can +// see is how a repository quietly stops being reviewed. +func (s *Service) Enrollments(ctx context.Context) ([]EnrollmentView, error) { + st, _, err := s.store.Load(ctx) + if err != nil { + return nil, err + } + cfg := s.cfg.WithFleet(st.Fleet) + seen := map[string]bool{} + var repos []string + add := func(repo string) { + repo = NormalizeRepo(repo) + if repo == "" || seen[repo] { + return + } + seen[repo] = true + repos = append(repos, repo) + } + for repo := range cfg.AllowRepos { + add(repo) + } + for _, repo := range st.EnrolledRepos() { + add(repo) + } + sort.Strings(repos) + out := make([]EnrollmentView, 0, len(repos)) + for _, repo := range repos { + out = append(out, s.enrollmentOf(st, repo)) + } + return out, nil +} + +// reviewsRepo is the one question every scan path asks: may this host enqueue +// work for repo? Sharing it is what keeps autoreview, watch and autofix from +// each growing their own slightly different answer. +func (s *Service) reviewsRepo(st State, repo string) bool { + return s.enrollmentOf(st, repo).Enabled +} + +// scanTargets is the list a pass should search: every repository enrolled by +// env or by record. scoped reports that there is no allow-list ANYWHERE, which +// is the signal to search CRQ_SCOPE owner-wide as WELL as the returned targets. +// +// The two answers are separate on purpose. An empty list with scoped false is an +// allow-list whose every entry is switched off, which is not the same thing at +// all: searching the owner for it walks the organisation's whole open-PR result +// set only for reviewsRepo to reject every row, spending the shared REST quota +// on a host that has no eligible repository to find. +func (s *Service) scanTargets(st State) (targets []string, scoped bool) { + cfg := s.cfg.WithFleet(st.Fleet) + // An empty CRQ_REPOS means this host searches CRQ_SCOPE owner-wide. Records + // must not narrow that to themselves: enrolling one repository would then + // silently stop every other one from being scanned. + // + // They may still WIDEN it. `crq repos add` accepts any well-shaped + // repository, and one whose owner is outside CRQ_SCOPE is reported as + // enrolled by every screen while no owner-wide search can ever reach it — + // enrolled, counted, and never enqueued. Those are named individually + // alongside the scope search rather than in place of it. + if len(cfg.AllowRepos) == 0 { + return s.enrolledOutsideScope(st), true + } + seen := map[string]bool{} + var out []string + for repo := range cfg.AllowRepos { + if s.reviewsRepo(st, repo) && !seen[repo] { + seen[repo] = true + out = append(out, repo) + } + } + for _, repo := range st.EnrolledRepos() { + if s.reviewsRepo(st, repo) && !seen[repo] { + seen[repo] = true + out = append(out, repo) + } + } + sort.Strings(out) + return out, false +} + +// enrolledOutsideScope names the enabled repositories an owner-wide CRQ_SCOPE +// search cannot reach, so a scope-wide host searches them by name as well. +// +// Only the ones outside the scope: a repository the search already covers would +// be walked twice, and every pull request it found would be examined against +// the same scan budget twice over. +func (s *Service) enrolledOutsideScope(st State) []string { + cfg := s.cfg.WithFleet(st.Fleet) + inScope := map[string]bool{} + for _, owner := range cfg.Scope { + if owner = strings.ToLower(strings.TrimSpace(owner)); owner != "" { + inScope[owner] = true + } + } + var out []string + for _, repo := range st.EnrolledRepos() { + owner, _, _ := strings.Cut(NormalizeRepo(repo), "/") + if inScope[owner] || !s.reviewsRepo(st, repo) { + continue + } + out = append(out, NormalizeRepo(repo)) + } + sort.Strings(out) + return out +} + +// EnrollmentIn answers for an already-loaded state, so a caller rendering many +// repositories does not re-read the ref once per row. +func (s *Service) EnrollmentIn(st State, repo string) EnrollmentView { + return s.enrollmentOf(st, repo) +} + +// scopeRepoLimit bounds the listing per owner. Discovery asks for one sentinel +// row beyond it so exactly 1,000 repositories is not mislabeled as truncated. +const scopeRepoLimit = 1000 + +// ScopeRepos lists the repositories in CRQ_SCOPE, for choosing one to enroll. +// It is the one genuinely expensive read in the dashboard — a multi-page REST +// walk per owner — so callers are expected to cache it. +// +// The second return names the owners whose listing hit the bound, most recently +// pushed first being all that survived it. A picker that silently offered a +// subset was the worse failure: a repository past the bound is perfectly +// eligible for review, and nothing said why it could not be found — so the +// truncation is reported, and `crq repos add /` enrolls one by name +// without any listing at all. +func (s *Service) ScopeRepos(ctx context.Context) ([]ghapi.Repo, []string, error) { + st, _, err := s.store.Load(ctx) + if err != nil { + return nil, nil, err + } + cfg := s.cfg.WithFleet(st.Fleet) + seen := map[string]bool{} + var out []ghapi.Repo + var truncated []string + for _, owner := range cfg.Scope { + owner = strings.TrimSpace(owner) + if owner == "" { + continue + } + repos, err := s.gh.ListOwnerRepos(ctx, owner, scopeRepoLimit+1) + if err != nil { + return nil, nil, err + } + if len(repos) > scopeRepoLimit { + truncated = append(truncated, owner) + repos = repos[:scopeRepoLimit] + } + for _, r := range repos { + key := NormalizeRepo(r.FullName) + if key == "" || seen[key] { + continue + } + seen[key] = true + out = append(out, r) + } + } + return out, truncated, nil +} + +// EnrollImpact is what enrolling a repository would actually do, before it is +// done. +// +// This is the one click in the product that can spend real money: a repository +// with a dozen open pull requests becomes a dozen metered reviews on the next +// pass. The dialog that offers it should say so in the terms the bill arrives +// in, not in "7 pull requests". +type EnrollImpact struct { + Rev int64 `json:"rev"` + Repo string `json:"repo"` + // Open is every open pull request; Eligible is what would actually be + // enqueued once the skip rules are applied. The gap between them is worth + // showing: "12 open, 9 eligible" answers the question the raw count raises. + Open int `json:"open"` + Eligible int `json:"eligible"` + // Skipped explains the gap, per reason. + Skipped map[string]int `json:"skipped,omitempty"` + // Metered is how many of those would spend the shared review allowance. + Metered int `json:"metered"` + // Low/High bound the cost of reviewing the backlog, summed over the + // eligible pull requests. Estimates, with the same honesty as crq cost. + Low float64 `json:"low"` + High float64 `json:"high"` + // Unpriced counts the pull requests whose cost could not be read — a spent + // REST quota, an unreadable diff, or a reviewer crq has no price for. They + // are reported rather than dropped: + // leaving them out of the total makes an unknown price look like a free one, + // which is the one thing this dialog exists to prevent. + Unpriced int `json:"unpriced,omitempty"` + // Unexamined counts the open pull requests this preview stopped short of + // reading. Eligible and the cost below are then floors, not answers, and + // saying so is the same honesty Unpriced exists for. + Unexamined int `json:"unexamined,omitempty"` + Summary string `json:"summary"` + PricesCheckedAt string `json:"prices_checked_at"` +} + +// maxExamined bounds the per-pull-request reads one preview makes. Every +// non-skipped pull request costs a head read and a review list, so a repository +// with hundreds of them would spend hundreds of requests from the same GitHub +// quota the queue runs on — for a dialog somebody is waiting in front of. +// +// The pricing loop below inherits the bound rather than setting its own: it +// prices what was found eligible, and nothing past this can be. +const maxExamined = 25 + +// PreviewEnroll reports what enrolling repo would do. It costs a head read and +// a review list per examined pull request — the same questions the scan asks — +// plus a diff read for each one it prices, which is why it is a separate call +// the dialog makes rather than something every repository row carries, and why +// the examining is bounded by maxExamined. +func (s *Service) PreviewEnroll(ctx context.Context, repo string) (EnrollImpact, error) { + repo = NormalizeRepo(repo) + if err := checkRepoShape(repo); err != nil { + return EnrollImpact{}, err + } + st, _, err := s.store.Load(ctx) + if err != nil { + return EnrollImpact{}, err + } + cfg := s.cfgFor(st, repo) + impact := EnrollImpact{Rev: st.Rev, Repo: repo, Skipped: map[string]int{}, PricesCheckedAt: dialect.PricesCheckedAt} + + var eligible []int + examined := 0 + err = s.gh.EachOpenPR(ctx, repo, true, func(pr ghapi.SearchPR) (bool, error) { + if NormalizeRepo(pr.Repo) != repo { + return false, nil + } + impact.Open++ + switch { + case cfg.SkipAuthors[dialect.NormalizeBotName(strings.ToLower(pr.Author))]: + impact.Skipped["author is skipped"]++ + case cfg.SkipsReview(pr.Body): + impact.Skipped["carries the skip marker"]++ + case examined >= maxExamined: + // Past the bound. The listing already carries the author and body, so + // the two rules above stay free and keep being applied — only the + // reads stop. Counted rather than guessed either way: calling it + // eligible promises a bill crq never checked for, and calling it + // skipped hides one. + impact.Unexamined++ + default: + examined++ + // The scan's own predicate, not just the skip rules. A repository + // being re-enabled keeps its completed rounds, and a pull request + // reviewed outside crq is answered for at its head — counting either + // as newly eligible promised a backlog, and a bill for it, that the + // next auto-review pass deduplicates on sight. Incremental, because + // that is how the daemon runs unless someone passes --no-incremental. + need, _, nerr := s.reviewNeeded(ctx, st, repo, pr.Number, true, noAnnounce) + if nerr != nil { + if ghapi.IsThrottled(nerr) { + return false, nerr + } + if ctx.Err() != nil { + return false, ctx.Err() + } + if !ghapi.IsRecoverableRead(nerr) { + return false, nerr + } + impact.Unexamined++ + return false, nil + } + if !need { + impact.Skipped["already reviewed at head"]++ + return false, nil + } + eligible = append(eligible, pr.Number) + } + return false, nil + }) + if err != nil { + return impact, err + } + impact.Eligible = len(eligible) + + // One read per eligible pull request, for the diff each would be reviewed + // at. Already bounded by maxExamined above — nothing past it was examined, + // so nothing past it can be eligible. + // + // The allowance is spent down as the backlog is priced, because that is what + // enrolling would do to it: every metered review here takes one of the + // account's included reviews, and pricing each pull request against the same + // unchanged count called a whole backlog free on the strength of the one + // review left for its first member. + allowance := accountAllowance(st) + meteredPerPR := 0 + for _, reviewer := range cfg.Reviewers { + if dialect.EstimateCost(reviewer.Login, cfg.Bot, dialect.DiffStat{}, allowance).Metered { + meteredPerPR++ + } + } + spendAllowance := func() { + impact.Metered += meteredPerPR + allowance.Remaining = max(0, allowance.Remaining-meteredPerPR) + } + for _, pr := range eligible { + // costFrom, not Cost: the state is already in hand, and Cost would load + // the ref again per pull request — four requests each, 100 across the + // bound, for an answer that cannot have changed since the load above. + pull, cerr := s.gh.GetPull(ctx, repo, pr) + if cerr != nil { + impact.Unpriced++ + spendAllowance() + continue + } + cost := s.costWith(st, repo, pr, pull.Head.SHA, dialect.DiffStat{ + Additions: pull.Additions, + Deletions: pull.Deletions, + ChangedFiles: pull.ChangedFiles, + }, allowance) + if len(cost.Unpriced) > 0 { + // A reviewer crq cannot price makes this pull request's total a + // floor, not an answer. Adding it in would let the sentence below + // call an unknown price free, which is what it exists to prevent. + impact.Unpriced++ + } + impact.Low += cost.Low + impact.High += cost.High + spendAllowance() + } + impact.Summary = enrollSummary(impact) + return impact, nil +} + +func enrollSummary(i EnrollImpact) string { + if i.Eligible == 0 && i.Unexamined == 0 { + return fmt.Sprintf("%d open pull request(s), none of which would be enqueued", i.Open) + } + // "no per-review cost" is only ever said about a backlog crq actually + // priced. A pull request whose price could not be read is an unknown, and an + // unknown that renders as free is the one way this sentence can mislead + // somebody into spending money. + cost := "no per-review cost" + switch { + case i.High > 0 && i.Low != i.High: + cost = fmt.Sprintf("roughly $%.2f–$%.2f", i.Low, i.High) + case i.High > 0: + cost = fmt.Sprintf("about $%.2f", i.High) + case i.Unpriced > 0: + cost = "a cost crq could not read" + } + count := fmt.Sprint(i.Eligible) + if i.Unexamined > 0 { + count = "at least " + count + } + out := fmt.Sprintf("would enqueue %s of %d open pull request(s) on the next pass — %s", + count, i.Open, cost) + if i.Unpriced > 0 && i.High > 0 { + out += fmt.Sprintf(", plus %d that could not be priced", i.Unpriced) + } + if i.Unexamined > 0 { + // Said plainly, because both numbers above are floors once this is set: + // a reader who takes them for the whole backlog is being told the wrong + // price for the click this dialog is asking them to make. + out += fmt.Sprintf("; %d more were not examined", i.Unexamined) + } + return out +} diff --git a/internal/crq/enrollment_test.go b/internal/crq/enrollment_test.go new file mode 100644 index 00000000..b7d8bb83 --- /dev/null +++ b/internal/crq/enrollment_test.go @@ -0,0 +1,1076 @@ +package crq + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/engine" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" +) + +// TestEnrollmentPrecedence pins the order the whole feature rests on. Getting it +// wrong is not a cosmetic bug: too permissive and crq reviews a repository +// somebody deliberately kept it out of, too strict and the dashboard's Off +// button silently does nothing. +func TestEnrollmentPrecedence(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/allowed": true, "o/fought-over": true} + cfg.ExcludeRepos = map[string]bool{"o/killed": true} + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + + // A repository named by neither list, on a host that HAS an allow-list, is + // off — the allow-list is the whole statement of what this host looks at. + if v, _ := svc.Enrollment(ctx, "o/unknown"); v.Enabled || v.Source != "off" { + t.Errorf("unknown repo = %+v, want off", v) + } + if v, _ := svc.Enrollment(ctx, "o/allowed"); !v.Enabled || v.Source != "env" { + t.Errorf("allowed repo = %+v, want enabled by env", v) + } + if v, _ := svc.Enrollment(ctx, "o/killed"); v.Enabled || v.Source != "excluded" { + t.Errorf("excluded repo = %+v, want excluded", v) + } + if v, _ := svc.Enrollment(ctx, cfg.GateRepo); v.Enabled { + t.Errorf("gate repo = %+v, want excluded: it holds crq's own state", v) + } + + // CRQ_EXCLUDE is a per-host kill switch and shared state does not override + // it, so the write is refused rather than recorded and ignored. + if _, err := svc.SetEnrollment(ctx, "o/killed", true, ""); err == nil { + t.Error("enrolling an env-excluded repo must be refused, not silently recorded") + } + + // Turning one off is the direction that needs a reason, because it makes a + // repository disappear from every queue. + if _, err := svc.SetEnrollment(ctx, "o/fought-over", false, ""); err == nil { + t.Error("removing without a reason must be refused") + } + + // A record beats env in BOTH directions. An Off that only tells you which + // file to edit on another machine is not a switch. + view, err := svc.SetEnrollment(ctx, "o/fought-over", false, "archived") + if err != nil { + t.Fatal(err) + } + if view.Enabled || view.Source != "state" || !view.EnvConflict || !view.ClearEnables { + t.Errorf("view = %+v, want off by record with the env disagreement reported", view) + } + + // And it enrolls a repository env never mentioned — which is not a + // conflict, it is the feature working. + view, err = svc.SetEnrollment(ctx, "o/added", true, "") + if err != nil { + t.Fatal(err) + } + if !view.Enabled || view.EnvConflict { + t.Errorf("view = %+v, want enabled with no conflict reported", view) + } + + st, _, err := svc.store.Load(ctx) + if err != nil { + t.Fatal(err) + } + targets, scoped := svc.scanTargets(st) + if scoped { + t.Error("a host with an allow-list must search by repository, not owner-wide") + } + want := map[string]bool{"o/allowed": true, "o/added": true} + if len(targets) != len(want) { + t.Fatalf("scan targets = %v, want exactly %v", targets, want) + } + for _, repo := range targets { + if !want[repo] { + t.Errorf("scan targets = %v, want %v — a repository turned off must not be searched", targets, want) + } + } + + // default hands it back to env. + if view, err = svc.ClearEnrollment(ctx, "o/fought-over"); err != nil { + t.Fatal(err) + } + if !view.Enabled || view.Source != "env" { + t.Errorf("view = %+v, want the env answer back", view) + } +} + +func TestSetEnrollmentHonorsFleetExcludePolicy(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "o/gate", + "CRQ_HOST": "testhost", + }) + if err != nil { + t.Fatal(err) + } + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.Fleet.Env = map[string]string{"CRQ_EXCLUDE": "o/excluded"} + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if _, err := svc.SetEnrollment(ctx, "o/excluded", true, ""); err == nil { + t.Fatal("enrolling a repository excluded by fleet policy succeeded") + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if _, ok := st.Enrollment("o/excluded"); ok { + t.Fatal("rejected fleet-excluded enrollment persisted a record") + } +} + +func TestSetEnrollmentRejectsInvalidRepositorySlugs(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + for _, repo := range []string{"owner/..", "owner/name?bad", "bad_owner/name", "owner/name/extra"} { + if _, err := svc.SetEnrollment(ctx, repo, true, ""); err == nil { + t.Errorf("SetEnrollment(%q) succeeded, want invalid slug rejected", repo) + } + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if repos := st.EnrolledRepos(); len(repos) != 0 { + t.Fatalf("invalid enrollment records persisted: %v", repos) + } +} + +// A host with no allow-list searches its whole CRQ_SCOPE. Records must not +// narrow that to themselves, or enrolling one repository would silently stop +// every other one from being scanned. +func TestEnrollmentDoesNotNarrowAScopeWideHost(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.Scope = []string{"o"} + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + + if v, _ := svc.Enrollment(ctx, "o/anything"); !v.Enabled || v.Source != "scope" { + t.Errorf("view = %+v, want enabled by scope", v) + } + if _, err := svc.SetEnrollment(ctx, "o/one", true, ""); err != nil { + t.Fatal(err) + } + st, _, err := svc.store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if targets, scoped := svc.scanTargets(st); len(targets) != 0 || !scoped { + t.Errorf("scan targets = %v (scoped %v), want none and scope mode so the pass still searches the whole scope", targets, scoped) + } + + // It must still WIDEN it. A repository enrolled from outside CRQ_SCOPE is + // reported as enrolled by every screen, and no owner-wide search can reach + // it — so it has to be named as a target of its own or it is enrolled, + // counted, and never enqueued. + if _, err := svc.SetEnrollment(ctx, "elsewhere/added", true, ""); err != nil { + t.Fatal(err) + } + if st, _, err = svc.store.Load(ctx); err != nil { + t.Fatal(err) + } + targets, scoped := svc.scanTargets(st) + if !scoped { + t.Error("an out-of-scope enrollment must not turn the scope search off") + } + if len(targets) != 1 || targets[0] != "elsewhere/added" { + t.Errorf("scan targets = %v, want only the out-of-scope enrollment named", targets) + } + // The off direction still works there: the per-PR gate reads the record. + if _, err := svc.SetEnrollment(ctx, "o/noisy", false, "too busy"); err != nil { + t.Fatal(err) + } + st, _, err = svc.store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if svc.reviewsRepo(st, "o/noisy") { + t.Error("a repository turned off must not be reviewed, scope-wide host or not") + } + if !svc.reviewsRepo(st, "o/other") { + t.Error("turning one repository off must not affect the rest of the scope") + } +} + +func TestEnrollmentAndScanUseFleetResolvedPolicy(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_SCOPE": "startup-owner", + "CRQ_REPOS": "startup-owner/old", + }) + if err != nil { + t.Fatal(err) + } + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.Fleet.Env = map[string]string{ + "CRQ_SCOPE": "fleet-owner", + "CRQ_REPOS": "fleet-owner/new,fleet-owner/excluded", + "CRQ_EXCLUDE": "fleet-owner/excluded", + } + return nil + }); err != nil { + t.Fatal(err) + } + gh := newFakeGitHub() + svc := NewService(cfg, gh, store, nil) + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + + if view := svc.enrollmentOf(st, "startup-owner/old"); view.Enabled { + t.Fatalf("startup-only repository remained enabled: %+v", view) + } + if view := svc.enrollmentOf(st, "fleet-owner/excluded"); view.Enabled || view.Source != "excluded" { + t.Fatalf("fleet exclusion was ignored: %+v", view) + } + if _, err := svc.SetEnrollment(ctx, "fleet-owner/excluded", true, ""); err == nil { + t.Fatal("enrolling a fleet-excluded repository must be refused") + } + if st, _, err = store.Load(ctx); err != nil { + t.Fatal(err) + } + if _, ok := st.Enrollment("fleet-owner/excluded"); ok { + t.Fatal("fleet-excluded repository was persisted") + } + if targets, scoped := svc.scanTargets(st); scoped || len(targets) != 1 || targets[0] != "fleet-owner/new" { + t.Fatalf("scan targets = %v scoped=%v, want only the fleet-resolved repository", targets, scoped) + } + enrollments, err := svc.Enrollments(ctx) + if err != nil { + t.Fatal(err) + } + var listed []string + for _, view := range enrollments { + listed = append(listed, view.Repo) + } + if strings.Join(listed, ",") != "fleet-owner/excluded,fleet-owner/new" { + t.Fatalf("enrollments = %v, want the fleet-resolved allow-list", listed) + } + if _, _, err := svc.ScopeRepos(ctx); err != nil { + t.Fatal(err) + } + if strings.Join(gh.owners, ",") != "fleet-owner" { + t.Fatalf("discovery owners = %v, want the fleet-resolved scope", gh.owners) + } +} + +// The record round-trips members a newer binary added, but only if the toggle +// EDITS it. Building a replacement from scratch — which is what every save did — +// erased them on the next CAS, so an older binary flipping a switch silently +// unset whatever setting the newer one had recorded beside it. +func TestTogglingEnrollmentKeepsANewerBinarysMembers(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + // Seeded as a newer binary would have left it. + if _, err := store.Update(ctx, func(st *State) error { + var foreign State + if err := json.Unmarshal([]byte(`{"v":4,"rev":1,"next_seq":1,"account":{"scope":"o"}, + "enrolled":{"o/repo":{"enabled":true,"future_enroll_flag":{"until":"2030-01-01"}}}}`), &foreign); err != nil { + return err + } + st.Enrolled = foreign.Enrolled + return nil + }); err != nil { + t.Fatal(err) + } + + if _, err := svc.SetEnrollment(ctx, "o/repo", false, "paused for the quarter"); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + rec, ok := st.Enrollment("o/repo") + if !ok || rec.Enabled || rec.Reason != "paused for the quarter" { + t.Fatalf("record = %+v, want the switch actually thrown", rec) + } + raw, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + var back map[string]any + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatal(err) + } + enrolled, _ := back["enrolled"].(map[string]any) + own, _ := enrolled["o/repo"].(map[string]any) + carried, _ := own["future_enroll_flag"].(map[string]any) + if carried == nil || carried["until"] != "2030-01-01" { + t.Errorf("toggling the switch erased a member this binary does not know: %#v", own) + } +} + +// Enrolling a repository is the one click in the product that spends money, so +// the dialog's rule is that an unknown price must never read as a free one. A +// per-PR pricing call that fails — a spent REST quota, an unreadable diff — used +// to be skipped silently, and a backlog nothing could be priced for was +// summarised as having "no per-review cost". +func TestEnrollSummaryNeverPricesAnUnknownAsFree(t *testing.T) { + none := enrollSummary(EnrollImpact{Open: 4, Eligible: 4, Unpriced: 4}) + if strings.Contains(none, "no per-review cost") { + t.Errorf("summary = %q, want an unpriced backlog reported as unknown", none) + } + if !strings.Contains(none, "could not") { + t.Errorf("summary = %q, want it to say the cost could not be read", none) + } + // A partly priced backlog states both: the money it knows about, and how + // many pull requests are not in that number. + partial := enrollSummary(EnrollImpact{Open: 4, Eligible: 4, Low: 1, High: 2, Unpriced: 2}) + if !strings.Contains(partial, "$1.00–$2.00") || !strings.Contains(partial, "2 that could not be priced") { + t.Errorf("summary = %q, want the known cost and the unpriced count", partial) + } + // And a fully priced free backlog still says so. + free := enrollSummary(EnrollImpact{Open: 2, Eligible: 2}) + if !strings.Contains(free, "no per-review cost") { + t.Errorf("summary = %q, want a genuinely free backlog unchanged", free) + } +} + +// Turning a repository off has to remove the work already queued for it, not +// merely stop new scans finding more. Pump chooses from Rounds through +// NextEligible, which asks nothing about enrollment — so a queued round kept its +// place and spent the shared allowance on a metered review minutes after +// somebody stopped the repository. +func TestDisablingEnrollmentDropsTheQueuedRounds(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/stopped": true} + gh := newFakeGitHub() + var pull ghapi.Pull + pull.State = "open" + pull.Head.SHA = "abcdef1234567890" + gh.pulls["o/stopped#7"] = pull + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + + if _, err := svc.Enqueue(ctx, "o/stopped", 7); err != nil { + t.Fatal(err) + } + if _, err := svc.SetEnrollment(ctx, "o/stopped", false, "stop reviewing this"); err != nil { + t.Fatal(err) + } + + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round("o/stopped", 7); round != nil { + t.Fatalf("round = %+v, want it archived rather than left fire-eligible", round) + } + if next := st.NextEligible(svc.clock()); next != nil { + t.Errorf("next eligible = %+v, want nothing for a repository that was turned off", next) + } + // The round is archived, never deleted: it says why it stopped. + if len(st.Archive) != 1 || st.Archive[0].Phase != PhaseAbandoned || + !strings.Contains(st.Archive[0].Note, "turned off") { + t.Errorf("archive = %+v, want the round kept with the reason it ended", st.Archive) + } + + // Turning it back on enqueues the head again — an off switch somebody can + // undo has to hand the repository back the way it found it. + if _, err := svc.SetEnrollment(ctx, "o/stopped", true, ""); err != nil { + t.Fatal(err) + } + if _, err := svc.Enqueue(ctx, "o/stopped", 7); err != nil { + t.Fatal(err) + } + st, _, _ = store.Load(ctx) + if round := st.Round("o/stopped", 7); round == nil || round.Phase != PhaseQueued { + t.Errorf("round = %+v, want a fresh queued round once the repository is back on", round) + } +} + +func TestSetEnrollmentPersistsInDryRun(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/stopped": true} + cfg.DryRun = true + store := NewMemoryStore(cfg) + now := time.Date(2026, 7, 29, 8, 0, 0, 0, time.UTC) + if _, err := store.Update(ctx, func(st *State) error { + _, err := st.NewRound("o/stopped", 7, "abcdef123", now) + return err + }); err != nil { + t.Fatal(err) + } + _, revision, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + svc := NewService(cfg, newFakeGitHub(), store, nil) + svc.now = func() time.Time { return now } + + view, err := svc.SetEnrollment(ctx, "o/stopped", false, "stop reviewing") + if err != nil { + t.Fatal(err) + } + if view.Enabled || view.Source != "state" || view.Reason != "stop reviewing" { + t.Fatalf("dry-run enrollment = %+v, want the persisted disabled enrollment", view) + } + + after, afterRevision, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if afterRevision == revision { + t.Fatalf("dry-run did not change state revision: before=%+v after=%+v", revision, afterRevision) + } + if enrollment, ok := after.Enrollment("o/stopped"); !ok || enrollment.Enabled || + enrollment.Reason != "stop reviewing" { + t.Fatalf("dry-run enrollment = %+v (present %v), want the explicit configuration persisted", enrollment, ok) + } + if after.Round("o/stopped", 7) != nil { + t.Fatal("dry-run left the disabled repository's queued round active") + } +} + +func TestDisablingEnrollmentRefusesAnArchivedTriggerClaim(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/stopped": true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + now := time.Now() + if _, err := store.Update(ctx, func(st *State) error { + round, err := st.NewRound("o/stopped", 7, "abcdef123", now) + if err != nil { + return err + } + round.ClaimCo("cursor[bot]", now) + st.PutRound(*round) + _, err = st.Supersede("o/stopped", 7, "fedcba987", now.Add(time.Second)) + return err + }); err != nil { + t.Fatal(err) + } + + if _, err := svc.SetEnrollment(ctx, "o/stopped", false, "stop reviewing this"); err == nil { + t.Fatal("turning the repository off succeeded while an archived trigger claim was still posting") + } +} + +func TestExpiredArchivedTriggerClaimDoesNotBlockEnrollment(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/stopped": true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + now := time.Now().UTC() + svc.now = func() time.Time { return now } + if _, err := store.Update(ctx, func(st *State) error { + round, err := st.NewRound("o/stopped", 7, "abcdef123", now.Add(-triggerClaimTTL-time.Minute)) + if err != nil { + return err + } + round.ClaimCo("cursor[bot]", now.Add(-triggerClaimTTL-time.Second)) + st.PutRound(*round) + _, err = st.Supersede("o/stopped", 7, "fedcba987", now.Add(-time.Minute)) + return err + }); err != nil { + t.Fatal(err) + } + + if _, err := svc.SetEnrollment(ctx, "o/stopped", false, "stop reviewing this"); err != nil { + t.Fatalf("expired archived trigger claim still blocked enrollment: %v", err) + } +} + +func TestArchivedTriggerClaimClearsWhenItsPostFinishes(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/stopped": true} + cfg.CoBots = []CoBotConfig{{Login: "cursor[bot]", Command: "@cursor review"}} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + now := time.Now() + var claimed Round + if _, err := store.Update(ctx, func(st *State) error { + round, err := st.NewRound("o/stopped", 7, "abcdef123", now) + if err != nil { + return err + } + round.ClaimCo("cursor[bot]", now) + st.PutRound(*round) + claimed = *round + _, err = st.Supersede("o/stopped", 7, "fedcba987", now.Add(time.Second)) + return err + }); err != nil { + t.Fatal(err) + } + + svc.fireCoTrigger(ctx, cfg, claimed, "cursor[bot]") + if _, err := svc.SetEnrollment(ctx, "o/stopped", false, "stop reviewing this"); err != nil { + t.Fatalf("turning the repository off after the archived poster finished: %v", err) + } +} + +func TestDisabledEnrollmentDoesNotRetryAnInflightRound(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/stopped": true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + now := time.Now().UTC() + + if _, err := store.Update(ctx, func(st *State) error { + round, err := st.NewRound("o/stopped", 7, "abcdef123", now.Add(-time.Hour)) + if err != nil { + return err + } + round.Phase = PhaseFired + round.FiredAt = &now + st.PutRound(*round) + st.FireSlot = &FireSlot{Key: QueueKey(round.Repo, round.PR), Token: round.Token, Since: now} + return nil + }); err != nil { + t.Fatal(err) + } + if _, err := svc.SetEnrollment(ctx, "o/stopped", false, "stop reviewing this"); err != nil { + t.Fatal(err) + } + + blocked := now.Add(time.Hour) + if _, err := store.Update(ctx, func(st *State) error { + round := st.Round("o/stopped", 7) + return svc.applyTransition(st, round, engine.Transition{ + Outcome: engine.OutRetry, + Reason: dialect.ReasonRateLimited, + RetryAt: blocked, + Blocked: &engine.AccountBlock{Until: blocked}, + }, now, cfg) + }); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round("o/stopped", 7); round != nil { + t.Fatalf("disabled round = %+v, want it archived instead of retryable", round) + } + if len(st.Archive) != 1 || st.Archive[0].Repo != "o/stopped" || + st.Archive[0].PR != 7 || st.Archive[0].Phase != PhaseAbandoned { + t.Fatalf("archive = %+v, want the disabled in-flight round retained as abandoned", st.Archive) + } + if st.Account.BlockedUntil == nil || !st.Account.BlockedUntil.Equal(blocked) { + t.Fatalf("account block = %v, want the purchased response's global quota evidence retained", st.Account.BlockedUntil) + } +} + +func TestClearedEnrollmentDoesNotRetryOutsideTheEnvAllowlist(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/other": true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + now := time.Now().UTC() + + if _, err := svc.SetEnrollment(ctx, "o/adopted", true, ""); err != nil { + t.Fatal(err) + } + if _, err := store.Update(ctx, func(st *State) error { + round, err := st.NewRound("o/adopted", 7, "abcdef123", now.Add(-time.Hour)) + if err != nil { + return err + } + round.Phase = PhaseFired + round.FiredAt = &now + st.PutRound(*round) + st.FireSlot = &FireSlot{Key: QueueKey(round.Repo, round.PR), Token: round.Token, Since: now} + return nil + }); err != nil { + t.Fatal(err) + } + if _, err := svc.ClearEnrollment(ctx, "o/adopted"); err != nil { + t.Fatal(err) + } + + if _, err := store.Update(ctx, func(st *State) error { + round := st.Round("o/adopted", 7) + return svc.applyTransition(st, round, engine.Transition{ + Outcome: engine.OutRetry, + Reason: "review failed", + RetryAt: now.Add(time.Minute), + }, now, cfg) + }); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round("o/adopted", 7); round != nil { + t.Fatalf("cleared round = %+v, want it archived instead of retryable", round) + } + if len(st.Archive) != 1 || st.Archive[0].Phase != PhaseAbandoned { + t.Fatalf("archive = %+v, want the cleared in-flight round retained as abandoned", st.Archive) + } +} + +func TestDisablingEnrollmentRefusesAClaimedTriggerPost(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/stopped": true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + if _, err := store.Update(ctx, func(st *State) error { + round, err := st.NewRound("o/stopped", 7, "abcdef123", time.Now()) + if err != nil { + return err + } + round.Phase = PhaseReserved + st.PutRound(*round) + return nil + }); err != nil { + t.Fatal(err) + } + + if _, err := svc.SetEnrollment(ctx, "o/stopped", false, "stop reviewing this"); err == nil { + t.Fatal("turning the repository off succeeded while its review trigger was being posted") + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if enrollment, ok := st.Enrollment("o/stopped"); ok && !enrollment.Enabled { + t.Fatal("the rejected off action still changed enrollment") + } +} + +// Clearing a record hands the repository back to this host's env, which need +// not list it: a record that said ON becomes an effective OFF without +// SetEnrollment ever being called. Pump asks Rounds, not enrollment, so the +// queued work has to go the same way it does when the switch is thrown. +func TestClearingEnrollmentIntoAnExcludingEnvDropsTheQueuedRounds(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + // Nonempty and WITHOUT o/adopted: the record is the only thing enrolling it. + cfg.AllowRepos = map[string]bool{"o/listed": true} + gh := newFakeGitHub() + var pull ghapi.Pull + pull.State = "open" + pull.Head.SHA = "abcdef1234567890" + gh.pulls["o/adopted#3"] = pull + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + + if _, err := svc.SetEnrollment(ctx, "o/adopted", true, ""); err != nil { + t.Fatal(err) + } + if _, err := svc.Enqueue(ctx, "o/adopted", 3); err != nil { + t.Fatal(err) + } + view, err := svc.ClearEnrollment(ctx, "o/adopted") + if err != nil { + t.Fatal(err) + } + if view.Enabled { + t.Fatalf("view = %+v, want the env's answer, which omits this repository", view) + } + + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round("o/adopted", 3); round != nil { + t.Errorf("round = %+v, want it archived rather than left fire-eligible", round) + } + if next := st.NextEligible(svc.clock()); next != nil { + t.Errorf("next eligible = %+v, want nothing for a repository nothing enrolls now", next) + } +} + +func TestClearingEnrollmentIntoAnExcludingEnvRefusesAClaimedTrigger(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/listed": true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if _, err := svc.SetEnrollment(ctx, "o/adopted", true, ""); err != nil { + t.Fatal(err) + } + if _, err := store.Update(ctx, func(st *State) error { + round, err := st.NewRound("o/adopted", 3, "abcdef123", time.Now()) + if err != nil { + return err + } + round.Phase = PhaseReserved + st.PutRound(*round) + return nil + }); err != nil { + t.Fatal(err) + } + + if _, err := svc.ClearEnrollment(ctx, "o/adopted"); err == nil { + t.Fatal("clear succeeded while its review trigger was being posted") + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if enrollment, ok := st.Enrollment("o/adopted"); !ok || !enrollment.Enabled { + t.Fatalf("rejected clear changed enrollment: %+v (present %v)", enrollment, ok) + } +} + +// The converse: clearing a record for a repository this host's env DOES list +// leaves it enrolled, so its queued work must survive untouched. +func TestClearingEnrollmentBackIntoEnvKeepsTheQueuedRounds(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/listed": true} + gh := newFakeGitHub() + var pull ghapi.Pull + pull.State = "open" + pull.Head.SHA = "abcdef1234567890" + gh.pulls["o/listed#5"] = pull + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + + if _, err := svc.SetEnrollment(ctx, "o/listed", true, ""); err != nil { + t.Fatal(err) + } + if _, err := svc.Enqueue(ctx, "o/listed", 5); err != nil { + t.Fatal(err) + } + if _, err := svc.ClearEnrollment(ctx, "o/listed"); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round("o/listed", 5); round == nil || round.Phase != PhaseQueued { + t.Errorf("round = %+v, want the queued round untouched: env still enrolls this repository", round) + } +} + +// An allow-list with every entry switched off is not the same as no allow-list. +// Treating both as "search CRQ_SCOPE owner-wide" made every pass walk the whole +// organisation's open-PR result set for the per-PR gate to reject each row — +// the shared REST quota spent by a host with nothing left to review. +func TestAPassWithAnAllowListButNoActiveRepositorySearchesNothing(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.Scope = []string{"o"} + cfg.AllowRepos = map[string]bool{"o/listed": true} + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: "o/elsewhere", Number: 1, Title: "t"}} + svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) + + if _, err := svc.SetEnrollment(ctx, "o/listed", false, "archived"); err != nil { + t.Fatal(err) + } + st, _, err := svc.store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if targets, scoped := svc.scanTargets(st); len(targets) != 0 || scoped { + t.Fatalf("scan targets = %v (scoped %v), want none and NOT scope mode", targets, scoped) + } + if err := svc.AutoReview(ctx, AutoOptions{Once: true, Incremental: true}); err != nil { + t.Fatal(err) + } + if gh.searches != 0 { + t.Errorf("searched %d time(s); a host with no eligible repository has nothing to look for", gh.searches) + } +} + +// The off switch abandons a repository's pending rounds, and every SCAN path +// honours it — but Enqueue is the manual path, and Pump asks nothing about +// enrollment. A `crq next` or `crq loop` run afterwards recreated the round and +// spent a metered review on a repository somebody had deliberately stopped. +func TestEnqueueRefusesARepositoryTurnedOff(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + gh := newFakeGitHub() + var pull ghapi.Pull + pull.State = "open" + pull.Head.SHA = "abcdef1234567890" + gh.pulls["o/stopped#7"] = pull + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + + if _, err := svc.SetEnrollment(ctx, "o/stopped", false, "archived"); err != nil { + t.Fatal(err) + } + result, err := svc.Enqueue(ctx, "o/stopped", 7) + if err != nil { + t.Fatal(err) + } + if !result.Held || !strings.Contains(result.Reason, "archived") { + t.Errorf("result = %+v, want it refused with the reason the record carries", result) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round("o/stopped", 7); round != nil { + t.Errorf("round = %+v, want none: a manual enqueue must not undo the off switch", round) + } + + // A repository this host's env simply does not list is NOT turned off. A + // manual run against one is the ordinary way `crq next` is used, and + // refusing it would break every repository outside the fleet's allow-list. + cfg.AllowRepos = map[string]bool{"o/listed": true} + gh.pulls["o/unlisted#8"] = pull + other := NewService(cfg, gh, NewMemoryStore(cfg), nil) + if result, err := other.Enqueue(ctx, "o/unlisted", 8); err != nil || result.Held { + t.Errorf("result = %+v, err = %v, want a manual enqueue on an unlisted repository to work", result, err) + } +} + +// The preview is a dialog somebody is waiting in front of, and each pull +// request it examines costs a head read and a review list from the same GitHub +// quota the queue runs on. Unbounded, opening it for a repository with hundreds +// of open pull requests spent hundreds of requests and left review processing +// throttled — so the examining stops, and what it stopped short of is reported +// rather than silently counted as nothing. +func TestPreviewEnrollBoundsItsPerPullRequestReads(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + gh := newFakeGitHub() + const open = maxExamined + 10 + for i := 1; i <= open; i++ { + gh.searchPRs = append(gh.searchPRs, ghapi.SearchPR{Repo: "o/busy", Number: i, Author: "kristofferR"}) + var pull ghapi.Pull + pull.State = "open" + pull.Number = i + pull.Head.SHA = fmt.Sprintf("%016d", i) + gh.pulls[fakeKey("o/busy", i)] = pull + } + svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) + + impact, err := svc.PreviewEnroll(ctx, "o/busy") + if err != nil { + t.Fatal(err) + } + if impact.Open != open { + t.Errorf("open = %d, want every open pull request still counted (the listing is one search)", impact.Open) + } + if impact.Eligible > maxExamined { + t.Errorf("eligible = %d, want no more than the %d examined", impact.Eligible, maxExamined) + } + if impact.Unexamined != open-maxExamined { + t.Errorf("unexamined = %d, want the %d the bound stopped short of", impact.Unexamined, open-maxExamined) + } + if !strings.Contains(impact.Summary, "at least") || !strings.Contains(impact.Summary, "not examined") { + t.Errorf("summary = %q, want the counts stated as floors", impact.Summary) + } + // The reads themselves are what the bound exists for: an assertion on the + // reported number alone would pass against a preview that read everything + // and then truncated its own answer. + if got := gh.reviewPolls(); got > maxExamined { + t.Errorf("review reads = %d, want at most %d", got, maxExamined) + } +} + +// The preview quotes a price for a BACKLOG, and enrolling spends the account's +// included reviews down as it works through it. Pricing every pull request +// against the same unchanged count told an operator with one review left that a +// whole backlog was included — the one way this dialog can talk somebody into a +// bill it never checked for. +func TestPreviewEnrollSpendsTheAllowanceAcrossTheBacklog(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.Reviewers = buildReviewers(cfg.Bot, cfg.ReviewCommand, cfg.RequiredBots, nil, false) + gh := newFakeGitHub() + for i := 1; i <= 3; i++ { + gh.searchPRs = append(gh.searchPRs, ghapi.SearchPR{Repo: "o/backlog", Number: i, Author: "kristofferR"}) + var pull ghapi.Pull + pull.State = "open" + pull.Number = i + pull.Head.SHA = fmt.Sprintf("%016d", i) + pull.ChangedFiles = 4 + gh.pulls[fakeKey("o/backlog", i)] = pull + } + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + remaining := 1 + st.Account.Remaining = &remaining + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, gh, store, nil) + + impact, err := svc.PreviewEnroll(ctx, "o/backlog") + if err != nil { + t.Fatal(err) + } + if impact.Eligible != 3 || impact.Metered != 3 { + t.Fatalf("impact = %+v, want three eligible metered reviews", impact) + } + // One review left covers the first pull request. The other two are past the + // allowance, and crq has not learned whether usage-based reviews are on — + // so they are unknown rather than free. + if impact.Unpriced != 2 { + t.Errorf("unpriced = %d, want the two beyond the one included review", impact.Unpriced) + } + if !strings.Contains(impact.Summary, "could not") { + t.Errorf("summary = %q, want it to say the price past the allowance is unread", impact.Summary) + } +} + +func TestPreviewEnrollSpendsAllowanceWhenPricingReadFails(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.Reviewers = buildReviewers(cfg.Bot, cfg.ReviewCommand, cfg.RequiredBots, nil, false) + gh := newFakeGitHub() + for i := 1; i <= 2; i++ { + gh.searchPRs = append(gh.searchPRs, ghapi.SearchPR{Repo: "o/backlog", Number: i, Author: "kristofferR"}) + var pull ghapi.Pull + pull.State, pull.Number, pull.Head.SHA = "open", i, fmt.Sprintf("%016d", i) + pull.ChangedFiles = 4 + gh.pulls[fakeKey("o/backlog", i)] = pull + } + // reviewNeeded reads each pull once. Fail only the second read for #1, + // which is the pricing read after eligibility has already been established. + gh.pullErrOnRead[fakeKey("o/backlog", 1)] = 2 + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + remaining := 1 + st.Account.Remaining = &remaining + return nil + }); err != nil { + t.Fatal(err) + } + + impact, err := NewService(cfg, gh, store, nil).PreviewEnroll(ctx, "o/backlog") + if err != nil { + t.Fatal(err) + } + if impact.Metered != 2 || impact.Unpriced != 2 { + t.Fatalf("impact = %+v, want failed #1 to spend the allowance before #2 is priced", impact) + } +} + +func TestScopeReposUsesASentinelBeforeReportingTruncation(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.Scope = []string{"owner"} + gh := newFakeGitHub() + for i := 0; i < scopeRepoLimit; i++ { + gh.ownerRepos = append(gh.ownerRepos, ghapi.Repo{FullName: fmt.Sprintf("owner/repo-%04d", i)}) + } + svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) + + repos, truncated, err := svc.ScopeRepos(ctx) + if err != nil { + t.Fatal(err) + } + if len(repos) != scopeRepoLimit || len(truncated) != 0 { + t.Fatalf("repos=%d truncated=%v, want an exact-limit complete listing", len(repos), truncated) + } + + gh.ownerRepos = append(gh.ownerRepos, ghapi.Repo{FullName: "owner/one-too-many"}) + repos, truncated, err = svc.ScopeRepos(ctx) + if err != nil { + t.Fatal(err) + } + if len(repos) != scopeRepoLimit || len(truncated) != 1 || truncated[0] != "owner" { + t.Fatalf("repos=%d truncated=%v, want the sentinel removed and owner marked", len(repos), truncated) + } +} + +func TestSetEnrollmentAtRejectsAStalePreview(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + impact, err := svc.PreviewEnroll(ctx, "o/new") + if err != nil { + t.Fatal(err) + } + if _, err := store.Update(ctx, func(st *State) error { + st.SetEnrollment("o/other", RepoEnrollment{Enabled: true}) + return nil + }); err != nil { + t.Fatal(err) + } + + if _, err := svc.SetEnrollmentAt(ctx, "o/new", true, "", &impact.Rev); err == nil || + !strings.Contains(err.Error(), "preview enrollment again") { + t.Fatalf("stale enrollment save error = %v, want preview-again refusal", err) + } +} + +func TestClearEnrollmentAtRejectsAStaleEnablingPreview(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/re-enabled": true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + if _, err := svc.SetEnrollment(ctx, "o/re-enabled", false, "paused"); err != nil { + t.Fatal(err) + } + impact, err := svc.PreviewEnroll(ctx, "o/re-enabled") + if err != nil { + t.Fatal(err) + } + if _, err := store.Update(ctx, func(st *State) error { + st.CalibrationIssue++ + return nil + }); err != nil { + t.Fatal(err) + } + if _, err := svc.ClearEnrollmentAt(ctx, "o/re-enabled", &impact.Rev); err == nil || + !strings.Contains(err.Error(), "preview enrollment again") { + t.Fatalf("stale clear error = %v, want preview-again refusal", err) + } + view, err := svc.Enrollment(ctx, "o/re-enabled") + if err != nil { + t.Fatal(err) + } + if view.Enabled || !view.ClearEnables { + t.Fatalf("view = %+v, want the off record retained with its enabling-clear warning", view) + } +} + +func TestPreviewEnrollDoesNotSpendCodeRabbitAllowanceForRegistryPrimary(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "o/gate", + "CRQ_BOT": "chatgpt-codex-connector[bot]", + "CRQ_REVIEW_CMD": "@codex review", + "CRQ_COBOTS": "", + "CRQ_REPOS": "o/repo", + }) + if err != nil { + t.Fatal(err) + } + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: "o/repo", Number: 1, Author: "kristofferR"}} + var pull ghapi.Pull + pull.State, pull.Number, pull.Head.SHA = "open", 1, "abcdef1234567890" + gh.pulls[fakeKey("o/repo", 1)] = pull + svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) + + impact, err := svc.PreviewEnroll(ctx, "o/repo") + if err != nil { + t.Fatal(err) + } + if impact.Eligible != 1 || impact.Metered != 0 { + t.Fatalf("impact = %+v, want one eligible review that spends no CodeRabbit allowance", impact) + } +} diff --git a/internal/crq/envkeys.go b/internal/crq/envkeys.go new file mode 100644 index 00000000..d2fd7bac --- /dev/null +++ b/internal/crq/envkeys.go @@ -0,0 +1,356 @@ +package crq + +import ( + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" +) + +// EnvKey describes one setting: what it means, what shape its value has, and — +// the part that matters — whether it can honestly be recorded for the whole +// fleet or only ever belongs to one machine. +// +// Not every setting is fleet-settable, and pretending otherwise would be worse +// than not offering it. Three kinds are deliberately excluded: +// +// - IDENTITY. The gate repository, the state ref, the dashboard issue. These +// say WHERE the queue lives; a dashboard writing to that ref cannot change +// which ref it is without cutting the branch it is sitting on. +// - MACHINE. Paths, the host name, the fix agent's binary, the workspace +// root. A value that is only true of one filesystem is not a fleet setting, +// and recording one would break every other host. +// - CREDENTIALS. Never in state: the ref is a git branch on a repository +// other people can be given read access to. +type EnvKey struct { + Key string + Kind string // duration | int | bool | text | list + // Group orders the settings page: pacing, review, autofix, reporting. + Group string + Label string + Help string + // PerHost marks a setting that stays this machine's. It is still shown — + // "why can I not change this here" is a fair question — but not editable. + PerHost bool + // Identity marks bootstrap settings that must not be edited at all. + Identity bool + // AllowEmpty means an explicitly recorded empty string changes behaviour + // rather than handing the setting back to each host's environment. + AllowEmpty bool + // ReviewImpact means changing this value can change who reviews or gates a + // pull request. The dashboard previews those writes because they can reopen + // completed rounds and spend the shared allowance. + ReviewImpact bool +} + +// envKeys is every setting the dashboard knows about. Anything absent is not +// offered rather than offered and quietly ignored. +var envKeys = []EnvKey{ + {Key: "CRQ_MIN_INTERVAL", Kind: "duration", Group: "pacing", Label: "Minimum interval", + Help: "Smallest gap between two metered review fires, fleet-wide."}, + {Key: "CRQ_INFLIGHT_TIMEOUT", Kind: "duration", Group: "pacing", Label: "In-flight timeout", + Help: "How long a fired round waits for any bot response before it is retried."}, + {Key: "CRQ_RL_FALLBACK", Kind: "duration", Group: "pacing", Label: "Rate-limit fallback", + Help: "Block window assumed when the bot's \"available in\" cannot be parsed."}, + {Key: "CRQ_CALIBRATE_TTL", Kind: "duration", Group: "pacing", Label: "Calibration freshness", + Help: "How long an account-quota calibration reply remains current."}, + {Key: "CRQ_WEEKLY_LIMIT", Kind: "int", Group: "pacing", Label: "Weekly fair-use limit", + Help: "Reviews per rolling week before the vendor throttles: 60 on Pro, 90 on Pro+. 0 counts without forecasting."}, + {Key: "CRQ_AUTOREVIEW_POLL", Kind: "duration", Group: "pacing", Label: "Auto-review poll", + Help: "How often the daemon scans for pull requests needing a review."}, + {Key: "CRQ_AUTOREVIEW_MAX_SCAN", Kind: "int", Group: "pacing", Label: "Scan budget", + Help: "Most pull requests examined per scope per pass, so one large scope cannot starve the others."}, + {Key: "CRQ_LEADER_TTL", Kind: "duration", Group: "pacing", Label: "Leader lease", + Help: "How long a daemon's claim to drive the queue survives without a heartbeat."}, + + {Key: "CRQ_BOT", Kind: "text", Group: "review", Label: "Primary reviewer", + Help: "The metered reviewer's login. Changing it changes which bot's wording crq reads.", ReviewImpact: true}, + {Key: "CRQ_REVIEW_CMD", Kind: "text", Group: "review", Label: "Review command", + Help: "The comment crq posts to ask the primary for a review.", ReviewImpact: true}, + {Key: "CRQ_COBOTS", Kind: "list", Group: "review", Label: "Co-reviewers", + Help: "Which co-reviewers run. Editable as toggles above; this is the same setting.", AllowEmpty: true, ReviewImpact: true}, + {Key: "CRQ_REQUIRED_BOTS", Kind: "list", Group: "review", Label: "Required reviewers", + Help: "Which reviewers convergence waits for.", ReviewImpact: true}, + {Key: "CRQ_FEEDBACK_BOTS", Kind: "list", Group: "review", Label: "Feedback reviewers", + Help: "Whose findings are surfaced. Defaults to everyone who reviews; widen it to read a bot without waiting for it."}, + {Key: "CRQ_SETTLE", Kind: "duration", Group: "review", Label: "Settle window", + Help: "How long a converged loop keeps watching, so a trailing review wave is caught by crq rather than by you."}, + {Key: "CRQ_FEEDBACK_WAIT_TIMEOUT", Kind: "duration", Group: "review", Label: "Feedback wait", + Help: "How long crq loop waits for a round before reporting a timeout."}, + {Key: "CRQ_RL_CO_DEGRADE", Kind: "bool", Group: "review", Label: "Degrade on block", + Help: "During a quota block, ask the co-reviewers now and keep the primary queued, instead of waiting the block out."}, + {Key: "CRQ_AUTOREVIEW_SKIP_AUTHORS", Kind: "list", Group: "review", Label: "Skip authors", + Help: "Pull-request authors auto-review never enqueues.", AllowEmpty: true}, + {Key: "CRQ_AUTOREVIEW_SKIP_MARKER", Kind: "text", Group: "review", Label: "Skip marker", + Help: "A pull-request body containing this is left alone by fleet auto-review.", AllowEmpty: true}, + + // Per-bot trigger policy. Generated rather than listed, so adding a + // co-reviewer to the registry adds its settings here too — the alternative + // is a catalogue that silently stops covering the fleet it describes. + {Key: "CRQ_WATCH_INTERVAL", Kind: "duration", Group: "autofix", Label: "Watch interval", + Help: "How often the watcher looks for pull requests needing a fix session."}, + {Key: "CRQ_DISPATCH_MAX_ATTEMPTS", Kind: "int", Group: "autofix", Label: "Max attempts", + Help: "Failed code-fix sessions per head before crq stops. Provider outages do not count. Also settable per repository."}, + {Key: "CRQ_DISPATCH_FORKS", Kind: "bool", Group: "autofix", Label: "Fix fork PRs", + Help: "Allow sessions on pull requests from another repository. Off by default."}, + {Key: "CRQ_DISPATCH_CONCURRENCY", Kind: "int", Group: "autofix", Label: "Concurrency", + Help: "Cap on simultaneous fix sessions. 0 means no cap — fixing spends no quota.", + PerHost: true}, + + {Key: "CRQ_TZ", Kind: "text", Group: "reporting", Label: "Timezone", + Help: "How times are rendered in the GitHub issue dashboard."}, + {Key: "CRQ_TIDY", Kind: "bool", Group: "reporting", Label: "Tidy trigger comments", + Help: "Delete crq's own spent trigger comments as rounds progress. Opt-in while older binaries share the ref."}, + + // Shown, never editable here. + {Key: "CRQ_REPO", Kind: "text", Group: "identity", Label: "Gate repository", + Help: "Holds the state ref and the dashboard issue. Changing it is a re-init, not a setting.", Identity: true}, + {Key: "CRQ_STATE_REF", Kind: "text", Group: "identity", Label: "State ref", + Help: "The git ref this queue lives in. A dashboard writing to it cannot move it.", Identity: true}, + {Key: "CRQ_ISSUE", Kind: "int", Group: "identity", Label: "Dashboard issue", + Help: "The GitHub issue the legacy dashboard is rendered into.", Identity: true}, + {Key: "CRQ_SCOPE", Kind: "list", Group: "identity", Label: "Scope", + Help: "Owners searched for pull requests. Enrollment is per repository — see Repos.", Identity: true}, + {Key: "CRQ_REPOS", Kind: "list", Group: "identity", Label: "Allow-list (legacy)", + Help: "This host's repository list. Enrollment records supersede it — see Repos.", PerHost: true}, + {Key: "CRQ_EXCLUDE", Kind: "list", Group: "identity", Label: "Exclude (kill switch)", + Help: "This host refuses to touch these, whatever shared state says.", PerHost: true}, + {Key: "CRQ_HOST", Kind: "text", Group: "identity", Label: "Host name", + Help: "How this machine identifies itself in the state ref.", PerHost: true}, + {Key: "CRQ_WORKSPACE", Kind: "text", Group: "identity", Label: "Workspace root", + Help: "Where this machine keeps its mirrors and worktrees.", PerHost: true}, + {Key: "CRQ_DISPATCH_CMD", Kind: "text", Group: "identity", Label: "Fix command", + Help: "The session script this machine runs. Written by crq autofix install.", PerHost: true}, +} + +// EnvKeys returns the settings catalogue, in display order, including the +// per-co-reviewer trigger settings derived from the registry. +func EnvKeys() []EnvKey { + out := append([]EnvKey(nil), envKeys...) + for _, co := range dialect.KnownCoReviewers() { + up := strings.ToUpper(co.Name) + out = append(out, + EnvKey{Key: "CRQ_COBOT_" + up + "_TRIGGER", Kind: "text", Group: "review", + Label: co.Name + " trigger", + Help: "never (crq stays out of its way) · selfheal (ask only if it has not shown up) · " + + "always (ask on every round).", ReviewImpact: true}, + EnvKey{Key: "CRQ_COBOT_" + up + "_GRACE", Kind: "duration", Group: "review", + Label: co.Name + " self-heal grace", + Help: "How long to wait for " + co.Name + " to review on its own before asking."}, + EnvKey{Key: "CRQ_COBOT_" + up + "_CMD", Kind: "text", Group: "review", + Label: co.Name + " command", + Help: "The comment crq posts to ask " + co.Name + " for a review.", + AllowEmpty: true, ReviewImpact: true}, + ) + } + return out +} + +// envKeyByName indexes the catalogue. +func envKeyByName(key string) (EnvKey, bool) { + for _, k := range EnvKeys() { + if k.Key == key { + return k, true + } + } + return EnvKey{}, false +} + +// fleetSettable reports whether a key may be recorded for the whole fleet. +func fleetSettable(key string) bool { + k, ok := envKeyByName(key) + return ok && !k.PerHost && !k.Identity +} + +// fleetReadable includes the three policies v4 stored in shared state but v5 +// no longer lets an operator write there. Migration must keep honoring the +// recorded answer until it is deliberately removed; SetEnv still uses the +// narrower fleetSettable predicate, so no new shared identity/per-host value +// can be created. +func fleetReadable(key string) bool { + if fleetSettable(key) { + return true + } + switch key { + case "CRQ_SCOPE", "CRQ_REPOS", "CRQ_EXCLUDE": + return true + default: + return false + } +} + +// positiveOnly names the settings whose CONSUMER applies a recorded value only +// when it is greater than zero. +// +// Shape is not enough for these. "0s" parses, and -1 is an integer, so the +// generic check above accepted both — and then every daemon fell back to its +// own startup value while this page went on reporting the saved number as the +// fleet's. A save that cannot come into force has to fail here instead: the +// whole point of the settings page is that what it shows is what is running. +var positiveOnly = map[string]bool{ + "CRQ_INFLIGHT_TIMEOUT": true, + "CRQ_RL_FALLBACK": true, + "CRQ_CALIBRATE_TTL": true, + "CRQ_AUTOREVIEW_POLL": true, + "CRQ_AUTOREVIEW_MAX_SCAN": true, + "CRQ_LEADER_TTL": true, + "CRQ_FEEDBACK_WAIT_TIMEOUT": true, + "CRQ_WATCH_INTERVAL": true, + "CRQ_DISPATCH_MAX_ATTEMPTS": true, +} + +// validateEnvValue checks a value against its key's shape and range, so a +// setting that would fail to parse — or that would parse and then be ignored by +// the consumer it is meant for — is refused at edit time rather than silently +// falling back to a default on every host at once. +func validateEnvValue(key, value string) error { + k, ok := envKeyByName(key) + if !ok { + return fmt.Errorf("%s is not a setting crq knows", key) + } + value = strings.TrimSpace(value) + if value == "" { + // Empty either clears the setting or is the setting's explicit value; + // both shapes are valid, and SetEnv keeps that distinction. + return nil + } + if key == "CRQ_TZ" { + if value == "Local" { + return fmt.Errorf("%s must be an IANA timezone name, not Local", key) + } + if _, err := time.LoadLocation(value); err != nil { + return fmt.Errorf("%s is not a valid IANA timezone: %w", key, err) + } + } + switch k.Kind { + case "duration": + d, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("%s: %w", key, err) + } + if d < 0 { + return fmt.Errorf("%s cannot be negative", key) + } + if d == 0 && positiveOnly[key] { + return fmt.Errorf("%s has to be longer than 0s — a zero is ignored, and every host stays on its own value", key) + } + case "int": + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("%s: %w", key, err) + } + if n < 0 { + return fmt.Errorf("%s cannot be negative", key) + } + if n == 0 && positiveOnly[key] { + return fmt.Errorf("%s has to be greater than 0 — a zero is ignored, and every host stays on its own value", key) + } + case "bool": + if value != "0" && value != "1" { + return fmt.Errorf("%s takes 0 or 1", key) + } + } + if strings.HasSuffix(key, "_TRIGGER") { + switch value { + case "never", "selfheal", "always": + default: + return fmt.Errorf("%s takes never, selfheal or always", key) + } + } + return nil +} + +// EnvSetting is one setting as the dashboard shows it. +type EnvSetting struct { + EnvKey + // Value is what this host will actually use, after the fleet record. + Value string `json:"value"` + // Source is "fleet" when a record decided it, "env" when this host's file + // did, "default" when neither names it. + Source string `json:"source"` + // HostValue is what this host's own environment says, shown when a fleet + // record is overriding it — otherwise the override is invisible. + HostValue string `json:"host_value,omitempty"` +} + +// EnvSettings reports every setting with its effective value and source. +func (s *Service) EnvSettings(st State) []EnvSetting { + host := s.cfg.Env() + effective := s.cfg.WithFleet(st.Fleet) + keys := EnvKeys() + out := make([]EnvSetting, 0, len(keys)) + for _, k := range keys { + hostValue, present := host[k.Key] + set := EnvSetting{EnvKey: k, Value: configValueOf(effective, k.Key), Source: "default"} + if present { + set.Source = "env" + } + // Four settings have a typed home on the record — with their own + // validation and impact preview — so the generic map is not the only + // place to look. Missing that made an adopted setting keep reporting + // "env", which is the exact mislabel this page exists to remove. + if fleet, ok := fleetValueOf(st.Fleet, k.Key); ok { + _ = fleet // Value comes from the parsed effective configuration. + set.HostValue, set.Source = strings.TrimSpace(hostValue), "fleet" + } else if _, ok := st.Fleet.Env[k.Key]; ok && fleetReadable(k.Key) { + set.HostValue, set.Source = strings.TrimSpace(hostValue), "fleet" + } + out = append(out, set) + } + sort.SliceStable(out, func(i, j int) bool { + return groupOrder(out[i].Group) < groupOrder(out[j].Group) + }) + return out +} + +func groupOrder(group string) int { + switch group { + case "pacing": + return 0 + case "review": + return 1 + case "autofix": + return 2 + case "reporting": + return 3 + default: + return 4 + } +} + +// fleetValueOf renders a recorded setting that lives in a typed field rather +// than in the generic map. +func fleetValueOf(fd FleetDefaults, key string) (string, bool) { + switch key { + case "CRQ_COBOTS": + if fd.SetCoBots { + return strings.Join(fd.CoBots, ","), true + } + case "CRQ_REQUIRED_BOTS": + if fd.SetRequired { + return strings.Join(fd.Required, ","), true + } + case "CRQ_MIN_INTERVAL": + if strings.TrimSpace(fd.MinInterval) != "" { + return fd.MinInterval, true + } + case "CRQ_WEEKLY_LIMIT": + if fd.WeeklyLimit != nil { + return strconv.Itoa(*fd.WeeklyLimit), true + } + } + return "", false +} + +// typedEnvKey reports whether a write routes to a typed field, so a +// setting never ends up recorded in two places with one of them shadowed. +func typedEnvKey(key string) bool { + switch key { + case "CRQ_COBOTS", "CRQ_REQUIRED_BOTS", "CRQ_MIN_INTERVAL", "CRQ_WEEKLY_LIMIT": + return true + } + return false +} diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 335dd658..3aaac566 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -90,6 +90,16 @@ type CoReviewerStatus struct { } func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackReport, error) { + return s.feedback(ctx, repo, pr, true) +} + +// FeedbackReadOnly observes a pull request without persisting incidental +// account-quota evidence. It is the dashboard's read-only path. +func (s *Service) FeedbackReadOnly(ctx context.Context, repo string, pr int) (FeedbackReport, error) { + return s.feedback(ctx, repo, pr, false) +} + +func (s *Service) feedback(ctx context.Context, repo string, pr int, persist bool) (FeedbackReport, error) { repo = NormalizeRepo(repo) now := s.clock() st, _, err := s.store.Load(ctx) @@ -115,10 +125,12 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe // away, and the next fire went out inside a window the bot had already // stated. It is the one write on this path, it happens once per notice rather // than once per poll, and all it can do is stop a review. - if updated, err := s.recordObservedBlock(ctx, obs, st, now); err != nil { - return FeedbackReport{}, fmt.Errorf("recording the account block observed on %s: %w", QueueKey(repo, pr), err) - } else if updated != nil { - st = *updated + if persist { + if updated, err := s.recordObservedBlock(ctx, cfg, obs, st, now); err != nil { + return FeedbackReport{}, fmt.Errorf("recording the account block observed on %s: %w", QueueKey(repo, pr), err) + } else if updated != nil { + st = *updated + } } pull := obs.pull head := "" @@ -188,7 +200,7 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe report.CoReviewers = coReviewerStatuses(cfg, obs.eng, verdictCutoff) if why := engine.PrimaryUnavailableReason(obs.eng, cfg.policy(), head); why != "" { report.PrimaryUnavailable = true - report.PrimaryUnavailableReason = s.cfg.Bot + " " + why + report.PrimaryUnavailableReason = cfg.Bot + " " + why } // extractBots is the broader set whose findings we surface — a superset that @@ -218,7 +230,7 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe // before that round belongs to the previous head. Unresolved threads are // still surfaced below across commits, while thread-less body findings // must be re-reported by the current round instead of trapping the loop. - if anchorOK && s.cfg.isConfiguredBot(login) && + if anchorOK && cfg.isConfiguredBot(login) && (head == "" || !strings.HasPrefix(review.CommitID, head)) && !notBefore(review.SubmittedAt, anchorCutoff) { continue @@ -338,10 +350,10 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe } report.Findings = append(report.Findings, dialect.Finding{ Bot: comment.User.Login, - Severity: dialect.SeverityOf(comment.Body), + Severity: dialect.SeverityFor(comment.User.Login, comment.Body), Path: comment.Path, Line: firstPositive(comment.Line, comment.OriginalLine), - Title: dialect.TitleOf(comment.Body), + Title: dialect.ReviewTitleFor(comment.User.Login, comment.Body), Body: strings.TrimSpace(comment.Body), CommentID: comment.ID, ReviewID: comment.PullRequestReviewID, @@ -392,7 +404,7 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe if !dialect.InBots(extractBots, comment.User.Login) { continue } - if s.cr.IsReviewSkipped(comment.Body) && s.cfg.isConfiguredBot(comment.User.Login) && + if s.cr.IsReviewSkipped(comment.Body) && cfg.isConfiguredBot(comment.User.Login) && skipAppliesToHead(comment.Body, head) && !skipPredatesHead(comment, headCutoffOf) { // Checked BEFORE the rate-limit guard below: the skip notice embeds @@ -436,7 +448,7 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe if _, ok := coEventKinds[comment.ID]; ok { continue } - if s.cfg.isConfiguredBot(comment.User.Login) { + if cfg.isConfiguredBot(comment.User.Login) { continue } if dialect.IsNonActionableText(comment.Body) { @@ -447,8 +459,8 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe } report.Findings = append(report.Findings, dialect.Finding{ Bot: comment.User.Login, - Severity: dialect.SeverityOf(comment.Body), - Title: dialect.TitleOf(comment.Body), + Severity: dialect.SeverityFor(comment.User.Login, comment.Body), + Title: dialect.ReviewTitleFor(comment.User.Login, comment.Body), Body: strings.TrimSpace(comment.Body), CommentID: comment.ID, URL: comment.URL, @@ -667,7 +679,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport if allReviewed(report.ReviewedBy) { report.Reason = "all required reviewers finished; address findings, push once, and resolve threads" s.completeWaitRound(ctx, repo, pr, head, report.PrimaryAckPending, &report.config) - } else if report.CodeRabbitDeferred && engine.DoneExceptWithEvidence(report.ReviewedBy, s.cfg.Bot, dialect.CodexBotLogin) { + } else if report.CodeRabbitDeferred && engine.DoneExceptWithEvidence(report.ReviewedBy, report.config.Bot, dialect.CodexBotLogin) { // Degraded round: every required bot except the rate-limited // CodeRabbit has finished. These findings are this round's work — // fixing and pushing is exactly right; the CodeRabbit review stays @@ -702,6 +714,10 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport if settledAt.IsZero() { settledAt = s.clock() } + // report.config, not s.cfg: every setting the wait is paced by is + // fleet-settable, and this loop re-resolves it against the state ref + // on each poll. Reading the startup env would keep an in-flight wait + // running on a cadence the dashboard has already replaced. if report.config.SettleWindow <= 0 || s.clock().Sub(settledAt) >= report.config.SettleWindow { if report.Converged { s.completeWaitRound(ctx, repo, pr, head, report.PrimaryAckPending, &report.config) @@ -748,7 +764,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport // exits promptly, and if Codex never answers the round degrades // gracefully back to riding out the window.) if blockedUntil != nil && !report.CodeRabbitDeferred { - extended := extendDeadlineForBlock(deadline, blockedUntil, now, s.cfg.FeedbackWaitTimeout) + extended := extendDeadlineForBlock(deadline, blockedUntil, now, report.config.FeedbackWaitTimeout) if extended.After(deadline) { deadline = extended s.pushWaitDeadline(ctx, repo, pr, head, deadline) @@ -770,13 +786,13 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport return report, 2, nil } if s.log != nil && time.Since(lastLog) >= 30*time.Second { - activeElapsed := feedbackWaitElapsed(deadline, s.cfg.FeedbackWaitTimeout, now) + activeElapsed := feedbackWaitElapsed(deadline, report.config.FeedbackWaitTimeout, now) if blockedUntil != nil && report.CodeRabbitDeferred { - s.log.Printf("%s#%d degraded to codex-only — coderabbit rate-limited until %s; waiting for codex on %s (%s / %s)", repo, pr, blockedUntil.UTC().Format(time.RFC3339), report.Head, activeElapsed.Round(time.Second), s.cfg.FeedbackWaitTimeout) + s.log.Printf("%s#%d degraded to codex-only — coderabbit rate-limited until %s; waiting for codex on %s (%s / %s)", repo, pr, blockedUntil.UTC().Format(time.RFC3339), report.Head, activeElapsed.Round(time.Second), report.config.FeedbackWaitTimeout) } else if blockedUntil != nil { - s.log.Printf("%s#%d queued — account blocked until %s; waiting, not counting it against the %s review wait (%s active)", repo, pr, blockedUntil.UTC().Format(time.RFC3339), s.cfg.FeedbackWaitTimeout, activeElapsed.Round(time.Second)) + s.log.Printf("%s#%d queued — account blocked until %s; waiting, not counting it against the %s review wait (%s active)", repo, pr, blockedUntil.UTC().Format(time.RFC3339), report.config.FeedbackWaitTimeout, activeElapsed.Round(time.Second)) } else { - s.log.Printf("%s#%d waiting for review feedback on %s — reviewed %s (%s / %s)", repo, pr, report.Head, reviewedSummary(report.ReviewedBy), activeElapsed.Round(time.Second), s.cfg.FeedbackWaitTimeout) + s.log.Printf("%s#%d waiting for review feedback on %s — reviewed %s (%s / %s)", repo, pr, report.Head, reviewedSummary(report.ReviewedBy), activeElapsed.Round(time.Second), report.config.FeedbackWaitTimeout) } lastLog = time.Now() } @@ -813,7 +829,7 @@ func (s *Service) ensureWaitDeadline(ctx context.Context, repo string, pr int, h if r.FiredAt != nil { start = r.FiredAt.UTC() } - dl := start.Add(s.cfg.FeedbackWaitTimeout) + dl := start.Add(s.feedbackWait(*st)) r.WaitDeadline = &dl st.PutRound(*r) changed = true @@ -830,7 +846,7 @@ func (s *Service) ensureWaitDeadline(ctx context.Context, repo string, pr int, h } // The round is no longer a wait (completed/none): synthesize a transient // deadline so the loop still bounds its poll. - return s.clock().Add(s.cfg.FeedbackWaitTimeout), nil + return s.clock().Add(s.feedbackWait(updated)), nil } // pushWaitDeadline moves the fired/reviewing round's wait deadline later (never @@ -864,15 +880,6 @@ func (s *Service) pushWaitDeadline(ctx context.Context, repo string, pr int, hea } } -// inflightTimeout is the in-flight window in force: the fleet's when the caller -// decided from a fleet configuration, this host's when it had none. -func (s *Service) inflightTimeout(cfg *Config) time.Duration { - if cfg != nil { - return cfg.InflightTimeout - } - return s.cfg.InflightTimeout -} - // completeWaitRound ends the wait by completing the fired/reviewing round. The // completed round remains as the "this head was reviewed" dedup marker, so a // subsequent enqueue/needsReview at the same head is deduped rather than re-fired. @@ -898,7 +905,7 @@ func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, he if head != "" && r.Head != head { return ErrNoChange } - if cfg != nil && overrideChanged(st, repo, *cfg) { + if cfg != nil && reviewersChanged(st, repo, *cfg) { return ErrNoChange } if holdUnacked && st.FireSlot != nil && st.FireSlot.Key == QueueKey(repo, pr) { @@ -907,12 +914,18 @@ func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, he // advance that follows archives this round, and the slot would be // released with the command it was taken for still unanswered. So // record the hold on the slot itself, where it survives the supersede. - // Bounded by the in-flight window, the deadline Progress gives up at — - // the FLEET's, revalidated just above, not this host's. A host whose - // local value is shorter would otherwise drop the hold while Progress - // is still waiting on the command, letting another metered review fire. + // Bounded by the in-flight window, the deadline Progress gives up at + // — and read from the SAME resolved configuration Progress uses, not + // from the env this process started with. A fleet that lengthened the + // timeout would otherwise have the slot released here while the + // command was still in flight, and one that shortened it would hold + // the fleet for a window nothing is waiting out. if r.FiredAt != nil { - until := r.FiredAt.UTC().Add(s.inflightTimeout(cfg)) + inflight := s.cfg.InflightTimeout + if cfg != nil { + inflight = cfg.InflightTimeout + } + until := r.FiredAt.UTC().Add(inflight) if until.After(s.clock()) && (st.FireSlot.HoldUntil == nil || st.FireSlot.HoldUntil.Before(until)) { st.HoldSlotUntil(until) changed = true @@ -921,10 +934,11 @@ func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, he } return ErrNoChange } + ownerToken := r.Token if err := r.Complete(); err != nil { return err } - releaseSlot(st, QueueKey(repo, pr), r.Token) + releaseSlot(st, QueueKey(repo, pr), ownerToken) st.PutRound(*r) changed = true return nil @@ -1342,10 +1356,10 @@ func threadFindings(thread reviewThread, bots map[string]struct{}) []dialect.Fin } out = append(out, dialect.Finding{ Bot: comment.Author.Login, - Severity: dialect.SeverityOf(comment.Body), + Severity: dialect.SeverityFor(comment.Author.Login, comment.Body), Path: firstNonEmpty(thread.Path, comment.Path), Line: firstPositive(thread.Line, comment.Line, comment.OriginalLine), - Title: dialect.TitleOf(comment.Body), + Title: dialect.ReviewTitleFor(comment.Author.Login, comment.Body), Body: strings.TrimSpace(comment.Body), ThreadID: thread.ID, CommentID: comment.DatabaseID, @@ -1389,6 +1403,19 @@ func dedupeFindings(in []dialect.Finding, suppressPromptAt, settledStableIDs map for _, finding := range in { finding.Body = strings.TrimSpace(finding.Body) finding.Title = strings.TrimSpace(finding.Title) + labels := dialect.ReviewLabelsFor(finding.Bot, finding.Body) + if labels.Category != "" { + finding.Category = labels.Category + } + if labels.Severity != "" { + finding.Severity = labels.Severity + } + if labels.Scale != "" { + finding.Scale = labels.Scale + } + if labels.Effort != "" { + finding.Effort = labels.Effort + } if !dialect.IsActionableFinding(finding) { continue } diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index a94f1a71..117e7ccb 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -100,9 +100,8 @@ func TestFeedbackReturnsObservedAccountBlockPersistenceFailure(t *testing.T) { } } -func TestFeedbackUsesTheFleetFallbackForAWindowlessAccountBlock(t *testing.T) { +func TestReadOnlyFeedbackDoesNotPersistObservedAccountBlocks(t *testing.T) { cfg := firingConfig() - cfg.RateLimitFallback = 5 * time.Minute now := time.Now().UTC() gh := newFakeGitHub() var pull ghapi.Pull @@ -110,32 +109,19 @@ func TestFeedbackUsesTheFleetFallbackForAWindowlessAccountBlock(t *testing.T) { pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("o/repo", 3)] = pull notice := ghapi.IssueComment{ - ID: 17, Body: "You are rate limited by coderabbit.ai. Please wait before requesting another review.", + ID: 17, Body: "You are rate limited by coderabbit.ai. Reviews available in 3 minutes.", CreatedAt: now, UpdatedAt: now, } notice.User.Login = cfg.Bot gh.comments[fakeKey("o/repo", 3)] = []ghapi.IssueComment{notice} - store := NewMemoryStore(cfg) - if _, err := store.Update(context.Background(), func(st *State) error { - st.SetFleetValue("rate-limit-fallback", "45m") - return nil - }); err != nil { - t.Fatal(err) - } + writeErr := errors.New("read-only feedback attempted a state write") + store := &failNthUpdateStore{StateStore: NewMemoryStore(cfg), n: 1, err: writeErr} svc := NewService(cfg, gh, store, nil) svc.now = func() time.Time { return now } - if _, err := svc.Feedback(context.Background(), "o/repo", 3); err != nil { - t.Fatal(err) - } - st, _, err := store.Load(context.Background()) - if err != nil { - t.Fatal(err) - } - want := now.Add(45 * time.Minute) - if st.Account.BlockedUntil == nil || !st.Account.BlockedUntil.Equal(want) { - t.Fatalf("blocked until = %v, want fleet fallback %s", st.Account.BlockedUntil, want) + if _, err := svc.FeedbackReadOnly(context.Background(), "o/repo", 3); err != nil { + t.Fatalf("read-only feedback failed: %v", err) } } @@ -770,7 +756,8 @@ This boilerplate must not become part of the finding. if finding.Path != "convex/sections/aiCommands.ts" || finding.Line != 2170 { t.Fatalf("location mismatch: %#v", finding) } - if finding.Title != "Query learning history by topic before taking" || finding.Severity != "minor" { + if finding.Title != "Query learning history by topic before taking" || + finding.Severity != "potential" || finding.Scale != "P2" { t.Fatalf("metadata mismatch: %#v", finding) } if finding.Commit != "347388ffd" || finding.Source != "review_body" { @@ -1010,6 +997,9 @@ func TestThreadFindingsSurfacesUnresolvedAcrossCommits(t *testing.T) { if got[0].ThreadID != "PRRT_x" || got[0].Line != 42 { t.Fatalf("finding mismatch: %#v", got[0]) } + if got[0].Title != "Potential issue still unfixed." { + t.Fatalf("finding title = %q, want reviewer markup removed", got[0].Title) + } // Resolved and outdated threads are skipped regardless of commit. if got := threadFindings(mk(true, false, "0000oldcommit"), bots); len(got) != 0 { @@ -2198,6 +2188,47 @@ func TestFeedbackSurfacesSkippedReviewDespiteRateLimitMarker(t *testing.T) { } } +func TestFeedbackUsesTheFleetEditedPrimary(t *testing.T) { + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "o/gate", + "CRQ_HOST": "test", + "CRQ_COBOTS": "", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", + }) + if err != nil { + t.Fatal(err) + } + const primary = "replacement-reviewer[bot]" + body := corpusMessage(t, "coderabbit/review-skipped-too-many-files.md") + gh := newFakeGitHub() + sha := "56150a0423a243224b03f355c3a3ba6941011b5b" + pull := ghapi.Pull{State: "open"} + pull.Head.SHA = sha + gh.pulls[fakeKey("o/repo", 214)] = pull + skip := ghapi.IssueComment{ID: 5074197614, Body: body, + CreatedAt: time.Now().UTC().Add(-time.Hour), UpdatedAt: time.Now().UTC().Add(-time.Minute)} + skip.User.Login = primary + gh.comments[fakeKey("o/repo", 214)] = []ghapi.IssueComment{skip} + + store := NewMemoryStore(cfg) + if _, err := store.Update(context.Background(), func(st *State) error { + st.Fleet = fleetEnvSet(st.Fleet, "CRQ_BOT", primary, false) + return nil + }); err != nil { + t.Fatal(err) + } + rep, err := NewService(cfg, gh, store, nil).Feedback(context.Background(), "o/repo", 214) + if err != nil { + t.Fatal(err) + } + for _, finding := range rep.Findings { + if finding.CommentID == skip.ID { + return + } + } + t.Fatalf("the skip notice from the state-resolved primary was dropped: %#v", rep.Findings) +} + // TestAccountBlockIgnoredWhenPrimaryWillNotReview pins the rule that stopped // agents reasoning about CodeRabbit on repos it never reviews. On a summary-only // round the account block is meaningless: it must not extend the wait deadline, @@ -2401,45 +2432,6 @@ func TestCompleteWaitRoundHoldsAnUnacknowledgedFireSlot(t *testing.T) { } } -// The hold is bounded by the in-flight window Progress gives up at, and that -// window is fleet policy. A host whose own value is shorter would otherwise -// stamp a hold that lapses while Progress is still waiting on the command, -// letting the next pull request spend the allowance alongside it. -func TestHeldFireSlotUsesTheFleetsInflightTimeout(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - cfg.InflightTimeout = time.Minute // this host's own, shorter, answer - store := NewMemoryStore(cfg) - svc := NewService(cfg, newFakeGitHub(), store, nil) - now := time.Now().UTC() - repo, pr, head := "o/r", 6, "aaaaaaaa1" - seedRound(t, store, cfg, repo, pr, head, PhaseFired, now, 12) - if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("inflight-timeout", "2h") - return nil - }); err != nil { - t.Fatal(err) - } - st, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - fleet := svc.cfgFor(st, repo) - - svc.completeWaitRound(ctx, repo, pr, head, true, &fleet) - st, _, err = store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if st.FireSlot == nil || st.FireSlot.HoldUntil == nil { - t.Fatalf("the slot must be held for the unanswered command, got %+v", st.FireSlot) - } - want := now.Add(2 * time.Hour) - if !st.FireSlot.HoldUntil.Equal(want) { - t.Fatalf("hold until = %s, want the fleet's window %s", st.FireSlot.HoldUntil, want) - } -} - // Converging is the loop's signal to push, and the push supersedes the round the // held slot points at. Leaving the round fired is therefore not enough on its // own: the replacement round archives its predecessor, Normalize drops a slot no diff --git a/internal/crq/fixsession.go b/internal/crq/fixsession.go new file mode 100644 index 00000000..8ad07640 --- /dev/null +++ b/internal/crq/fixsession.go @@ -0,0 +1,125 @@ +package crq + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" +) + +// FixSession runs one fix session: it is what `crq watch --dispatch` executes +// per pull request. +// +// It exists so crq is ONE binary. The install used to write two bash scripts — +// a wrapper that started the watcher, and a session script that assembled the +// agent's command line — which meant three things had to agree about the +// configuration, two of them generated text on disk that no test ever ran. A +// setting added here reached a fleet only after every host reinstalled, and +// nothing said which hosts had not. +// +// The environment it reads is set by the dispatcher (watch.go): +// +// CRQ_FIX_AGENT the agent binary, chosen at install time on this machine +// CRQ_FIX_ARGS extra arguments the operator configured +// CRQ_FIX_PROMPT_FILE the instruction file +// CRQ_FIX_MODEL / CRQ_FIX_EFFORT / CRQ_FIX_PROMPT the repository's settings +// +// It execs the agent rather than waiting on it, so the process the watcher +// supervises IS the agent: a killed session kills the agent, and the exit +// status is the agent's own rather than a shell's approximation of it. +func FixSession(ctx context.Context, cfg Config) error { + agent := cfg.fixAgent() + if agent == "" { + return errors.New("no fix agent configured (CRQ_FIX_AGENT); run crq autofix install") + } + resolved, err := exec.LookPath(agent) + if err != nil { + return fmt.Errorf("fix agent %q: %w", agent, err) + } + + prompt, err := fixSessionPrompt() + if err != nil { + return err + } + argv := fixSessionArgv(resolved, prompt, SplitArgv(os.Getenv("CRQ_FIX_ARGS")), + os.Getenv("CRQ_FIX_MODEL"), os.Getenv("CRQ_FIX_EFFORT")) + + // Exec, not run: the watcher is supervising this pid and should be + // supervising the agent. + return syscall.Exec(resolved, argv, os.Environ()) +} + +// fixSessionPrompt is the instruction file with the repository's standing +// addition, when it has one. +func fixSessionPrompt() (string, error) { + path := strings.TrimSpace(os.Getenv("CRQ_FIX_PROMPT_FILE")) + if path == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + path = filepath.Join(home, ".local", "share", "crq", "fix-prompt.txt") + } + body, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("reading the fix prompt %q: %w", path, err) + } + prompt := string(body) + if extra := strings.TrimSpace(os.Getenv("CRQ_FIX_PROMPT")); extra != "" { + prompt += "\n\nAdditional instructions for this repository:\n" + extra + } + if strings.TrimSpace(prompt) == "" { + return "", fmt.Errorf("fix prompt %q is empty", path) + } + return prompt, nil +} + +// fixSessionArgv builds the agent's command line. +// +// An empty model or effort adds no flag at all rather than an empty one: every +// agent rejects an empty value differently and none ignores it, so a session +// would die on its first argument and the fix would silently never happen. +func fixSessionArgv(agent, prompt string, extra []string, model, effort string) []string { + model, effort = strings.TrimSpace(model), strings.TrimSpace(effort) + argv := []string{agent} + + switch filepath.Base(agent) { + case "claude": + // stream-json so the session log fills as it works rather than only at + // the end, where a hung session would leave an empty file. + argv = append(argv, "-p", prompt, + "--permission-mode", "bypassPermissions", + "--output-format", "stream-json", "--verbose") + if model != "" { + argv = append(argv, "--model", model) + } + if effort != "" { + argv = append(argv, "--effort", effort) + } + argv = append(argv, extra...) + case "codex": + // exec is codex's non-interactive form; the prompt is its final + // positional argument. --skip-git-repo-check because the session runs + // in a detached worktree crq created. + argv = append(argv, "exec", "--json", "--skip-git-repo-check", + "--dangerously-bypass-approvals-and-sandbox") + if model != "" { + argv = append(argv, "--model", model) + } + if effort != "" { + argv = append(argv, "-c", `model_reasoning_effort="`+effort+`"`) + } + argv = append(argv, extra...) + argv = append(argv, prompt) + default: + // An unknown executable is a self-contained prompt-taking wrapper. It + // gets no flags invented for it; the environment still reaches it. + argv = append(argv, extra...) + argv = append(argv, prompt) + } + return argv +} diff --git a/internal/crq/fixsession_test.go b/internal/crq/fixsession_test.go new file mode 100644 index 00000000..cf0117f7 --- /dev/null +++ b/internal/crq/fixsession_test.go @@ -0,0 +1,21 @@ +package crq + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFixSessionPromptRejectsWhitespaceOnlyInput(t *testing.T) { + path := filepath.Join(t.TempDir(), "prompt.txt") + if err := os.WriteFile(path, []byte(" \n\t"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("CRQ_FIX_PROMPT_FILE", path) + t.Setenv("CRQ_FIX_PROMPT", " ") + + if _, err := fixSessionPrompt(); err == nil || !strings.Contains(err.Error(), "is empty") { + t.Fatalf("fixSessionPrompt error = %v, want an empty-prompt error", err) + } +} diff --git a/internal/crq/fleetcmd.go b/internal/crq/fleetcmd.go deleted file mode 100644 index cad6120a..00000000 --- a/internal/crq/fleetcmd.go +++ /dev/null @@ -1,565 +0,0 @@ -package crq - -import ( - "context" - "errors" - "fmt" - "net/http" - "sort" - "strings" - - ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" -) - -// FleetSetting is one policy as `crq config` reports it: what the fleet -// records, what this host would use on its own, and which of the two is in -// effect. -type FleetSetting struct { - Key string `json:"key"` - Doc string `json:"doc"` - Env string `json:"env"` - Value string `json:"value"` - Source string `json:"source"` // "fleet" or "host" - // HostValue is what this machine's own environment says, reported only when - // it differs from what is in force. It is the divergence an operator needs - // to see: a setting they changed on this box that the fleet is overriding. - HostValue *string `json:"host_value,omitempty"` - // Error is why a recorded value was not applied, when this binary could not - // read it. - Error string `json:"error,omitempty"` - // Unknown marks a setting the fleet records that this binary has no notion - // of — written by a newer crq. Nothing is in force for it here, so Value and - // Env are empty and only the key is worth reporting. - Unknown bool `json:"unknown,omitempty"` - // Lagging names active queue drivers that cannot enforce fleet policy. - Lagging []string `json:"lagging_hosts,omitempty"` -} - -// FleetConfig reports every fleet setting, in force and as this host has it — -// including the ones only a newer crq knows. Reporting just the keys this binary -// understands would hide exactly the case that matters: a recorded setting this -// process silently ignores, while `crq config` and `crq doctor` call the host -// fully in step with the fleet. -func (s *Service) FleetConfig(ctx context.Context) ([]FleetSetting, error) { - st, _, err := s.store.Load(ctx) - if err != nil { - return nil, err - } - settings := fleetSettings() - effective := s.fleetCfg(st) - lagging := st.LaggingWriters(CapsFleetPolicy, s.clock()) - out := make([]FleetSetting, 0, len(settings)) - for _, key := range FleetKeys() { - setting := settings[key] - item := FleetSetting{ - Key: key, Doc: setting.Doc, Env: setting.Env, - Value: setting.Show(effective), Source: "host", Lagging: lagging, - } - if recorded, ok := st.FleetValue(key); ok { - item.Source = "fleet" - if err := ValidateFleetSetting(key, recorded); err != nil { - item.Source, item.Error = "host", err.Error() - } - } - if host := setting.Show(s.cfg); host != item.Value { - item.HostValue = &host - } - out = append(out, item) - } - for _, key := range st.FleetKeys() { - if _, known := settings[key]; known { - continue - } - out = append(out, FleetSetting{ - Key: key, - Unknown: true, - Source: "host", - Error: "not a setting this crq understands; it is being ignored on this host", - Lagging: lagging, - }) - } - return out, nil -} - -// SetFleetConfig records one setting for every host, refusing a value this -// binary cannot read: the ref is shared, so a value only some hosts can parse -// breaks the fleet from the inside. -func (s *Service) SetFleetConfig(ctx context.Context, key, value string) error { - if err := ValidateFleetSetting(key, value); err != nil { - return err - } - open, err := s.prepareFleetReviewerChange(ctx, key, func(st State) bool { - current, recorded := st.FleetValue(key) - return !recorded || current != value - }) - if err != nil { - return err - } - _, err = s.updateFleet(ctx, func(st *State) error { - if err := s.requireFleetCapableDrivers(st); err != nil { - return err - } - before := s.fleetCfg(*st) - current, recorded := st.FleetValue(key) - if recorded && current == value { - return ErrNoChange - } - st.SetFleetValue(key, value) - change := fleetChange{before: before, after: s.fleetCfg(*st)} - if !recorded { - change.adopted = []string{key} - } - return s.reconcileFleetChange(st, change, open) - }) - return err -} - -// UnsetFleetConfig returns one setting to whatever each host says, reporting -// whether the fleet had an opinion to drop. -// -// A key this binary does not know is refused only when the fleet has not -// recorded it either. Dropping one it HAS recorded is the remedy `crq doctor` -// names for a setting a newer crq wrote and this host ignores, and refusing it -// would leave that key unremovable from every host but the newest — but only -// while no newer binary is driving the queue, see requireNoAdvancedDrivers. -func (s *Service) UnsetFleetConfig(ctx context.Context, key string) (bool, error) { - known := false - if _, ok := fleetSettings()[key]; ok { - known = true - } - open, err := s.prepareFleetReviewerChange(ctx, key, func(st State) bool { - _, recorded := st.FleetValue(key) - return recorded - }) - if err != nil { - return false, err - } - dropped := false - _, err = s.updateFleet(ctx, func(st *State) error { - if _, recorded := st.FleetValue(key); !known && !recorded { - return fmt.Errorf("unknown setting %q", key) - } - if err := s.requireFleetCapableDrivers(st); err != nil { - return err - } - if !known { - if err := s.requireNoAdvancedDrivers(st, key); err != nil { - return err - } - } - before := s.fleetCfg(*st) - dropped = st.UnsetFleetValue(key) - if !dropped { - return ErrNoChange - } - return s.reconcileFleetChange(st, fleetChange{before: before, after: s.fleetCfg(*st)}, open) - }) - return dropped, err -} - -// SeedFleetConfig records this host's settings as the fleet's, for the ones the -// fleet has no answer for yet. -// -// It is how an existing setup adopts this without retyping it: run it once on -// the machine whose configuration is the one you mean. Settings the fleet has -// already recorded are left alone — seeding twice from two machines would -// otherwise make the last one to run the winner, which is the divergence this -// whole mechanism exists to end. -func (s *Service) SeedFleetConfig(ctx context.Context) ([]string, error) { - settings := fleetSettings() - rendered := make(map[string]string, len(settings)) - for _, key := range FleetKeys() { - rendered[key] = settings[key].Show(s.cfg) - } - var seeded []string - initial, _, err := s.store.Load(ctx) - if err != nil { - return nil, err - } - var open map[string]map[int]bool - if seedsReviewers(initial) { - open, err = s.openFleetPRs(ctx, initial) - if err != nil { - return nil, err - } - } - _, err = s.updateFleet(ctx, func(st *State) error { - if err := s.requireFleetCapableDrivers(st); err != nil { - return err - } - before := s.fleetCfg(*st) - seeded = seeded[:0] - for _, key := range FleetKeys() { - if _, ok := st.FleetValue(key); ok { - continue - } - if err := ValidateFleetSetting(key, rendered[key]); err != nil { - return fmt.Errorf("cannot seed %s from this host: %w", key, err) - } - seeded = append(seeded, key) - } - for _, key := range seeded { - st.SetFleetValue(key, rendered[key]) - } - if len(seeded) == 0 { - return ErrNoChange - } - return s.reconcileFleetChange(st, fleetChange{before: before, after: s.fleetCfg(*st), adopted: seeded}, open) - }) - return seeded, err -} - -// updateFleet runs the exact same mutation for a preview and a real update. -// Dry-run differs only at the persistence boundary, so validation, capability -// fences and reconciliation cannot drift from the command it previews. -func (s *Service) updateFleet(ctx context.Context, mutate func(*State) error) (State, error) { - changed := false - apply := func(st *State) error { - err := mutate(st) - if err == nil { - changed = true - } - return err - } - if !s.cfg.DryRun { - st, err := s.store.Update(ctx, apply) - if err == nil && changed { - s.sync(ctx, st) - } - return st, err - } - st, _, err := s.store.Load(ctx) - if err != nil { - return State{}, err - } - err = apply(&st) - if errors.Is(err, ErrNoChange) { - err = nil - } - return st, err -} - -// FleetDivergence lists the settings this host still answers for itself while -// the fleet records them, for `crq doctor`. -// -// A host that quietly disagrees is the failure this is about: everything looks -// healthy on both machines, and a repository is excluded on one and reviewed by -// the other. Naming the variable is the point — the remedy is to delete it from -// this host's environment. -func (s *Service) FleetDivergence(ctx context.Context) ([]string, error) { - items, err := s.FleetConfig(ctx) - if err != nil { - return nil, err - } - var out []string - settings := fleetSettings() - for _, item := range items { - // Every name the host is still feeding this policy from, not just the - // canonical one: a legacy alias left behind is what the host falls back to - // if the fleet key is ever unset. - hostEnv := explicitEnv(s.cfg, settings[item.Key]) - switch { - case item.Unknown: - out = append(out, fmt.Sprintf("%s is recorded for the fleet but is not a setting this crq understands; upgrade crq on this host or run crq config unset %s", - item.Key, item.Key)) - case item.Error != "": - out = append(out, fmt.Sprintf("%s: the fleet's value is one this crq cannot read (%s); using this host's %q", - item.Key, item.Error, item.Value)) - case item.Source == "fleet" && item.HostValue != nil && len(hostEnv) > 0: - out = append(out, fmt.Sprintf("%s is %q for the fleet, but %s is set to %q on this host; remove it or run crq config set %s", - item.Key, item.Value, strings.Join(hostEnv, ", "), *item.HostValue, item.Key)) - case item.Source == "fleet" && len(hostEnv) > 0: - // A host copy that currently AGREES is still worth naming. Nothing - // misbehaves while the fleet records the key — but `crq config unset` - // hands the setting back to whatever each host says, and this host - // then falls back to a value nobody remembers setting, diverging from - // every host without it. That is the same failure, one command later, - // and by then there is no recorded value left to compare against and - // nothing here would report it. - out = append(out, fmt.Sprintf("%s is %q for the fleet and %s is still set to that value on this host; remove it, or unsetting %s silently returns this host to its own copy", - item.Key, item.Value, strings.Join(hostEnv, ", "), item.Key)) - } - } - return out, nil -} - -func (s *Service) requireFleetCapableDrivers(st *State) error { - if lagging := st.LaggingWriters(CapsFleetPolicy, s.clock()); len(lagging) > 0 { - return fmt.Errorf("cannot activate fleet policy while queue drivers lack fleet-policy support: %v", lagging) - } - return nil -} - -// requireNoAdvancedDrivers refuses to drop a setting this binary cannot -// interpret while a newer one is driving the queue. -// -// Removing a recorded key is the remedy `crq doctor` names for a setting a newer -// crq wrote, and it has to keep working — an unremovable key would be worse. But -// the capability fence above only asks for THIS binary's fixed CapsFleetPolicy, -// which a newer driver passes by definition, so nothing else stops an old CLI -// deleting a policy it can neither validate nor reconcile. If that key drives -// reviewers, exclusions or quota, the newer driver sees it vanish without the -// cleanup its removal calls for. The setting's own binary is the one that may -// drop it; here the remedy is to run the unset from an upgraded host. -func (s *Service) requireNoAdvancedDrivers(st *State, key string) error { - if ahead := st.AdvancedWriters(WriterCaps, s.clock()); len(ahead) > 0 { - return fmt.Errorf("cannot unset %q from this crq: it is not a setting this binary understands, and newer queue drivers are running: %v; upgrade crq on this host and unset it there", - key, ahead) - } - return nil -} - -// prepareFleetReviewerChange fetches the open PRs reconciliation needs, for the -// keys that can actually move a round. -// -// Only MEMBERSHIP asks that question. reopenForChangedReviewers compares the -// required and co-reviewer login sets and returns before touching this map when -// they match, and a co-reviewer's command, trigger mode or self-heal grace never -// moves either — so asking for the open PRs of every repository ever recorded in -// Rounds bought nothing, spent a REST lookup per historical repository, and made -// a purely local timing update fail whenever one of them was throttled. -// -// A membership key that is not actually moving asks it just as pointlessly, and -// `changes` is what settles that against the recorded value before anything is -// spent: re-recording the value the fleet already holds, or unsetting a key it -// never held, reconciles nothing, and without this an idempotent command still -// cost a lookup per historical repository and still failed under throttling. The -// recorded value is read again inside the CAS, so a concurrent write landing -// between the two leaves `open` nil rather than wrong — the same degraded case an -// inaccessible repository already produces, where a completed round is marked -// rather than reopened and the next enqueue that finds the PR alive reopens it. -func (s *Service) prepareFleetReviewerChange(ctx context.Context, key string, changes func(State) bool) (map[string]map[int]bool, error) { - if !isReviewerMembershipFleetKey(key) { - return nil, nil - } - st, _, err := s.store.Load(ctx) - if err != nil { - return nil, err - } - if !changes(st) { - return nil, nil - } - return s.openFleetPRs(ctx, st) -} - -// seedsReviewers reports whether seeding would record a reviewer setting the -// fleet has no answer for yet, which is the case where the effective reviewers -// can move and existing rounds have to be reconciled against them. Same -// membership-only question as prepareFleetReviewerChange, for the same reason. -func seedsReviewers(st State) bool { - for _, key := range FleetKeys() { - if !isReviewerMembershipFleetKey(key) { - continue - } - if _, ok := st.FleetValue(key); !ok { - return true - } - } - return false -} - -// fleetChange is one policy mutation as reconciliation sees it: the effective -// configuration before and after it, plus the keys the fleet had no answer for -// until now. -// -// Those adopted keys are why `before` cannot be taken at face value. fleetCfg -// fills an unrecorded setting from THIS host, so the first `crq config set` or -// `crq config seed` of a value this machine was already using renders before and -// after identical — while every other host may have been acting under a -// different one all along. For an adopted key the fleet-wide baseline is -// unknown, and reconciliation has to assume it differed rather than assume it -// matched. -type fleetChange struct { - before, after Config - adopted []string -} - -func (c fleetChange) adopts(key string) bool { - for _, adopted := range c.adopted { - if adopted == key { - return true - } - } - return false -} - -// baseline is the pre-change configuration with each adopted key's host fallback -// removed, so a policy the fleet is only now recording is reconciled as the -// change it is for everyone else. -// -// A reviewer MEMBERSHIP key drops the whole reviewer set: another host may have -// completed a head without a bot the adoption makes fleet-required, and that -// host's completed round is the dedup marker that would keep the newly required -// bot from ever being triggered. It is conservative, not expensive — a reopened -// round the primary already answered dedupes at DecideFire's already-reviewed -// gate instead of buying a second review, and a repository that pins its own -// reviewers still decides its effective set, so its rounds compare equal and are -// left alone. Adopting `exclude` likewise drops the baseline exclusions, so a -// repository this host already skipped still has to pass the claimed-trigger -// refusal that the rest of the fleet never applied to it. -// -// The per-bot keys are deliberately not in that set. Adopting a co-reviewer's -// command, trigger mode or self-heal grace moves nobody in or out of the -// reviewer set, and reconciliation compares membership — so erasing the baseline -// for one would reopen every completed round in the fleet and force a self-heal -// trigger post on every open PR, for a timing value. Ordinary updates to the -// same key reopen nothing, and adoption has no more to reconcile than they do. -func (c fleetChange) baseline() Config { - before := c.before - for _, key := range c.adopted { - if isReviewerMembershipFleetKey(key) { - before.RequiredBots, before.CoBots, before.Reviewers = nil, nil, nil - } - if key == "exclude" { - before.ExcludeRepos = nil - } - } - return before -} - -func (s *Service) openFleetPRs(ctx context.Context, st State) (map[string]map[int]bool, error) { - repos := map[string]bool{} - for _, round := range st.Rounds { - repos[NormalizeRepo(round.Repo)] = true - } - open := make(map[string]map[int]bool, len(repos)) - for repo := range repos { - pulls, err := s.openPRs(ctx, repo) - if err != nil { - if !inaccessibleRepoLookup(err) { - return nil, err - } - // Completed rounds are permanent dedup markers, so repositories that - // were deleted or became inaccessible remain in state indefinitely. - // Treat their PRs as closed for this reconciliation: the rounds are - // marked and will reopen if a later enqueue proves the PR is live. - if s.log != nil { - s.log.Printf("warning: reviewer change could not inspect historical repository %s: %v", repo, err) - } - continue - } - open[repo] = pulls - } - return open, nil -} - -func inaccessibleRepoLookup(err error) bool { - if ghapi.IsThrottled(err) { - return false - } - if errors.Is(err, ghapi.ErrNotFound) { - return true - } - var apiErr *ghapi.APIError - return errors.As(err, &apiErr) && apiErr.Status == http.StatusForbidden -} - -// reconcileFleetChange applies what a policy change invalidates — and refuses -// the change outright when there is a network effect it cannot take back. -func (s *Service) reconcileFleetChange(st *State, change fleetChange, open map[string]map[int]bool) error { - before, after := change.baseline(), change.after - if err := checkExcludedTriggerPosts(st, before, after); err != nil { - return err - } - if fleetScopeMoved(st, change) { - st.Account = AccountQuota{ - Scope: strings.Join(after.Scope, ","), - Source: "fleet scope changed", - } - } - for _, round := range st.Rounds { - if round.Phase != PhaseQueued && round.Phase != PhaseAwaitingRetry { - continue - } - if !after.ExcludeRepos[NormalizeRepo(round.Repo)] { - continue - } - // A queued round holds no slot, so this releases one only when the round - // it retires is the one that took it — which it never is here. An orphaned - // hold at the same key stands for a metered command a previous round - // posted and is left alone: excluding a repository stops the next review, - // it does not answer the one already in flight. - st.EndRound(round.Repo, round.PR, "repository excluded by fleet policy") - releaseSlot(st, QueueKey(round.Repo, round.PR), round.Token) - } - s.reopenForFleetReviewerChange(st, before, after, open) - return nil -} - -// checkExcludedTriggerPosts refuses an exclusion that would race a trigger post -// already claimed for the repository being excluded. -// -// The reservation is a fire's commit point, but the command goes to GitHub -// after it: a round left reserved here is one whose post is already authorized, -// so retiring it would not stop the excluded repository receiving a review -// command — it would only destroy the record of quota the account is about to -// be charged for. As with a hold, the claim and this write are both CAS, which -// makes either ordering safe: an existing claim rejects the exclusion, and an -// exclusion recorded first stops the next fire. -func checkExcludedTriggerPosts(st *State, before, after Config) error { - var blocked []string - for _, round := range st.Rounds { - repo := NormalizeRepo(round.Repo) - if !after.ExcludeRepos[repo] || before.ExcludeRepos[repo] { - continue - } - if triggerPostClaimed(&round) { - blocked = append(blocked, QueueKey(round.Repo, round.PR)) - } - } - if len(blocked) == 0 { - return nil - } - sort.Strings(blocked) - return fmt.Errorf("a review trigger is already being posted for %s; wait for it to finish before excluding it", - strings.Join(blocked, ", ")) -} - -// fleetScopeMoved reports whether the recorded account quota belongs to an -// account other than the one the fleet now scans — which is what makes it -// meaningless rather than merely stale. -// -// Ordinarily that is just "did the scope change". Adopting the scope needs the -// other question: fleetCfg fills an unrecorded setting from THIS host, so the -// first `crq config set scope` compares equal to itself even when the fleet was -// scanning something else entirely. The quota's own recorded scope is the one -// piece of that account's identity that survived, so ask it instead. -func fleetScopeMoved(st *State, change fleetChange) bool { - if change.adopts("scope") { - return !sameFoldedSet(splitList(st.Account.Scope), change.after.Scope) - } - return !sameFoldedSet(change.before.Scope, change.after.Scope) -} - -func sameFoldedSet(a, b []string) bool { - if len(a) != len(b) { - return false - } - seen := make(map[string]int, len(a)) - for _, value := range a { - seen[strings.ToLower(strings.TrimSpace(value))]++ - } - for _, value := range b { - key := strings.ToLower(strings.TrimSpace(value)) - seen[key]-- - if seen[key] < 0 { - return false - } - } - return true -} - -// reopenForFleetReviewerChange requeues what a fleet reviewer change invalidates. -// Which rounds those are is decided per repository, against that repository's -// override — the fleet's required set is only half of the effective one, and a -// co-reviewer the fleet enables or disables moves it just as much. -func (s *Service) reopenForFleetReviewerChange(st *State, before, after Config, open map[string]map[int]bool) { - repos := map[string]bool{} - for _, round := range st.Rounds { - repos[NormalizeRepo(round.Repo)] = true - } - for repo := range repos { - ov, _ := st.RepoOverride(repo) - s.reopenForChangedReviewers(st, repo, before.ForRepo(ov), after.ForRepo(ov), open[repo]) - } -} diff --git a/internal/crq/fleetconfig.go b/internal/crq/fleetconfig.go deleted file mode 100644 index 04a9153c..00000000 --- a/internal/crq/fleetconfig.go +++ /dev/null @@ -1,663 +0,0 @@ -package crq - -import ( - "fmt" - "sort" - "strconv" - "strings" - "time" - - "github.com/kristofferR/coderabbit-queue/internal/dialect" - "github.com/kristofferR/coderabbit-queue/internal/engine" -) - -// fleetSetting is one policy the whole fleet shares: how to read it from the -// state ref onto a Config, and how to render what a host is currently using. -// -// The registry below is the definition of "fleet policy". A setting listed here -// stops being something each machine answers for itself; one that is not listed -// stays local, and the split is deliberate — see FleetKeys. -type fleetSetting struct { - // Doc is what the setting means, for `crq config`. - Doc string - // Env names the variable it used to come from, so an operator can find it. - Env string - // AltEnv names the other variables a host may still feed this policy from: - // legacy aliases and per-bot keys. A host exporting one of these diverges - // from the fleet exactly as the canonical name does, and `crq doctor` has to - // say so — otherwise the stale setting stays, invisible, and the host falls - // back to it the moment the fleet key is unset. - AltEnv []string - // Apply writes value onto cfg, rejecting a value it cannot parse. A bad - // value must fail where it is SET, but it is validated here too: the state - // ref is shared, and a host reading a value it cannot use has to say so - // rather than silently keep its own. - Apply func(cfg *Config, value string) error - // Show renders what cfg is currently using, for comparison. - Show func(cfg Config) string -} - -// fleetSettings is every setting that belongs to the fleet rather than to a -// machine. -// -// What is NOT here matters as much. Three kinds stay local: where the state -// lives (CRQ_REPO, CRQ_ISSUE, CRQ_STATE_REF — a host cannot read fleet config -// until it knows where to look), credentials, and what this machine can -// physically do (which fix agent is installed, where its disk is, how many -// sessions it can take). Everything else is policy, and policy the hosts -// disagree about is how a repository ends up excluded on one and reviewed by -// another with nothing to say so. -// -// The primary reviewer is the one policy deliberately left out, and it is a -// FOURTH kind rather than an oversight: who the primary is (CRQ_BOT) is -// inseparable from the wording crq reads it by (CRQ_REVIEW_CMD and the -// completion, rate-limit, review-done and calibration markers), and those are -// compiled into the dialect classifiers when the Service is constructed — -// before any state ref has been read. Recording the login and its command while -// the markers stayed per-host would make a host post the fleet's command for a -// bot it classifies with its own wording, which is the half-applied policy -// applyFleet refuses everywhere else. Centralizing it means moving the whole -// family and building the classifiers from the fleet-applied configuration, not -// adding two entries here. -func fleetSettings() map[string]fleetSetting { - settings := map[string]fleetSetting{ - "scope": { - Doc: "owners crq scans when no repository list is set", - Env: "CRQ_SCOPE", - Apply: func(cfg *Config, v string) error { - owners, err := fleetOwners(v) - cfg.Scope = owners - return err - }, - Show: func(cfg Config) string { return strings.Join(cfg.Scope, ",") }, - }, - "repos": { - Doc: "the repositories crq reviews and watches", - Env: "CRQ_REPOS", - Apply: func(cfg *Config, v string) error { - repos, err := fleetRepoSet(v) - cfg.AllowRepos = repos - return err - }, - Show: func(cfg Config) string { return strings.Join(sortedSetKeys(cfg.AllowRepos), ",") }, - }, - "exclude": { - Doc: "repositories crq never reviews, watches or fixes", - Env: "CRQ_EXCLUDE", - Apply: func(cfg *Config, v string) error { - repos, err := fleetRepoSet(v) - cfg.ExcludeRepos = repos - return err - }, - Show: func(cfg Config) string { return strings.Join(sortedSetKeys(cfg.ExcludeRepos), ",") }, - }, - requiredBotsKey: { - Doc: "logins that must review a head before a round converges", - Env: "CRQ_REQUIRED_BOTS", - AltEnv: coBotRequiredEnvs(), - Apply: func(cfg *Config, v string) error { - cfg.RequiredBots = splitList(v) - if len(cfg.RequiredBots) == 0 { - return fmt.Errorf("at least one required bot is needed") - } - return nil - }, - Show: func(cfg Config) string { return strings.Join(cfg.RequiredBots, ",") }, - }, - feedbackBotsKey: { - Doc: "logins whose findings crq surfaces beyond the ones it waits for (empty follows required-bots and cobots)", - Env: "CRQ_FEEDBACK_BOTS", - Apply: func(cfg *Config, v string) error { - logins := splitList(v) - // Present-but-empty is not a choice here either: it is the - // environment's "unset", so the surfaced set goes back to being - // derived from who reviews, recomputed rather than frozen. - cfg.FeedbackBots, cfg.FeedbackBotsExplicit = logins, len(logins) > 0 - if !cfg.FeedbackBotsExplicit { - cfg.FeedbackBots = cfg.reviewerLogins(func(r Reviewer) bool { return r.Required || !r.Metered() }) - } - return nil - }, - Show: func(cfg Config) string { - if !cfg.FeedbackBotsExplicit { - return "" - } - return strings.Join(cfg.FeedbackBots, ",") - }, - }, - "min-interval": { - Doc: "floor between two metered reviews", - Env: "CRQ_MIN_INTERVAL", - Apply: func(cfg *Config, v string) error { - d, err := parseFleetDuration(v) - cfg.MinInterval = d - return err - }, - Show: func(cfg Config) string { return cfg.MinInterval.String() }, - }, - "inflight-timeout": { - Doc: "how long a metered review command may remain unanswered", - Env: "CRQ_INFLIGHT_TIMEOUT", - Apply: func(cfg *Config, v string) error { - d, err := parseFleetDuration(v) - cfg.InflightTimeout = d - return err - }, - Show: func(cfg Config) string { return cfg.InflightTimeout.String() }, - }, - "rate-limit-fallback": { - Doc: "how long to wait when a rate-limit notice states no window", - Env: "CRQ_RL_FALLBACK", - Apply: func(cfg *Config, v string) error { - d, err := parseFleetDuration(v) - cfg.RateLimitFallback = d - return err - }, - Show: func(cfg Config) string { return cfg.RateLimitFallback.String() }, - }, - "calibrate-ttl": { - Doc: "how long a calibration answer counts as current", - Env: "CRQ_CALIBRATE_TTL", - Apply: func(cfg *Config, v string) error { - d, err := parseFleetDuration(v) - cfg.CalibrationTTL = d - return err - }, - Show: func(cfg Config) string { return cfg.CalibrationTTL.String() }, - }, - "settle": { - Doc: "quiet window before a PR counts as converged", - Env: "CRQ_SETTLE", - Apply: func(cfg *Config, v string) error { - d, err := parseFleetDuration(v) - cfg.SettleWindow = d - return err - }, - Show: func(cfg Config) string { return cfg.SettleWindow.String() }, - }, - "skip-marker": { - Doc: "text in a PR body that opts it out of review (empty disables the opt-out)", - Env: "CRQ_AUTOREVIEW_SKIP_MARKER", - Apply: func(cfg *Config, v string) error { - cfg.SkipMarker = v - return nil - }, - Show: func(cfg Config) string { return cfg.SkipMarker }, - }, - "skip-authors": { - Doc: "PR authors autoreview never enqueues (empty reviews every author)", - Env: "CRQ_AUTOREVIEW_SKIP_AUTHORS", - Apply: func(cfg *Config, v string) error { - cfg.SkipAuthors = authorSet(v) - return nil - }, - Show: func(cfg Config) string { return strings.Join(sortedSetKeys(cfg.SkipAuthors), ",") }, - }, - coBotsKey: { - Doc: "co-reviewers crq surfaces and triggers (empty disables all)", - Env: "CRQ_COBOTS", - // A required bot is enabled whatever CRQ_COBOTS lists, so the per-bot - // required keys shape this set too. - AltEnv: coBotRequiredEnvs(), - Apply: func(cfg *Config, v string) error { - names, err := fleetCoBotNames(v) - if err != nil { - return err - } - *cfg = cfg.ForRepo(RepoReviewers{CoBots: names, SetCoBots: true}) - return nil - }, - Show: func(cfg Config) string { - names := make([]string, 0, len(cfg.CoBots)) - for _, cb := range cfg.CoBots { - names = append(names, cb.Name) - } - return strings.Join(names, ",") - }, - }, - "rate-limit-co-degrade": { - Doc: "run co-reviewer-only rounds while the CodeRabbit account is blocked", - Env: "CRQ_RL_CO_DEGRADE", - AltEnv: []string{"CRQ_RL_CODEX_DEGRADE"}, - Apply: func(cfg *Config, v string) error { - on, err := parseFleetBool(v) - if err != nil { - return err - } - cfg.RateLimitCoDegrade = on - return nil - }, - Show: func(cfg Config) string { return fleetBool(cfg.RateLimitCoDegrade) }, - }, - } - // Every co-reviewer's own policy, one family per bot. Nothing here names a - // bot: the registry is what says which exist, so a new co-reviewer arrives - // with its fleet settings already in place. - for _, co := range dialect.KnownCoReviewers() { - for key, setting := range coBotFleetSettings(co) { - settings[key] = setting - } - } - return settings -} - -// coBotFleetSettings is how the fleet drives one co-reviewer: whether crq posts -// its command, what it posts, and how long a self-heal nudge waits first. -// -// Required-ness is deliberately NOT here — required-bots owns that list, and two -// settings answering the same question is how they end up disagreeing. -func coBotFleetSettings(co dialect.CoReviewer) map[string]fleetSetting { - name := co.Name - env := "CRQ_COBOT_" + strings.ToUpper(name) - var legacyCommand []string - if co.LegacyCommandEnv != "" { - legacyCommand = []string{co.LegacyCommandEnv} - } - return map[string]fleetSetting{ - "cobot-" + name + "-trigger": { - Doc: "when crq posts " + name + "'s command: never, selfheal or always (empty leaves it to how the bot is required)", - Env: env + "_TRIGGER", - Apply: func(cfg *Config, v string) error { - value := strings.ToLower(strings.TrimSpace(v)) - if value == "" { - // The environment's "unset" — the registry default for how the - // bot is required, recomputed rather than frozen. - updateCoBot(cfg, name, func(cb *CoBotConfig) { cb.TriggerExplicit = false }) - return nil - } - mode := engine.TriggerMode(value) - switch mode { - case engine.TriggerNever, engine.TriggerSelfHeal, engine.TriggerAlways: - default: - return fmt.Errorf("trigger must be never, selfheal or always, got %q", v) - } - updateCoBot(cfg, name, func(cb *CoBotConfig) { - cb.Trigger, cb.TriggerExplicit = mode, true - }) - return nil - }, - Show: func(cfg Config) string { - if cb := coBotOf(cfg, name); cb.TriggerExplicit { - return string(cb.Trigger) - } - return "" - }, - }, - "cobot-" + name + "-cmd": { - Doc: "the comment that triggers " + name + " (empty means crq never posts one)", - Env: env + "_CMD", - AltEnv: legacyCommand, - Apply: func(cfg *Config, v string) error { - command := strings.TrimSpace(v) - updateCoBot(cfg, name, func(cb *CoBotConfig) { cb.Command = command }) - return nil - }, - Show: func(cfg Config) string { return coBotOf(cfg, name).Command }, - }, - "cobot-" + name + "-grace": { - Doc: "how long a selfheal trigger waits for " + name + " to show up on its own", - Env: env + "_GRACE", - Apply: func(cfg *Config, v string) error { - d, err := parseFleetDuration(v) - if err != nil { - return err - } - updateCoBot(cfg, name, func(cb *CoBotConfig) { cb.SelfHealGrace = d }) - return nil - }, - Show: func(cfg Config) string { return coBotOf(cfg, name).SelfHealGrace.String() }, - }, - } -} - -// coBotRequiredEnvs is every per-bot required key. Required-ness is not a fleet -// setting of its own (required-bots owns the list), but the environment still -// lets a host answer it per bot — so those variables belong to the settings that -// carry the answer, not to none of them. -func coBotRequiredEnvs() []string { - out := make([]string, 0, 3) - for _, co := range dialect.KnownCoReviewers() { - out = append(out, "CRQ_COBOT_"+strings.ToUpper(co.Name)+"_REQUIRED") - } - return out -} - -// envNames is every variable this host may be feeding the setting from, canonical -// name first. -func (s fleetSetting) envNames() []string { - return append([]string{s.Env}, s.AltEnv...) -} - -// explicitEnv names the variables this host actually set for one fleet setting, -// canonical name first. Empty for a setting the host leaves to the fleet — and -// for the zero fleetSetting an unknown key resolves to, whose empty Env no -// environment can hold. -func explicitEnv(cfg Config, setting fleetSetting) []string { - var out []string - for _, name := range setting.envNames() { - if name == "" { - continue - } - if cfg.ExplicitFleetEnv[name] { - out = append(out, name) - } - } - return out -} - -// coBotOf is how this host currently drives a co-reviewer: its enabled entry -// when it has one, otherwise what its environment resolved for a bot the fleet -// leaves disabled — which is still the configuration a repository override -// would pick it up with. -func coBotOf(cfg Config, name string) CoBotConfig { - for _, cb := range cfg.CoBots { - if strings.EqualFold(cb.Name, name) { - return cb - } - } - cb, _ := cfg.knownCoBot(name) - return cb -} - -// updateCoBot changes one co-reviewer wherever its configuration is held: the -// enabled set crq drives, and the registry-wide set a per-repo override draws -// from for a bot the fleet does not enable. Both are copied first — the caller's -// Config shares these slices, and a setting that fails to parse must leave it -// untouched. -func updateCoBot(cfg *Config, name string, mutate func(*CoBotConfig)) { - cfg.CoBots = updateCoBotIn(cfg.CoBots, name, mutate) - // The registry-wide set is what a bot this host never resolved is enabled - // from — by the fleet's own cobots setting, or by a repository override — so - // it has to hold the policy even when there is no entry to change yet. - // Without this the fleet's answer would be silently dropped, which is the - // failure the shared registry exists to prevent. - if !hasCoBot(cfg.KnownCoBots, name) { - if co, ok := dialect.CoReviewerByName(name); ok { - cfg.KnownCoBots = append(append([]CoBotConfig(nil), cfg.KnownCoBots...), defaultCoBot(co, false)) - } - } - cfg.KnownCoBots = updateCoBotIn(cfg.KnownCoBots, name, mutate) -} - -func hasCoBot(bots []CoBotConfig, name string) bool { - for _, cb := range bots { - if strings.EqualFold(cb.Name, name) { - return true - } - } - return false -} - -func updateCoBotIn(bots []CoBotConfig, name string, mutate func(*CoBotConfig)) []CoBotConfig { - out := append([]CoBotConfig(nil), bots...) - for i, cb := range out { - if !strings.EqualFold(cb.Name, name) { - continue - } - mutate(&cb) - // Re-derive what the change implies, exactly as the environment parse - // does: an implicit trigger follows the registry default for how the bot - // is required, and no command means crq can never post one. - cb = reconcileTrigger(cb) - if cb.Command == "" { - cb.Trigger = engine.TriggerNever - } - out[i] = cb - } - return out -} - -// fleetCoBotNames resolves a recorded co-reviewer list to registry names, -// refusing one this binary does not know rather than skipping it: a silently -// dropped bot looks exactly like one that is simply never triggered. -func fleetCoBotNames(value string) ([]string, error) { - var names, unknown []string - for _, item := range splitList(value) { - co, ok := dialect.CoReviewerByName(item) - if !ok { - unknown = append(unknown, item) - continue - } - names = append(names, co.Name) - } - if len(unknown) > 0 { - known := make([]string, 0, 3) - for _, co := range dialect.KnownCoReviewers() { - known = append(known, co.Name) - } - return nil, fmt.Errorf("unknown co-reviewer %s (known: %s)", - strings.Join(unknown, ", "), strings.Join(known, ", ")) - } - return names, nil -} - -// feedbackBotsKey is the one reviewer-view input that does not reshape who -// reviews: it widens whose findings are read, which decides whether a head -// comes back as `fix` or as clean. Two hosts answering it differently is the -// same divergence as one excluding a repository the other reviews — one queue -// driver reports findings the next considers absent — so it is fleet policy, -// but it never requeues a round. -const feedbackBotsKey = "feedback-bots" - -// requiredBotsKey and coBotsKey are the two membership settings, named because -// their order relative to each other decides what they resolve to. See -// fleetApplyOrder. -const ( - requiredBotsKey = "required-bots" - coBotsKey = "cobots" -) - -// isReviewerFleetKey reports whether a setting reshapes who reviews, or how a -// co-reviewer is driven. Those are the ones whose derived views have to be -// rebuilt, and whose changes existing rounds may have to be reconciled against. -func isReviewerFleetKey(key string) bool { - return isReviewerMembershipFleetKey(key) || strings.HasPrefix(key, "cobot-") -} - -// isReviewerMembershipFleetKey reports whether a setting decides WHO reviews: -// the reviewer set itself, or which of them a round waits for. The per-bot keys -// isReviewerFleetKey also covers only say how crq asks a co-reviewer that is -// already configured — its command, its trigger mode, its self-heal grace — and -// none of them adds or removes a reviewer. -// -// The distinction matters when the fleet adopts a key, see fleetChange.baseline. -func isReviewerMembershipFleetKey(key string) bool { - return key == requiredBotsKey || key == coBotsKey -} - -// FleetKeys lists every setting the fleet owns, in a stable order. -func FleetKeys() []string { - settings := fleetSettings() - keys := make([]string, 0, len(settings)) - for key := range settings { - keys = append(keys, key) - } - sort.Strings(keys) - return keys -} - -// parseFleetDuration rejects what a Go duration cannot express, and refuses a -// negative one: every setting here is a window, and a negative window is a -// setting that would make crq act immediately, for ever. -func parseFleetDuration(v string) (time.Duration, error) { - d, err := time.ParseDuration(strings.TrimSpace(v)) - if err != nil { - return 0, fmt.Errorf("not a duration (try 90s, 15m, 2h): %w", err) - } - if d < 0 { - return 0, fmt.Errorf("duration must not be negative, got %s", d) - } - return d, nil -} - -// parseFleetBool reads the spellings the environment already accepts, so a -// seeded value and a hand-typed one mean the same thing. -func parseFleetBool(v string) (bool, error) { - switch value := strings.ToLower(strings.TrimSpace(v)); value { - case "on": - return true, nil - case "off": - return false, nil - default: - b, err := strconv.ParseBool(value) - if err != nil { - return false, fmt.Errorf("not a switch (try 1 or 0): %w", err) - } - return b, nil - } -} - -func fleetBool(on bool) string { - if on { - return "1" - } - return "0" -} - -// ValidateFleetSetting reports whether key is a fleet setting and value is one -// it can hold. Set fails on a bad value rather than writing it: the state ref -// is shared, so a value only one host can parse is a value that breaks the -// fleet from the inside. -func ValidateFleetSetting(key, value string) error { - setting, ok := fleetSettings()[key] - if !ok { - return fmt.Errorf("unknown setting %q (try one of: %s)", key, strings.Join(FleetKeys(), ", ")) - } - probe := Config{} - return setting.Apply(&probe, value) -} - -// fleetOwners refuses a scope entry that is not an owner login — a repository -// slug typed here being the mistake to catch. -// -// autoReviewPass hands every scope target to EachOpenPR as a user or -// organisation name, which resolves it against /users/; one that is -// neither fails the whole pass. Recorded for the fleet, a single typo therefore -// stops scanning on every host at once, which is exactly the reach that makes -// validating it here worth more than validating a per-host variable. -func fleetOwners(value string) ([]string, error) { - owners := splitList(value) - for _, owner := range owners { - if !validOwnerLogin(owner) { - return nil, fmt.Errorf("scope must be owner or organisation logins, got %q", owner) - } - } - return owners, nil -} - -func fleetRepoSet(value string) (map[string]bool, error) { - repos := repoSet(value) - for repo := range repos { - if !validRepoSlug(repo) { - return nil, fmt.Errorf("repo must be owner/name, got %q", repo) - } - } - return repos, nil -} - -// applyFleet overlays the fleet's recorded policy onto a host's configuration. -// -// Recorded wins. The environment is what a host uses until the fleet has an -// opinion — which is what makes adopting this safe on a machine at a time, and -// what `crq config seed` writes from. -// -// A value this binary cannot parse is LEFT to the host rather than applied -// half-way: a newer crq may have widened what it accepts, and acting on a -// partially-read policy is worse than acting on the one already in hand. The -// disagreement surfaces in `crq doctor`, not silently here. -func applyFleet(cfg Config, fleet map[string]string, warn func(string)) Config { - settings := fleetSettings() - for _, key := range fleetApplyOrder(fleet) { - setting, ok := settings[key] - if !ok { - if warn != nil { - warn(fmt.Sprintf("fleet setting %q is not one this crq understands; ignoring it", key)) - } - continue - } - candidate := cfg - if err := setting.Apply(&candidate, fleet[key]); err != nil { - if warn != nil { - warn(fmt.Sprintf("fleet setting %q: %v; keeping this host's value", key, err)) - } - continue - } - cfg = candidate - } - // The reviewer settings are the inputs to the derived reviewer views. - // Applying one as a scalar without rebuilding those views can leave a - // required co-reviewer disabled, carrying its optional trigger mode, or - // still driven by the command this host was started with. - if touchesReviewers(fleet) { - // A primary that is also a registry bot is triggered as the primary; - // asking it twice is the bug, and a fleet trigger for it must not undo - // the silencing LoadConfig applied. - cfg.CoBots = silenceTrigger(cfg.CoBots, cfg.Bot) - enabled := make([]string, 0, len(cfg.CoBots)) - for _, cb := range cfg.CoBots { - enabled = append(enabled, cb.Login) - } - cfg = cfg.ForRepo(RepoReviewers{ - CoBots: enabled, - SetCoBots: true, - Required: cfg.RequiredBots, - SetRequired: true, - }) - } - return cfg -} - -// fleetApplyOrder is the order recorded settings are applied in: alphabetical, -// except that required-bots comes first. -// -// The two membership settings are interdependent — `cobots` resolves through -// ForRepo, where a required bot is enabled whatever the list says. Alphabetical -// order applies it BEFORE required-bots, so it reads THIS HOST's required set: -// a fleet that records `required-bots=coderabbitai[bot]` with `cobots=""` has -// the host's locally required co-reviewer put straight back, and the rebuild -// below preserves the contaminated set — leaving hosts surfacing and triggering -// a bot the fleet disabled, from local environment the fleet is meant to -// override. Resolving the required list first gives both keys one baseline. -func fleetApplyOrder(fleet map[string]string) []string { - keys := make([]string, 0, len(fleet)) - if _, ok := fleet[requiredBotsKey]; ok { - keys = append(keys, requiredBotsKey) - } - for _, key := range sortedKeys(fleet) { - if key != requiredBotsKey { - keys = append(keys, key) - } - } - return keys -} - -// touchesReviewers reports whether the recorded policy holds anything the -// derived reviewer views are built from. feedback-bots is one of those inputs -// without reshaping who reviews: dropping it has to return the surfaced set to -// the derived one, which only the rebuild can compute. -func touchesReviewers(fleet map[string]string) bool { - for key := range fleet { - if isReviewerFleetKey(key) || key == feedbackBotsKey { - return true - } - } - return false -} - -func sortedKeys(m map[string]string) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - -func sortedSetKeys(m map[string]bool) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go deleted file mode 100644 index 0cb186ec..00000000 --- a/internal/crq/fleetconfig_test.go +++ /dev/null @@ -1,1261 +0,0 @@ -package crq - -import ( - "context" - "net/http" - "strings" - "testing" - "time" - - "github.com/kristofferR/coderabbit-queue/internal/dialect" - "github.com/kristofferR/coderabbit-queue/internal/engine" - ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" -) - -// One setting, one place. A host that carries its own answer for something the -// fleet has decided is how a repository ends up excluded on one machine and -// reviewed by another, with nothing to say so. -func TestFleetPolicyOverridesTheHostsOwn(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - cfg.AllowRepos = map[string]bool{"owner/local": true} - cfg.MinInterval = time.Minute - store := NewMemoryStore(cfg) - svc := NewService(cfg, newFakeGitHub(), store, nil) - - if err := svc.SetFleetConfig(ctx, "repos", "owner/fleet-a,owner/fleet-b"); err != nil { - t.Fatal(err) - } - if err := svc.SetFleetConfig(ctx, "min-interval", "5m"); err != nil { - t.Fatal(err) - } - st, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - got := svc.fleetCfg(st) - if !got.AllowRepos["owner/fleet-a"] || !got.AllowRepos["owner/fleet-b"] { - t.Errorf("repos = %v, want the fleet's list", got.AllowRepos) - } - if got.AllowRepos["owner/local"] { - t.Error("the host's own repository list survived the fleet's") - } - if got.MinInterval != 5*time.Minute { - t.Errorf("min-interval = %s, want the fleet's 5m", got.MinInterval) - } - - // A setting the fleet has no opinion on stays this host's answer. - if got.SettleWindow != cfg.SettleWindow { - t.Errorf("settle = %s, want the host's %s when the fleet is silent", got.SettleWindow, cfg.SettleWindow) - } - - // And unsetting hands it back. - if dropped, err := svc.UnsetFleetConfig(ctx, "min-interval"); err != nil || !dropped { - t.Fatalf("unset = %v %v", dropped, err) - } - st, _, _ = store.Load(ctx) - if svc.fleetCfg(st).MinInterval != time.Minute { - t.Error("unsetting a fleet setting did not return the host's own value") - } -} - -// A value only some binaries can read would break the fleet from the inside, -// so it is refused where it is set — and if one arrives anyway, from a newer -// crq, the host keeps what it has rather than acting on half a policy. -func TestFleetRefusesWhatItCannotRead(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - cfg.MinInterval = 90 * time.Second - store := NewMemoryStore(cfg) - svc := NewService(cfg, newFakeGitHub(), store, nil) - - if err := svc.SetFleetConfig(ctx, "min-interval", "later"); err == nil { - t.Error("a duration that does not parse was accepted") - } - if err := svc.SetFleetConfig(ctx, "min-interval", "-5m"); err == nil { - t.Error("a negative window was accepted") - } - if err := svc.SetFleetConfig(ctx, "nonsense", "1"); err == nil { - t.Error("an unknown setting was accepted") - } - for _, key := range []string{"repos", "exclude"} { - // A slug GitHub can never return is a rule covering nothing, whether it - // is missing the slash or holds a character no repository name can. - for _, slug := range []string{"owner-repo", "owner/re po", "owner/re\tpo", "owner/repo?"} { - if err := svc.SetFleetConfig(ctx, key, slug); err == nil { - t.Errorf("%s accepted the malformed repository slug %q", key, slug) - } - } - } - // A scope entry is an owner login, not a slug: autoreview hands each one to - // EachOpenPR as a user or organisation name, so a repository typed here — or - // anything else GitHub cannot resolve — fails every pass, on every host at - // once, because the value is fleet-wide. - // A login may not begin or end with a hyphen, hold two in a row, run past - // GitHub's 39-character limit, or hold underscores or dots — those are - // repository-name characters, and accepting any of them here records a scope - // no /users/ lookup can ever resolve. - for _, owner := range []string{ - "owner/repo", "own er", "owner?", "..", "-team", "team-", "a_team", "a.team", - "a--team", strings.Repeat("a", maxOwnerLogin+1), - } { - if err := svc.SetFleetConfig(ctx, "scope", owner); err == nil { - t.Errorf("scope accepted the malformed owner %q", owner) - } - } - if err := svc.SetFleetConfig(ctx, "scope", "acme-2,Some-Org"); err != nil { - t.Errorf("a pair of ordinary owner logins was rejected: %v", err) - } - if err := svc.SetFleetConfig(ctx, "required-bots", ""); err == nil { - t.Error("an empty required reviewer set was accepted") - } - - // Planted directly, as a newer binary would leave it. - if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("min-interval", "a fortnight") - st.SetFleetValue("from-the-future", "whatever") - return nil - }); err != nil { - t.Fatal(err) - } - st, _, _ := store.Load(ctx) - if got := svc.fleetCfg(st).MinInterval; got != 90*time.Second { - t.Errorf("min-interval = %s, want this host's 90s kept when the fleet's is unreadable", got) - } -} - -// Seeding is how an existing setup adopts this without retyping it, and it must -// not let a second machine overwrite the first one's answer. -func TestSeedingDoesNotOverwriteWhatTheFleetAlreadySays(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - cfg.AllowRepos = map[string]bool{"owner/from-this-host": true} - store := NewMemoryStore(cfg) - svc := NewService(cfg, newFakeGitHub(), store, nil) - - if err := svc.SetFleetConfig(ctx, "repos", "owner/decided-already"); err != nil { - t.Fatal(err) - } - seeded, err := svc.SeedFleetConfig(ctx) - if err != nil { - t.Fatal(err) - } - for _, key := range seeded { - if key == "repos" { - t.Error("seeding overwrote a setting the fleet had already recorded") - } - } - st, _, _ := store.Load(ctx) - if v, _ := st.FleetValue("repos"); v != "owner/decided-already" { - t.Errorf("repos = %q, want the fleet's existing answer", v) - } - // Everything else it had no answer for is now recorded. - if v, ok := st.FleetValue("settle"); !ok || v == "" { - t.Errorf("settle = %q %v, want this host's value seeded", v, ok) - } -} - -func TestSeedingValidatesEveryMissingValueBeforeWriting(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - cfg.AllowRepos = map[string]bool{"not-a-repository": true} - store := NewMemoryStore(cfg) - - seeded, err := NewService(cfg, newFakeGitHub(), store, nil).SeedFleetConfig(ctx) - if err == nil || !strings.Contains(err.Error(), "cannot seed repos") { - t.Fatalf("seed = %v, %v; want the malformed host repository rejected", seeded, err) - } - st, _, loadErr := store.Load(ctx) - if loadErr != nil { - t.Fatal(loadErr) - } - if len(st.FleetConfig) != 0 { - t.Fatalf("failed seed partially wrote fleet policy: %v", st.FleetConfig) - } -} - -func TestFleetRequiredBotsRebuildsDerivedReviewers(t *testing.T) { - cfg := isolatedConfig(t, map[string]string{ - "CRQ_COBOTS": "codex", - "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", - }) - st := DefaultState(cfg) - st.SetFleetValue("required-bots", "coderabbitai[bot],"+dialect.CodexBotLogin) - - got := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil).fleetCfg(st) - if !containsBot(got.RequiredBots, dialect.CodexBotLogin) { - t.Fatalf("RequiredBots = %v, want fleet-required codex", got.RequiredBots) - } - if len(got.CoBots) != 1 || !got.CoBots[0].Required || got.CoBots[0].Trigger != "always" { - t.Fatalf("CoBots = %+v, want required codex with its required trigger", got.CoBots) - } -} - -// Which co-reviewers run, how crq drives them, and whether an account-blocked -// round degrades to them are decisions, not machine facts. Two hosts answering -// them differently is the same divergence as one excluding a repository the -// other reviews: both behave correctly, and the PRs disagree. -func TestFleetOwnsTheCoReviewerPolicy(t *testing.T) { - ctx := context.Background() - cfg := isolatedConfig(t, map[string]string{ - "CRQ_COBOTS": "codex", - "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", - "CRQ_COBOT_BUGBOT_CMD": "bugbot run", - "CRQ_COBOT_BUGBOT_GRACE": "10m", - "CRQ_COBOT_CODEX_TRIGGER": "always", - "CRQ_RL_CO_DEGRADE": "0", - }) - store := NewMemoryStore(cfg) - svc := NewService(cfg, newFakeGitHub(), store, nil) - - for _, set := range []struct{ key, value string }{ - {"cobots", "bugbot"}, - {"cobot-bugbot-trigger", "always"}, - {"cobot-bugbot-cmd", "bugbot run now"}, - {"cobot-bugbot-grace", "30m"}, - {"rate-limit-co-degrade", "1"}, - } { - if err := svc.SetFleetConfig(ctx, set.key, set.value); err != nil { - t.Fatalf("set %s: %v", set.key, err) - } - } - st, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - got := svc.fleetCfg(st) - - if len(got.CoBots) != 1 || got.CoBots[0].Name != "bugbot" { - t.Fatalf("CoBots = %+v, want only the fleet's bugbot", got.CoBots) - } - bugbot := got.CoBots[0] - if bugbot.Trigger != engine.TriggerAlways || bugbot.Command != "bugbot run now" || bugbot.SelfHealGrace != 30*time.Minute { - t.Errorf("bugbot = %+v, want the fleet's trigger, command and grace", bugbot) - } - if !got.RateLimitCoDegrade { - t.Error("the fleet turned co-reviewer degradation on and this host stayed off") - } - // The derived views have to follow, or which co-reviewer runs depends on - // which view crq happens to ask. - var seen bool - for _, r := range got.Reviewers { - if !sameBot(r.Login, bugbot.Login) { - continue - } - seen = true - if r.Trigger != engine.TriggerAlways || r.Command != "bugbot run now" { - t.Errorf("reviewer %+v disagrees with the fleet's co-reviewer policy", r) - } - } - if !seen { - t.Errorf("Reviewers = %+v, want the fleet's co-reviewer among them", got.Reviewers) - } -} - -// The two membership settings are read together or not at all. Applied in -// alphabetical order, `cobots` resolved against the host's required list — which -// re-enabled a co-reviewer the fleet had just disabled, and the rebuild kept it, -// leaving the policy depending on local environment again. -func TestFleetCoBotsResolveAgainstTheFleetsRequiredBots(t *testing.T) { - ctx := context.Background() - cfg := isolatedConfig(t, map[string]string{ - "CRQ_COBOTS": "codex", - "CRQ_COBOT_CODEX_REQUIRED": "1", - }) - if len(cfg.CoBots) != 1 || cfg.CoBots[0].Name != "codex" { - t.Fatalf("this host must start with codex required: %+v", cfg.CoBots) - } - store := NewMemoryStore(cfg) - svc := NewService(cfg, newFakeGitHub(), store, nil) - - if err := svc.SetFleetConfig(ctx, "cobots", ""); err != nil { - t.Fatal(err) - } - if err := svc.SetFleetConfig(ctx, "required-bots", "coderabbitai[bot]"); err != nil { - t.Fatal(err) - } - st, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - got := svc.fleetCfg(st) - if len(got.CoBots) != 0 { - t.Errorf("CoBots = %+v, want none — the fleet disabled every co-reviewer", got.CoBots) - } - if containsBot(got.RequiredBots, dialect.CodexBotLogin) { - t.Errorf("RequiredBots = %v, want only the fleet's", got.RequiredBots) - } -} - -// A value only some hosts can read breaks the fleet from the inside, so the -// co-reviewer settings are refused where they are set like every other one. -func TestFleetRefusesCoReviewerPolicyItCannotRead(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) - - for _, bad := range []struct{ key, value string }{ - {"cobots", "codex,nosuchbot"}, - {"cobot-codex-trigger", "sometimes"}, - {"cobot-codex-grace", "-5m"}, - {"rate-limit-co-degrade", "maybe"}, - } { - if err := svc.SetFleetConfig(ctx, bad.key, bad.value); err == nil { - t.Errorf("%s accepted %q", bad.key, bad.value) - } - } - // Explicitly none is a real answer, not a malformed one. - if err := svc.SetFleetConfig(ctx, "cobots", ""); err != nil { - t.Errorf("cobots refused an empty set: %v", err) - } -} - -// Enabling a co-reviewer fleet-wide is a reviewer change like requiring one: -// the heads already reviewed were reviewed without it. -func TestFleetCoBotChangeReopensCompletedRounds(t *testing.T) { - ctx := context.Background() - cfg := isolatedConfig(t, map[string]string{ - "CRQ_COBOTS": "codex", - "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", - }) - repo, pr := "owner/repo", 7 - gh := newFakeGitHub() - gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.PutRound(Round{Repo: repo, PR: pr, Head: "abcdef123", Phase: PhaseCompleted}) - return nil - }); err != nil { - t.Fatal(err) - } - svc := NewService(cfg, gh, store, nil) - if err := svc.SetFleetConfig(ctx, "cobots", "codex,bugbot"); err != nil { - t.Fatal(err) - } - st, _, _ := store.Load(ctx) - round := st.Round(repo, pr) - if round == nil || round.Phase != PhaseQueued { - t.Fatalf("round = %+v, want the completed round reopened for the new co-reviewer", round) - } - if !containsBot(round.ForceCoReviewers, dialect.BugbotLogin) { - t.Errorf("ForceCoReviewers = %v, want the newly enabled self-heal bot nudged once", round.ForceCoReviewers) - } -} - -func TestFleetMutationsAreInertInDryRun(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("min-interval", "5m") - return nil - }); err != nil { - t.Fatal(err) - } - cfg.DryRun = true - svc := NewService(cfg, newFakeGitHub(), store, nil) - - if err := svc.SetFleetConfig(ctx, "min-interval", "10m"); err != nil { - t.Fatal(err) - } - if dropped, err := svc.UnsetFleetConfig(ctx, "min-interval"); err != nil || !dropped { - t.Fatalf("dry-run unset = %v, %v; want the report without a write", dropped, err) - } - if seeded, err := svc.SeedFleetConfig(ctx); err != nil || len(seeded) == 0 { - t.Fatalf("dry-run seed = %v, %v; want missing keys reported", seeded, err) - } - st, _, _ := store.Load(ctx) - if value, _ := st.FleetValue("min-interval"); value != "5m" || len(st.FleetConfig) != 1 { - t.Fatalf("dry run changed fleet state: %v", st.FleetConfig) - } -} - -func TestFleetDryRunStillChecksDriverCompatibility(t *testing.T) { - ctx := context.Background() - now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) - cfg := firingConfig() - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.Leader = &LeaderLease{Owner: "old-daemon", ExpiresAt: now.Add(time.Minute)} - st.NoteWriter("old-daemon", CapsFleetPolicy-1, now) - return nil - }); err != nil { - t.Fatal(err) - } - cfg.DryRun = true - svc := NewService(cfg, newFakeGitHub(), store, nil) - svc.now = func() time.Time { return now } - - err := svc.SetFleetConfig(ctx, "min-interval", "5m") - if err == nil || !strings.Contains(err.Error(), "lack fleet-policy support") { - t.Fatalf("dry-run set with a lagging driver = %v, want a capability refusal", err) - } -} - -func TestFleetScopeChangeInvalidatesAccountQuota(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - store := NewMemoryStore(cfg) - blocked := time.Date(2026, 7, 27, 13, 0, 0, 0, time.UTC) - remaining := 3 - if _, err := store.Update(ctx, func(st *State) error { - st.Account.BlockedUntil = &blocked - st.Account.Remaining = &remaining - st.Account.CheckedAt = &blocked - st.Account.CalibAskedAt = &blocked - st.Account.RLCommentID = 42 - st.Account.RLCommentUpdated = &blocked - return nil - }); err != nil { - t.Fatal(err) - } - - if err := NewService(cfg, newFakeGitHub(), store, nil).SetFleetConfig(ctx, "scope", "other-org"); err != nil { - t.Fatal(err) - } - st, _, _ := store.Load(ctx) - if st.Account.Scope != "other-org" { - t.Fatalf("account scope = %q, want other-org", st.Account.Scope) - } - if st.Account.BlockedUntil != nil || st.Account.Remaining != nil || st.Account.CheckedAt != nil || - st.Account.CalibAskedAt != nil || st.Account.RLCommentID != 0 || st.Account.RLCommentUpdated != nil { - t.Fatalf("old account quota survived the scope change: %+v", st.Account) - } -} - -func TestEquivalentFleetScopeKeepsAccountQuota(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - cfg.Scope = []string{"Acme", "Foo"} - store := NewMemoryStore(cfg) - blocked := time.Date(2026, 7, 27, 13, 0, 0, 0, time.UTC) - if _, err := store.Update(ctx, func(st *State) error { - st.Account.BlockedUntil = &blocked - st.Account.Scope = "Acme,Foo" - return nil - }); err != nil { - t.Fatal(err) - } - - if err := NewService(cfg, newFakeGitHub(), store, nil).SetFleetConfig(ctx, "scope", "foo,acme"); err != nil { - t.Fatal(err) - } - st, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if st.Account.BlockedUntil == nil || !st.Account.BlockedUntil.Equal(blocked) { - t.Fatalf("equivalent scope cleared the account block: %+v", st.Account) - } -} - -func TestFleetInflightTimeoutOverridesTheHost(t *testing.T) { - cfg := firingConfig() - cfg.InflightTimeout = 15 * time.Minute - st := DefaultState(cfg) - st.SetFleetValue("inflight-timeout", "45m") - - got := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil).fleetCfg(st) - if got.InflightTimeout != 45*time.Minute { - t.Fatalf("inflight timeout = %s, want fleet 45m", got.InflightTimeout) - } -} - -func TestFleetExcludeRetiresQueuedRounds(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.PutRound(Round{ - Repo: "owner/repo", PR: 7, Head: "abcdef123", - Phase: PhaseQueued, EnqueuedAt: time.Now().UTC(), - }) - return nil - }); err != nil { - t.Fatal(err) - } - - if err := NewService(cfg, newFakeGitHub(), store, nil).SetFleetConfig(ctx, "exclude", "owner/repo"); err != nil { - t.Fatal(err) - } - st, _, _ := store.Load(ctx) - if round := st.Round("owner/repo", 7); round != nil { - t.Fatalf("excluded round remains fire-eligible: %+v", round) - } - if len(st.Archive) != 1 || st.Archive[0].Phase != PhaseAbandoned { - t.Fatalf("excluded round was not archived as abandoned: %+v", st.Archive) - } -} - -// The slot outlives the round that took it: a hold left behind for a metered -// command whose round was superseded by the push it invited belongs to that -// command. Excluding the repository retires the QUEUED replacement at the same -// key, and dropping the hold with it would let a second metered review fire -// while the first command is still unanswered. -func TestFleetExcludeLeavesAnOrphanedFireSlotHold(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - store := NewMemoryStore(cfg) - now := time.Now().UTC() - until := now.Add(20 * time.Minute) - if _, err := store.Update(ctx, func(st *State) error { - st.PutRound(Round{ - Repo: "owner/repo", PR: 7, Head: "beef456", - Phase: PhaseQueued, EnqueuedAt: now, - }) - st.FireSlot = &FireSlot{Key: QueueKey("owner/repo", 7), Token: "gone", Since: now} - st.HoldSlotUntil(until) - return nil - }); err != nil { - t.Fatal(err) - } - - if err := NewService(cfg, newFakeGitHub(), store, nil).SetFleetConfig(ctx, "exclude", "owner/repo"); err != nil { - t.Fatal(err) - } - st, _, _ := store.Load(ctx) - if round := st.Round("owner/repo", 7); round != nil { - t.Fatalf("excluded round remains fire-eligible: %+v", round) - } - if !st.SlotHeld(now) { - t.Fatalf("exclusion released the unanswered command's hold: %+v", st.FireSlot) - } -} - -// A reservation is a fire's commit point, but the review command reaches GitHub -// after it. Excluding the repository in that window cannot take the post back: -// retiring the round would only destroy the record of the quota the account is -// about to be charged for, on a repository crq is no longer meant to touch. -func TestFleetExcludeRefusesToRaceAClaimedTriggerPost(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.PutRound(Round{Repo: "owner/repo", PR: 7, Head: "abcdef123", Phase: PhaseReserved, Token: "tok"}) - return nil - }); err != nil { - t.Fatal(err) - } - - svc := NewService(cfg, newFakeGitHub(), store, nil) - err := svc.SetFleetConfig(ctx, "exclude", "owner/repo") - if err == nil || !strings.Contains(err.Error(), "already being posted") { - t.Fatalf("exclude during a claimed trigger post = %v, want a refusal", err) - } - st, _, _ := store.Load(ctx) - if value, ok := st.FleetValue("exclude"); ok { - t.Errorf("exclude = %q, want the refused change not written", value) - } - if round := st.Round("owner/repo", 7); round == nil || round.Phase != PhaseReserved { - t.Fatalf("round = %+v, want the reserved round left alone", round) - } - - // A co-reviewer claim is the same promise, and both clear once the post is - // recorded — then the exclusion goes through. - if _, err := store.Update(ctx, func(st *State) error { - r := st.Round("owner/repo", 7) - r.Phase = PhaseQueued - st.PutRound(*r) - return nil - }); err != nil { - t.Fatal(err) - } - if err := svc.SetFleetConfig(ctx, "exclude", "owner/repo"); err != nil { - t.Fatalf("exclude after the post finished = %v, want it recorded", err) - } -} - -// Whose findings crq reads decides whether a head comes back as `fix` or as -// clean, which makes it fleet policy: one queue driver reporting findings the -// next considers absent is the same divergence as one host excluding a -// repository the other reviews. -func TestFleetOwnsTheFeedbackBots(t *testing.T) { - ctx := context.Background() - cfg := isolatedConfig(t, map[string]string{ - "CRQ_COBOTS": "codex", - "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", - "CRQ_FEEDBACK_BOTS": "coderabbitai[bot]", - }) - store := NewMemoryStore(cfg) - svc := NewService(cfg, newFakeGitHub(), store, nil) - - if err := svc.SetFleetConfig(ctx, feedbackBotsKey, "coderabbitai[bot],"+dialect.CodexBotLogin); err != nil { - t.Fatal(err) - } - st, _, _ := store.Load(ctx) - got := svc.fleetCfg(st) - if !containsBot(got.FeedbackBots, dialect.CodexBotLogin) { - t.Errorf("FeedbackBots = %v, want the fleet's list to win over this host's", got.FeedbackBots) - } - // And `crq doctor` names the variable still set locally, or the host looks - // healthy while it reads a different set. - diverged, err := svc.FleetDivergence(ctx) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(strings.Join(diverged, "\n"), "CRQ_FEEDBACK_BOTS") { - t.Errorf("divergence = %v, want the overridden host variable named", diverged) - } - - // Recording the empty value is the fleet saying "derive it", which has to - // recompute the surfaced set rather than leave this host's explicit one. - if err := svc.SetFleetConfig(ctx, feedbackBotsKey, ""); err != nil { - t.Fatal(err) - } - st, _, _ = store.Load(ctx) - got = svc.fleetCfg(st) - if got.FeedbackBotsExplicit || !containsBot(got.FeedbackBots, dialect.CodexBotLogin) { - t.Fatalf("FeedbackBots = %v (explicit %v), want the derived set including the enabled co-reviewer", - got.FeedbackBots, got.FeedbackBotsExplicit) - } -} - -// Seeding writes THIS host's answers, so comparing the reviewer policy it -// records against the policy this host was already using can only ever say -// "nothing changed" — while the host that completed the head may have been -// running an entirely different set. That divergence is what seeding exists to -// end, so a seeded reviewer key reconciles as if the fleet had no reviewers. -func TestSeedingReviewerPolicyReopensCompletedRounds(t *testing.T) { - ctx := context.Background() - cfg := isolatedConfig(t, map[string]string{ - "CRQ_REPOS": "", - "CRQ_EXCLUDE": "", - "CRQ_COBOTS": "codex,bugbot", - "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", - "CRQ_COBOT_BUGBOT_CMD": "bugbot run", - "CRQ_COBOT_BUGBOT_TRIGGER": "selfheal", - }) - repo, pr := "owner/repo", 7 - gh := newFakeGitHub() - gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.PutRound(Round{Repo: repo, PR: pr, Head: "abcdef123", Phase: PhaseCompleted}) - return nil - }); err != nil { - t.Fatal(err) - } - - if _, err := NewService(cfg, gh, store, nil).SeedFleetConfig(ctx); err != nil { - t.Fatal(err) - } - st, _, _ := store.Load(ctx) - round := st.Round(repo, pr) - if round == nil || round.Phase != PhaseQueued { - t.Fatalf("round = %+v, want the completed round reopened by the seed", round) - } - if !containsBot(round.ForceCoReviewers, dialect.BugbotLogin) { - t.Errorf("ForceCoReviewers = %v, want the seeded self-heal bot nudged once", round.ForceCoReviewers) - } -} - -// The first `crq config set` of a key is the same adoption a seed performs, and -// the pre-change baseline it is reconciled against comes from THIS host — so -// recording the value this machine was already using looks like no change at -// all, while the host that completed the head may have been running another set -// entirely. -func TestFirstFleetReviewerValueReopensCompletedRounds(t *testing.T) { - ctx := context.Background() - required := "coderabbitai[bot]," + dialect.CodexBotLogin - cfg := isolatedConfig(t, map[string]string{ - "CRQ_COBOTS": "codex", - "CRQ_REQUIRED_BOTS": required, - }) - repo, pr := "owner/repo", 7 - gh := newFakeGitHub() - gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.PutRound(Round{Repo: repo, PR: pr, Head: "abcdef123", Phase: PhaseCompleted}) - return nil - }); err != nil { - t.Fatal(err) - } - - // The same value this host already had: only its absence from the fleet - // makes it a change, and that is the change that has to be reconciled. - if err := NewService(cfg, gh, store, nil).SetFleetConfig(ctx, "required-bots", required); err != nil { - t.Fatal(err) - } - st, _, _ := store.Load(ctx) - if round := st.Round(repo, pr); round == nil || round.Phase != PhaseQueued { - t.Fatalf("round = %+v, want the completed round reopened by the adoption", round) - } -} - -// Adoption erases the reviewer baseline because the fleet may never have agreed -// on WHO reviews. A co-reviewer's self-heal grace is not that question: it is -// how long an already-configured bot is given, every host was driving the same -// set before and after, and reconciliation compares membership. Erasing the -// baseline for it reopened every completed round in the fleet and forced a -// self-heal trigger post on every open PR, for a timing value. -func TestFirstFleetCoBotTimingValueLeavesCompletedRoundsAlone(t *testing.T) { - ctx := context.Background() - cfg := isolatedConfig(t, map[string]string{ - "CRQ_COBOTS": "codex,bugbot", - "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", - "CRQ_COBOT_BUGBOT_CMD": "bugbot run", - "CRQ_COBOT_BUGBOT_TRIGGER": "selfheal", - }) - repo, pr := "owner/repo", 7 - gh := newFakeGitHub() - gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - // Who reviews is already the fleet's answer; only the timing is not. - st.SetFleetValue("cobots", "codex,bugbot") - st.SetFleetValue("required-bots", "coderabbitai[bot]") - st.PutRound(Round{Repo: repo, PR: pr, Head: "abcdef123", Phase: PhaseCompleted}) - return nil - }); err != nil { - t.Fatal(err) - } - - if err := NewService(cfg, gh, store, nil).SetFleetConfig(ctx, "cobot-bugbot-grace", "10m"); err != nil { - t.Fatal(err) - } - st, _, _ := store.Load(ctx) - round := st.Round(repo, pr) - if round == nil || round.Phase != PhaseCompleted { - t.Fatalf("round = %+v, want the completed round left alone by a timing-only adoption", round) - } - if len(round.ForceCoReviewers) != 0 { - t.Errorf("ForceCoReviewers = %v, want no trigger forced by a timing-only adoption", round.ForceCoReviewers) - } -} - -// Adopting an exclusion this host already applied is a change for every host -// that did not, so it still has to pass the claimed-trigger refusal. -func TestFirstFleetExcludeStillRefusesAClaimedTriggerPost(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - cfg.ExcludeRepos = map[string]bool{"owner/repo": true} - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.PutRound(Round{Repo: "owner/repo", PR: 7, Head: "abcdef123", Phase: PhaseReserved, Token: "tok"}) - return nil - }); err != nil { - t.Fatal(err) - } - - err := NewService(cfg, newFakeGitHub(), store, nil).SetFleetConfig(ctx, "exclude", "owner/repo") - if err == nil || !strings.Contains(err.Error(), "already being posted") { - t.Fatalf("adopting an exclusion during a claimed trigger post = %v, want a refusal", err) - } -} - -// The recorded quota belongs to whichever account the fleet was scanning, which -// this host's own scope says nothing about — so adopting the scope is judged -// against the quota's own recorded one. -func TestFirstFleetScopeDropsQuotaFromAnotherAccount(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - cfg.Scope = []string{"acme"} - store := NewMemoryStore(cfg) - blocked := time.Date(2026, 7, 27, 13, 0, 0, 0, time.UTC) - if _, err := store.Update(ctx, func(st *State) error { - st.Account.BlockedUntil = &blocked - st.Account.Scope = "other-org" - return nil - }); err != nil { - t.Fatal(err) - } - - if err := NewService(cfg, newFakeGitHub(), store, nil).SetFleetConfig(ctx, "scope", "acme"); err != nil { - t.Fatal(err) - } - st, _, _ := store.Load(ctx) - if st.Account.BlockedUntil != nil || st.Account.Scope != "acme" { - t.Fatalf("account = %+v, want another account's block dropped", st.Account) - } -} - -// Dropping a recorded key this binary cannot read is the remedy doctor names, -// but it is not this binary's to make while the crq that wrote it is driving the -// queue: the removal may need cleanup only that version knows how to do. -func TestUnsettingAnUnknownKeyWaitsForTheNewerDriver(t *testing.T) { - ctx := context.Background() - now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) - cfg := firingConfig() - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("some-future-knob", "7") - st.Leader = &LeaderLease{Owner: "new-daemon", ExpiresAt: now.Add(time.Minute)} - st.NoteWriter("new-daemon", WriterCaps+1, now) - return nil - }); err != nil { - t.Fatal(err) - } - svc := NewService(cfg, newFakeGitHub(), store, nil) - svc.now = func() time.Time { return now } - - if _, err := svc.UnsetFleetConfig(ctx, "some-future-knob"); err == nil || !strings.Contains(err.Error(), "newer queue drivers") { - t.Fatalf("unset of an uninterpretable key = %v, want a refusal naming the newer driver", err) - } - // A setting this binary does understand is still its own to drop: it can - // reconcile that removal itself. - if err := svc.SetFleetConfig(ctx, "min-interval", "5m"); err != nil { - t.Fatal(err) - } - if dropped, err := svc.UnsetFleetConfig(ctx, "min-interval"); err != nil || !dropped { - t.Fatalf("unset of a known key = %v %v, want it dropped", dropped, err) - } -} - -func TestFleetDivergenceIncludesConfigFileValues(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - cfg.MinInterval = time.Minute - cfg.ExplicitFleetEnv = map[string]bool{"CRQ_MIN_INTERVAL": true} - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("min-interval", "5m") - return nil - }); err != nil { - t.Fatal(err) - } - got, err := NewService(cfg, newFakeGitHub(), store, nil).FleetDivergence(ctx) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 || !strings.Contains(got[0], "CRQ_MIN_INTERVAL") { - t.Fatalf("divergence = %v, want the explicitly loaded config-file value", got) - } -} - -func TestFleetDivergenceIncludesAnExplicitlyEmptyHostValue(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - cfg.ExcludeRepos = map[string]bool{} - cfg.ExplicitFleetEnv = map[string]bool{"CRQ_EXCLUDE": true} - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("exclude", "owner/repo") - return nil - }); err != nil { - t.Fatal(err) - } - got, err := NewService(cfg, newFakeGitHub(), store, nil).FleetDivergence(ctx) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 || !strings.Contains(got[0], `CRQ_EXCLUDE is set to ""`) { - t.Fatalf("divergence = %v, want the explicitly empty host value", got) - } -} - -// A host copy that agrees with the fleet is the same variable waiting to be -// fallen back on, and `crq config unset` is what makes it one: the setting goes -// back to whatever each host says, and by then there is no recorded value left -// for this report to compare against. A setting the fleet does not record is not -// that — it is simply this host's to answer, and saying so would name every -// variable on a machine that has yet to adopt anything. -func TestFleetDivergenceNamesAHostCopyThatAgrees(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - cfg.MinInterval = 5 * time.Minute - cfg.ExplicitFleetEnv = map[string]bool{"CRQ_MIN_INTERVAL": true} - store := NewMemoryStore(cfg) - svc := NewService(cfg, newFakeGitHub(), store, nil) - - got, err := svc.FleetDivergence(ctx) - if err != nil { - t.Fatal(err) - } - if len(got) != 0 { - t.Fatalf("divergence = %v, want nothing reported for a setting the fleet leaves to this host", got) - } - - if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("min-interval", "5m") - return nil - }); err != nil { - t.Fatal(err) - } - got, err = svc.FleetDivergence(ctx) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 || !strings.Contains(got[0], "CRQ_MIN_INTERVAL") { - t.Fatalf("divergence = %v, want the matching host copy named", got) - } -} - -// A policy a host feeds from a legacy alias or a per-bot key diverges exactly as -// the canonical variable does — and the remedy names the variable that is -// actually there, since that is the one still waiting to be fallen back on. -func TestFleetDivergenceNamesTheVariableTheHostSet(t *testing.T) { - for _, tc := range []struct { - name string - key string - value string - env string - host func(*Config) - }{ - { - name: "legacy alias", - key: "rate-limit-co-degrade", - value: "1", - env: "CRQ_RL_CODEX_DEGRADE", - host: func(cfg *Config) { cfg.RateLimitCoDegrade = false }, - }, - { - name: "per-bot required key", - key: "required-bots", - value: "coderabbitai[bot]", - env: "CRQ_COBOT_BUGBOT_REQUIRED", - host: func(cfg *Config) { - cfg.RequiredBots = []string{"coderabbitai[bot]", dialect.BugbotLogin} - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - tc.host(&cfg) - cfg.ExplicitFleetEnv = map[string]bool{tc.env: true} - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue(tc.key, tc.value) - return nil - }); err != nil { - t.Fatal(err) - } - got, err := NewService(cfg, newFakeGitHub(), store, nil).FleetDivergence(ctx) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 || !strings.Contains(got[0], tc.env) { - t.Fatalf("divergence = %v, want it to name %s", got, tc.env) - } - }) - } -} - -func TestFleetRevisionInvalidatesAStaleDecision(t *testing.T) { - cfg := firingConfig() - st := DefaultState(cfg) - svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) - snapshot := svc.cfgFor(st, "owner/repo") - st.SetFleetValue("min-interval", "5m") - if !overrideChanged(&st, "owner/repo", snapshot) { - t.Error("a fleet policy change did not invalidate the earlier decision") - } -} - -type dashboardConfigStore struct { - StateStore - render StoreConfig - syncs int -} - -func (s *dashboardConfigStore) SyncDashboard(_ context.Context, _ State, cfg StoreConfig) error { - s.render = cfg - s.syncs++ - return nil -} - -func TestFleetMutationsSyncTheDashboard(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - cfg.DashboardIssue = 1 - store := &dashboardConfigStore{StateStore: NewMemoryStore(cfg)} - svc := NewService(cfg, newFakeGitHub(), store, &recordingLogger{}) - - if err := svc.SetFleetConfig(ctx, "min-interval", "5m"); err != nil { - t.Fatal(err) - } - if store.syncs != 1 { - t.Fatalf("dashboard syncs after set = %d, want 1", store.syncs) - } - if dropped, err := svc.UnsetFleetConfig(ctx, "min-interval"); err != nil || !dropped { - t.Fatalf("unset = %v, %v", dropped, err) - } - if store.syncs != 2 { - t.Fatalf("dashboard syncs after unset = %d, want 2", store.syncs) - } - if seeded, err := svc.SeedFleetConfig(ctx); err != nil || len(seeded) == 0 { - t.Fatalf("seed = %v, %v", seeded, err) - } - if store.syncs != 3 { - t.Fatalf("dashboard syncs after seed = %d, want 3", store.syncs) - } -} - -func TestDashboardSyncReceivesTheEffectiveFleetReviewers(t *testing.T) { - ctx := context.Background() - cfg := isolatedConfig(t, map[string]string{ - "CRQ_COBOTS": "codex", - "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", - }) - cfg.DashboardIssue = 1 - inner := NewMemoryStore(cfg) - store := &dashboardConfigStore{StateStore: inner} - if _, err := inner.Update(ctx, func(st *State) error { - st.SetFleetValue("required-bots", "coderabbitai[bot],"+dialect.CodexBotLogin) - return nil - }); err != nil { - t.Fatal(err) - } - st, _, err := inner.Load(ctx) - if err != nil { - t.Fatal(err) - } - svc := NewService(cfg, newFakeGitHub(), store, &recordingLogger{}) - svc.sync(ctx, st) - if !strings.Contains(store.render.CoReviewers, "codex (required") { - t.Fatalf("dashboard co-reviewers = %q, want fleet-required codex", store.render.CoReviewers) - } -} - -func TestFleetSettleWindowShapesNext(t *testing.T) { - cfg := firingConfig() - cfg.SettleWindow = 0 - st := DefaultState(cfg) - st.SetFleetValue("settle", "5m") - effective := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil).fleetCfg(st) - evidence := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) - got := settleUntil(FeedbackReport{LastEvidenceAt: evidence}, effective.SettleWindow) - want := evidence.Add(5 * time.Minute) - if got == nil || !got.Equal(want) { - t.Fatalf("settle until = %v, want %s", got, want) - } -} - -func TestFleetPolicyRefusesALaggingQueueDriver(t *testing.T) { - ctx := context.Background() - now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) - cfg := firingConfig() - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.Leader = &LeaderLease{Owner: "old-daemon", ExpiresAt: now.Add(time.Minute)} - st.NoteWriter("old-daemon", CapsFleetPolicy-1, now) - return nil - }); err != nil { - t.Fatal(err) - } - svc := NewService(cfg, newFakeGitHub(), store, nil) - svc.now = func() time.Time { return now } - if err := svc.SetFleetConfig(ctx, "min-interval", "5m"); err == nil || !strings.Contains(err.Error(), "lack fleet-policy support") { - t.Fatalf("set with a lagging driver = %v, want a capability refusal", err) - } -} - -func TestFleetReviewerChangeReopensCompletedRounds(t *testing.T) { - ctx := context.Background() - cfg := isolatedConfig(t, map[string]string{ - "CRQ_COBOTS": "codex", - "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", - }) - repo, pr := "owner/repo", 7 - gh := newFakeGitHub() - gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.PutRound(Round{Repo: repo, PR: pr, Head: "abcdef123", Phase: PhaseCompleted}) - return nil - }); err != nil { - t.Fatal(err) - } - svc := NewService(cfg, gh, store, nil) - if err := svc.SetFleetConfig(ctx, "required-bots", "coderabbitai[bot],"+dialect.CodexBotLogin); err != nil { - t.Fatal(err) - } - st, _, _ := store.Load(ctx) - if round := st.Round(repo, pr); round == nil || round.Phase != PhaseQueued { - t.Fatalf("round = %+v, want completed round reopened", round) - } -} - -func TestFleetReviewerChangeDoesNotFailOnAnInaccessibleHistoricalRepository(t *testing.T) { - ctx := context.Background() - cfg := isolatedConfig(t, map[string]string{ - "CRQ_COBOTS": "codex", - "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", - }) - gh := newFakeGitHub() - gh.searchPRs = []ghapi.SearchPR{{Repo: "owner/live", Number: 7}} - gh.listPullErrs["owner/gone"] = &ghapi.APIError{Status: http.StatusForbidden, Body: "resource not accessible"} - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.PutRound(Round{Repo: "owner/gone", PR: 6, Head: "aaaaaaa11", Phase: PhaseCompleted}) - st.PutRound(Round{Repo: "owner/live", PR: 7, Head: "bbbbbbb22", Phase: PhaseCompleted}) - return nil - }); err != nil { - t.Fatal(err) - } - svc := NewService(cfg, gh, store, nil) - if err := svc.SetFleetConfig(ctx, "required-bots", "coderabbitai[bot],"+dialect.CodexBotLogin); err != nil { - t.Fatal(err) - } - st, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if round := st.Round("owner/live", 7); round == nil || round.Phase != PhaseQueued { - t.Fatalf("live round = %+v, want it reopened", round) - } - if round := st.Round("owner/gone", 6); round == nil || !round.ReviewersChanged || round.Phase != PhaseCompleted { - t.Fatalf("inaccessible historical round = %+v, want a marked completed round", round) - } -} - -// A co-reviewer's command, trigger mode or self-heal grace moves no membership, -// so reconciliation compares the same reviewer sets before and after and never -// reads the open-PR map. Building one anyway spent a REST lookup on every -// repository ever recorded in Rounds — and made a purely local timing update -// fail whenever one of those historical lookups was throttled. -func TestFleetCoBotTimingChangeScansNoPullRequests(t *testing.T) { - ctx := context.Background() - cfg := isolatedConfig(t, map[string]string{ - "CRQ_COBOTS": "codex,bugbot", - "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", - }) - gh := newFakeGitHub() - gh.listPullErrs["owner/repo"] = &ghapi.RateLimitError{Kind: "secondary"} - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.PutRound(Round{Repo: "owner/repo", PR: 7, Head: "bbbbbbb22", Phase: PhaseCompleted}) - return nil - }); err != nil { - t.Fatal(err) - } - svc := NewService(cfg, gh, store, nil) - for _, key := range []string{"cobot-bugbot-grace", "cobot-bugbot-cmd", "cobot-bugbot-trigger"} { - value := map[string]string{ - "cobot-bugbot-grace": "12m", - "cobot-bugbot-cmd": "bugbot run", - "cobot-bugbot-trigger": "selfheal", - }[key] - if err := svc.SetFleetConfig(ctx, key, value); err != nil { - t.Fatalf("set %s: %v", key, err) - } - if _, err := svc.UnsetFleetConfig(ctx, key); err != nil { - t.Fatalf("unset %s: %v", key, err) - } - } -} - -// An idempotent membership command scans nothing either. Re-recording the value -// the fleet already holds, or unsetting a key it never held, reconciles nothing -// — and a fleet whose round history names many repositories would otherwise -// spend an open-PR lookup on each of them and fail outright when one was -// throttled, for a command with nothing to change. -func TestIdempotentFleetReviewerCommandScansNoPullRequests(t *testing.T) { - ctx := context.Background() - cfg := isolatedConfig(t, map[string]string{ - "CRQ_COBOTS": "codex", - "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", - }) - gh := newFakeGitHub() - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.PutRound(Round{Repo: "owner/repo", PR: 7, Head: "bbbbbbb22", Phase: PhaseCompleted}) - return nil - }); err != nil { - t.Fatal(err) - } - svc := NewService(cfg, gh, store, nil) - if err := svc.SetFleetConfig(ctx, "required-bots", "coderabbitai[bot]"); err != nil { - t.Fatalf("recording required-bots: %v", err) - } - // Only now does the lookup fail, so reaching it at all is the failure. - gh.listPullErrs["owner/repo"] = &ghapi.RateLimitError{Kind: "secondary"} - if err := svc.SetFleetConfig(ctx, "required-bots", "coderabbitai[bot]"); err != nil { - t.Fatalf("repeating the recorded value: %v", err) - } - if dropped, err := svc.UnsetFleetConfig(ctx, "cobots"); err != nil || dropped { - t.Fatalf("unsetting a key the fleet never recorded = (%v, %v), want (false, nil)", dropped, err) - } -} - -func TestFleetReviewerChangePropagatesGitHubThrottling(t *testing.T) { - ctx := context.Background() - cfg := isolatedConfig(t, map[string]string{ - "CRQ_COBOTS": "codex", - "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", - }) - gh := newFakeGitHub() - gh.listPullErrs["owner/repo"] = &ghapi.RateLimitError{Kind: "secondary"} - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.PutRound(Round{Repo: "owner/repo", PR: 7, Head: "bbbbbbb22", Phase: PhaseCompleted}) - return nil - }); err != nil { - t.Fatal(err) - } - svc := NewService(cfg, gh, store, nil) - err := svc.SetFleetConfig(ctx, "required-bots", "coderabbitai[bot],"+dialect.CodexBotLogin) - if !ghapi.IsThrottled(err) { - t.Fatalf("reviewer change error = %v, want GitHub throttling propagated", err) - } -} - -func TestInaccessibleRepoLookupClassification(t *testing.T) { - tests := []struct { - name string - err error - want bool - }{ - {name: "not found", err: ghapi.ErrNotFound, want: true}, - {name: "forbidden", err: &ghapi.APIError{Status: http.StatusForbidden}, want: true}, - {name: "unprocessable", err: &ghapi.APIError{Status: http.StatusUnprocessableEntity}, want: false}, - {name: "primary throttle", err: &ghapi.RateLimitError{Kind: "primary"}, want: false}, - {name: "secondary throttle", err: &ghapi.RateLimitError{Kind: "secondary"}, want: false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := inaccessibleRepoLookup(tt.err); got != tt.want { - t.Fatalf("inaccessibleRepoLookup(%v) = %v, want %v", tt.err, got, tt.want) - } - }) - } -} - -// A key only a newer crq knows is the one divergence this host cannot act on: -// applyFleet ignores it, and reporting only the settings this binary understands -// would have `crq config` and `crq doctor` both call the host fully in step -// while it silently drops part of the shared policy. -func TestFleetConfigReportsSettingsOnlyANewerCrqKnows(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - cfg.ExplicitFleetEnv = map[string]bool{} - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("some-future-knob", "7") - return nil - }); err != nil { - t.Fatal(err) - } - svc := NewService(cfg, newFakeGitHub(), store, nil) - - items, err := svc.FleetConfig(ctx) - if err != nil { - t.Fatal(err) - } - var reported *FleetSetting - for i, item := range items { - if item.Key == "some-future-knob" { - reported = &items[i] - } - } - if reported == nil || !reported.Unknown || reported.Error == "" { - t.Fatalf("crq config must report the recorded key it ignores, got %+v", items) - } - diverged, err := svc.FleetDivergence(ctx) - if err != nil { - t.Fatal(err) - } - if len(diverged) != 1 || !strings.Contains(diverged[0], "some-future-knob") { - t.Fatalf("doctor divergence = %v, want the ignored setting named", diverged) - } - - // And the remedy doctor names has to work: refusing to unset a key this - // binary does not know would leave it unremovable from every older host. - if dropped, err := svc.UnsetFleetConfig(ctx, "some-future-knob"); err != nil || !dropped { - t.Fatalf("unset = %v %v, want the recorded key dropped", dropped, err) - } - if _, err := svc.UnsetFleetConfig(ctx, "never-recorded"); err == nil { - t.Fatal("a key that is neither known nor recorded is still a typo") - } -} diff --git a/internal/crq/fleetsettings.go b/internal/crq/fleetsettings.go new file mode 100644 index 00000000..4ba9bea5 --- /dev/null +++ b/internal/crq/fleetsettings.go @@ -0,0 +1,1157 @@ +package crq + +import ( + "context" + "errors" + "fmt" + "maps" + "sort" + "strconv" + "strings" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" +) + +// FleetView is the fleet's effective defaults and where each one comes from. +type FleetView struct { + // Reviewers is the default set every repository inherits, resolved the same + // way a per-repo view is, so the two can be read side by side. + Reviewers []ReviewerDetail `json:"reviewers"` + // Recorded says a fleet record exists at all; without one every value below + // is this host's env and changing it means editing a file on each machine. + Recorded bool `json:"recorded"` + MinInterval string `json:"min_interval"` + WeeklyLimit int `json:"weekly_limit"` + // AutofixDefault is whether a repository with no explicit switch is fixed. + AutofixDefault bool `json:"autofix_default"` + // Sources names, per setting, whether the value came from the record or from + // this host's env — the distinction that decides whether changing it here + // will actually change anything for the other hosts. + Sources map[string]string `json:"sources"` + // Overriding names the repositories that have their own answer, so a fleet + // default can say who it does NOT reach. A count with no names is a number + // you cannot act on. + Overriding []string `json:"overriding,omitempty"` + + By string `json:"by,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Lagging []string `json:"lagging_hosts,omitempty"` +} + +// FleetImpact is what a proposed change would do, in product terms, before it +// is made. The plan for the dashboard asked for this and it is the reason the +// fleet form is a separate verb from the per-repo one: a per-repo save affects +// the repository you are looking at, and a fleet save affects every repository +// that has not overridden the setting — which is most of them. +type FleetImpact struct { + // Rev binds a preview to the state it described. + Rev int64 `json:"rev"` + // Repos is how many repositories inherit the changed setting. + Repos int `json:"repos"` + // Reopened is how many completed rounds a reviewer change would requeue, + // because their heads would suddenly be missing a required answer. + Reopened int `json:"reopened"` + // Overridden is how many repositories would NOT be affected, because they + // have their own answer already. + Overridden int `json:"overridden"` + Changes []string `json:"changes"` + Summary string `json:"summary"` +} + +// FleetSettings reports the fleet's effective defaults. +func (s *Service) FleetSettings(ctx context.Context) (FleetView, error) { + st, _, err := s.store.Load(ctx) + if err != nil { + return FleetView{}, err + } + return s.fleetViewOf(st), nil +} + +func (s *Service) fleetViewOf(st State) FleetView { + cfg := s.cfg.WithFleet(st.Fleet) + view := FleetView{ + Recorded: !st.Fleet.Empty(), + MinInterval: cfg.MinInterval.String(), + WeeklyLimit: cfg.WeeklyReviewLimit, + AutofixDefault: st.AutofixDefaultOn(), + Reviewers: []ReviewerDetail{}, + Sources: map[string]string{}, + } + // "env" and "default" are different answers and the page must not merge + // them: telling someone a value comes from their env file, when nothing in + // that file mentions it, sends them looking for a line that is not there. + host := s.cfg.Env() + from := func(key string, recorded bool, envKeys ...string) { + switch { + case recorded: + view.Sources[key] = "fleet" + default: + view.Sources[key] = "default" + for _, ek := range envKeys { + if _, ok := host[ek]; ok { + view.Sources[key] = "env" + break + } + } + } + } + recorded := func(keys ...string) bool { + for _, key := range keys { + if fleetGoverns(st.Fleet, key) { + return true + } + } + return false + } + from("reviewers", recorded("CRQ_BOT", "CRQ_COBOTS", "CRQ_REQUIRED_BOTS"), "CRQ_BOT", "CRQ_COBOTS", "CRQ_REQUIRED_BOTS") + from("min_interval", recorded("CRQ_MIN_INTERVAL"), "CRQ_MIN_INTERVAL") + from("weekly_limit", recorded("CRQ_WEEKLY_LIMIT"), "CRQ_WEEKLY_LIMIT") + from("autofix_default", st.Fleet.AutofixDefault != nil) + + for _, r := range cfg.Reviewers { + view.Reviewers = append(view.Reviewers, ReviewerDetail{ + Login: r.Login, Budget: string(r.Budget), Required: r.Required, Trigger: string(r.Trigger), + }) + } + for repo := range st.Repos { + if ov, ok := st.RepoOverride(repo); ok && (ov.SetCoBots || ov.SetRequired || ov.PrimaryOff) { + view.Overriding = append(view.Overriding, repo) + } + } + sort.Strings(view.Overriding) + if st.Fleet.UpdatedAt != nil { + view.By = st.Fleet.By + view.UpdatedAt = st.Fleet.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z") + // The autofix watcher consumes fleet defaults too, but holds neither the + // autoreview leader lease nor the fire slot. Include its role report or + // an old watcher can ignore an autofix-off switch while this page says + // every acting host understands the record. + view.Lagging = st.LaggingRoleWriters(CapsFleetDefaults, s.clock().UTC(), "autofix") + } + return view +} + +// FleetSettingsIn resolves the fleet view from an already-loaded state so one +// dashboard snapshot cannot mix two revisions or pay for the state ref twice. +func (s *Service) FleetSettingsIn(st State) FleetView { + return s.fleetViewOf(st) +} + +// FleetChange is a proposed edit. Every field is a pointer or a nil slice +// meaning "leave this one alone": a form that posts its whole state would +// otherwise overwrite a setting another host changed a second earlier. +type FleetChange struct { + CoBots []string `json:"cobots"` + Required []string `json:"required"` + MinInterval *string `json:"min_interval"` + WeeklyLimit *int `json:"weekly_limit"` + AutofixDefault *bool `json:"autofix_default"` + // ExpectedRev makes a confirmed dashboard preview a compare-and-swap. + // CLI callers omit it and retain the ordinary latest-state behavior. + ExpectedRev *int64 `json:"expected_rev,omitempty"` + // Unset* removes one typed field from the record, handing that setting back + // to each host's env. "Leave this alone" and "the fleet has no answer" are + // different instructions, and a nil pointer or slice can only express the + // first — so unsetting a co-reviewer list used to report success and change + // nothing. + UnsetCoBots bool `json:"unset_cobots,omitempty"` + UnsetRequired bool `json:"unset_required,omitempty"` + UnsetMinInterval bool `json:"unset_min_interval,omitempty"` + UnsetWeeklyLimit bool `json:"unset_weekly_limit,omitempty"` + // Clear drops the whole record, returning every setting to this host's env. + Clear bool `json:"clear"` +} + +// PreviewFleet reports what a change would do without making it. +func (s *Service) PreviewFleet(ctx context.Context, change FleetChange) (FleetImpact, error) { + st, _, err := s.store.Load(ctx) + if err != nil { + return FleetImpact{}, err + } + next, err := s.applyFleetChange(st, change) + if err != nil { + return FleetImpact{}, err + } + open, err := s.openPRsForReviewerChange(ctx, st, next) + if err != nil { + return FleetImpact{}, err + } + return s.fleetImpact(st, next, open), nil +} + +// SetFleetSettings records the change and requeues whatever it invalidates. +func (s *Service) SetFleetSettings(ctx context.Context, change FleetChange) (FleetView, FleetImpact, error) { + st, _, err := s.store.Load(ctx) + if err != nil { + return FleetView{}, FleetImpact{}, err + } + if err := checkFleetPreviewRevision(st, change.ExpectedRev); err != nil { + return FleetView{}, FleetImpact{}, err + } + next, err := s.applyFleetChange(st, change) + if err != nil { + return FleetView{}, FleetImpact{}, err + } + // Read before the write, for the same reason SetReviewers does: the requeue + // may only touch live pull requests and the CAS closure cannot ask GitHub. + open, err := s.openPRsForReviewerChange(ctx, st, next) + if err != nil { + return FleetView{}, FleetImpact{}, err + } + impact := s.fleetImpact(st, next, open) + + now := s.clock().UTC() + written, err := s.store.Update(ctx, func(st *State) error { + if err := checkFleetPreviewRevision(*st, change.ExpectedRev); err != nil { + return err + } + var applied FleetDefaults + if change.Clear { + if st.Fleet.Empty() { + return ErrNoChange + } + } else { + var aerr error + applied, aerr = s.applyFleetChange(*st, change) + if aerr != nil { + return aerr + } + } + if err := s.rejectClaimedReviewerChanges(st, applied, now); err != nil { + return err + } + before := map[string]Config{} + for _, repo := range s.reposWithChangedReviewers(*st, applied) { + before[repo] = s.cfgFor(*st, repo) + } + if change.Clear { + st.Fleet = FleetDefaults{} + } else { + st.SetFleetDefaults(applied, s.cfg.Host, now) + } + for repo, was := range before { + s.reopenForChangedReviewers(st, repo, was, s.cfgFor(*st, repo), open[repo]) + } + return nil + }) + if err != nil && !errors.Is(err, ErrNoChange) { + return FleetView{}, FleetImpact{}, err + } + if err == nil { + s.sync(ctx, written) + } else { + written, _, err = s.store.Load(ctx) + if err != nil { + return FleetView{}, FleetImpact{}, err + } + } + return s.fleetViewOf(written), impact, nil +} + +func checkFleetPreviewRevision(st State, expected *int64) error { + if expected != nil && st.Rev != *expected { + return fmt.Errorf("fleet state moved from revision %d to %d; preview the change again", *expected, st.Rev) + } + return nil +} + +// applyFleetChange folds a change onto the current record, validating it. It +// does not write, so preview and save cannot disagree about what a change means. +func (s *Service) applyFleetChange(st State, change FleetChange) (FleetDefaults, error) { + if change.Clear { + return FleetDefaults{}, nil + } + existingRequired := s.cfg.WithFleet(st.Fleet).RequiredBots + fd := st.Fleet + switch { + case change.UnsetCoBots: + fd.CoBots, fd.SetCoBots = nil, false + case change.CoBots != nil: + resolved, err := resolveCoBotLogins(change.CoBots) + if err != nil { + return fd, err + } + fd.CoBots, fd.SetCoBots = resolved, true + } + switch { + case change.UnsetRequired: + fd.Required, fd.SetRequired = nil, false + case change.Required != nil: + if len(change.Required) == 0 { + return fd, errors.New("the required set cannot be empty: a round that gates on nobody converges before any reviewer runs") + } + resolved, err := resolveRequiredLoginsPreserving( + change.Required, s.cfg.WithFleet(fd).Bot, existingRequired) + if err != nil { + return fd, err + } + fd.Required, fd.SetRequired = resolved, true + if len(s.cfg.WithFleet(fd).RequiredBots) == 0 { + return fd, errors.New("the required set resolves to no enabled reviewer") + } + } + switch { + case change.UnsetMinInterval: + fd.MinInterval = "" + case change.MinInterval != nil: + text := strings.TrimSpace(*change.MinInterval) + d, err := time.ParseDuration(text) + if err != nil { + return fd, fmt.Errorf("min interval: %w", err) + } + if d < 0 { + return fd, errors.New("min interval cannot be negative") + } + // The pacing floor is the fleet's protection against spending the + // account faster than the vendor will refill it. A very small one is + // legal but worth refusing to set by accident. + if d > 0 && d < 5*time.Second { + return fd, errors.New("min interval below 5s would fire faster than any review completes") + } + fd.MinInterval = d.String() + } + switch { + case change.UnsetWeeklyLimit: + fd.WeeklyLimit = nil + case change.WeeklyLimit != nil: + if *change.WeeklyLimit < 0 { + return fd, errors.New("weekly limit cannot be negative") + } + limit := *change.WeeklyLimit + fd.WeeklyLimit = &limit + } + if change.AutofixDefault != nil { + on := *change.AutofixDefault + fd.AutofixDefault = &on + } + return fd, nil +} + +// fleetImpact describes, in product terms, what moving from st to next would do. +func (s *Service) fleetImpact(st State, next FleetDefaults, open map[string]map[int]bool) FleetImpact { + after := st + after.Fleet = next + impact := FleetImpact{Rev: st.Rev, Changes: []string{}} + + // Which repositories a change reaches depends on WHICH setting changed, and + // the answers differ: a reviewer default stops at a repository that answers + // both reviewer questions itself, the autofix default stops at one with its + // own switch, and pacing or the weekly limit stop nowhere at all — they are + // account-wide. Counting reviewer overrides for every kind of change told an + // operator turning autofix on that the repository with custom reviewers was + // "unaffected", moments before agents started running in it. + all := s.fleetRepos(st) + following := s.reposFollowingFleet(st) + affected := map[string]bool{} + reaches := func(repos []string) { + for _, repo := range repos { + affected[repo] = true + } + } + + beforeCfg, afterCfg := s.cfg.WithFleet(st.Fleet), s.cfg.WithFleet(next) + changedReviewers := s.reposWithChangedReviewers(st, next) + // Which reviewers RUN and which of them GATE are separate questions and + // both are changes: turning a bot off stops its findings arriving at all, + // which is not the same as it merely no longer holding the round open. + beforeCo := beforeCfg.reviewerLogins(func(r Reviewer) bool { return !r.Metered() }) + afterCo := afterCfg.reviewerLogins(func(r Reviewer) bool { return !r.Metered() }) + if !sameBot(beforeCfg.Bot, afterCfg.Bot) { + impact.Changes = append(impact.Changes, fmt.Sprintf("primary reviewer: %s → %s", + shortBots([]string{beforeCfg.Bot}), shortBots([]string{afterCfg.Bot}))) + reaches(changedReviewers) + } + if !sameLogins(beforeCo, afterCo) { + impact.Changes = append(impact.Changes, fmt.Sprintf("co-reviewers running: %s → %s", + shortBots(beforeCo), shortBots(afterCo))) + reaches(following) + } + if !sameLogins(beforeCfg.RequiredBots, afterCfg.RequiredBots) { + impact.Changes = append(impact.Changes, fmt.Sprintf("required reviewers: %s → %s", + shortBots(beforeCfg.RequiredBots), shortBots(afterCfg.RequiredBots))) + reaches(following) + } + if sameBot(beforeCfg.Bot, afterCfg.Bot) && sameLogins(beforeCo, afterCo) && + !sameTriggerSettings(beforeCfg.Reviewers, afterCfg.Reviewers) { + impact.Changes = append(impact.Changes, "reviewer trigger policy or command changed") + reaches(changedReviewers) + } + if beforeCfg.MinInterval != afterCfg.MinInterval { + impact.Changes = append(impact.Changes, + fmt.Sprintf("pacing: %s → %s", beforeCfg.MinInterval, afterCfg.MinInterval)) + reaches(all) // one queue, one fire slot: pacing is not a per-repo answer + } + if beforeCfg.WeeklyReviewLimit != afterCfg.WeeklyReviewLimit { + impact.Changes = append(impact.Changes, + fmt.Sprintf("weekly limit: %d → %d", beforeCfg.WeeklyReviewLimit, afterCfg.WeeklyReviewLimit)) + reaches(all) // one account allowance, likewise + } + if st.AutofixDefaultOn() != after.AutofixDefaultOn() { + impact.Changes = append(impact.Changes, + fmt.Sprintf("autofix default: %s → %s", onOff(st.AutofixDefaultOn()), onOff(after.AutofixDefaultOn()))) + // A default reaches every repository without a switch of its own, + // whatever it may have decided about its reviewers. + for _, repo := range all { + if _, explicit := st.AutofixSwitch(repo); !explicit { + affected[repo] = true + } + } + } + impact.Repos = len(affected) + if len(impact.Changes) > 0 { + for _, repo := range all { + if !affected[repo] { + impact.Overridden++ + } + } + } + + // A completed round is the "this head was reviewed" marker. Requiring a + // reviewer it never had means that marker is now wrong, and the round has to + // be reopened — which is the consequence worth stating before the click. + for _, repo := range changedReviewers { + wasCfg, isCfg := s.cfgFor(st, repo), s.cfgFor(after, repo) + wasCo := wasCfg.reviewerLogins(func(r Reviewer) bool { return !r.Metered() }) + isCo := isCfg.reviewerLogins(func(r Reviewer) bool { return !r.Metered() }) + // The same question reopenForChangedReviewers asks, or the preview + // warns about work that will not happen: only ADDING a reviewer + // invalidates a finished round, so a narrowing reopens nothing. + if !addedReviewers(wasCfg, isCfg, wasCo, isCo) { + continue + } + for _, r := range st.Rounds { + if NormalizeRepo(r.Repo) == repo && r.Phase == PhaseCompleted && open[repo][r.PR] { + impact.Reopened++ + } + } + } + + switch { + case len(impact.Changes) == 0: + impact.Summary = "nothing would change" + case impact.Reopened > 0: + impact.Summary = fmt.Sprintf("affects %d repositories; %d completed round(s) would be reopened and reviewed again", + impact.Repos, impact.Reopened) + default: + impact.Summary = fmt.Sprintf("affects %d repositories; no round would be reopened", impact.Repos) + } + if impact.Overridden > 0 { + impact.Summary += fmt.Sprintf(" (%d with their own answer to what changed are unaffected)", impact.Overridden) + } + return impact +} + +// openPRsForReviewerChange reads the live set only for repositories where an +// added reviewer invalidates completed dedup markers. Both the preview count and +// the subsequent write use this same set, so the dialog describes work that +// will actually be requeued rather than every historical completed round. +func (s *Service) openPRsForReviewerChange(ctx context.Context, st State, next FleetDefaults) (map[string]map[int]bool, error) { + after := st + after.Fleet = next + open := map[string]map[int]bool{} + for _, repo := range s.reposWithChangedReviewers(st, next) { + wasCfg, isCfg := s.cfgFor(st, repo), s.cfgFor(after, repo) + wasCo := wasCfg.reviewerLogins(func(r Reviewer) bool { return !r.Metered() }) + isCo := isCfg.reviewerLogins(func(r Reviewer) bool { return !r.Metered() }) + if !addedReviewers(wasCfg, isCfg, wasCo, isCo) { + continue + } + hasCompleted := false + for _, r := range st.Rounds { + if NormalizeRepo(r.Repo) == repo && r.Phase == PhaseCompleted { + hasCompleted = true + break + } + } + if !hasCompleted { + continue + } + prs, err := s.openPRs(ctx, repo) + if err != nil { + return nil, err + } + open[repo] = prs + } + return open, nil +} + +// reposWithChangedReviewers resolves both sides for every active repository. +// This is intentionally broader than reposFollowingFleet: a repository can +// override both co-reviewer choices and still inherit the primary, so clearing +// a fleet CRQ_BOT reaches it unless PrimaryOff says otherwise. +func (s *Service) reposWithChangedReviewers(st State, next FleetDefaults) []string { + after := st + after.Fleet = next + var out []string + for _, repo := range s.fleetRepos(st) { + if !sameReviewers(s.cfgFor(st, repo), s.cfgFor(after, repo)) { + out = append(out, repo) + } + } + return out +} + +// fullyOverridesReviewers reports whether repo answers BOTH reviewer questions +// itself, so no fleet reviewer default reaches it. +// +// The test is AND, not OR, and that is the whole point: a repository that +// overrides only its co-reviewers still inherits the fleet's required set, and +// vice versa. Treating either half as a complete override dropped those +// repositories from the impact preview and from the requeue — cfgFor handed +// them the new reviewer, their completed round stayed a "this head was +// reviewed" marker, and the reviewer somebody had just required was never +// asked. +func fullyOverridesReviewers(st State, repo string) bool { + ov, ok := st.RepoOverride(repo) + return ok && ov.SetCoBots && ov.SetRequired +} + +// reposFollowingFleet is every repository crq knows about that inherits at +// least one half of the fleet's REVIEWER default — the ones a change to it can +// actually reach. Settings that are not per repository (pacing, the weekly +// limit) reach fleetRepos instead; this exclusion is about reviewers only. +func (s *Service) reposFollowingFleet(st State) []string { + return s.fleetReposWhere(st, func(repo string) bool { + return !fullyOverridesReviewers(st, repo) + }) +} + +// fleetRepos is every repository crq knows about and would act on at all. +func (s *Service) fleetRepos(st State) []string { + return s.fleetReposWhere(st, func(string) bool { return true }) +} + +func (s *Service) fleetReposWhere(st State, keep func(repo string) bool) []string { + seen := map[string]bool{} + var out []string + add := func(repo string) { + repo = NormalizeRepo(repo) + if repo == "" || seen[repo] { + return + } + if !keep(repo) { + return + } + // Use the complete effective decision: shared enrollment, the host's + // absolute exclusion list, scope, and gate-repository protection. + if !s.reviewsRepo(st, repo) { + return + } + seen[repo] = true + out = append(out, repo) + } + for repo := range s.cfg.AllowRepos { + add(repo) + } + for _, repo := range st.EnrolledRepos() { + add(repo) + } + for _, r := range st.Rounds { + add(r.Repo) + } + sort.Strings(out) + return out +} + +func onOff(on bool) string { + if on { + return "on" + } + return "off" +} + +// shortBots renders a login list the way a person reads it. +func shortBots(logins []string) string { + if len(logins) == 0 { + return "none" + } + out := make([]string, 0, len(logins)) + for _, l := range logins { + out = append(out, dialect.NormalizeBotName(l)) + } + return strings.Join(out, ", ") +} + +// AdoptedSetting is one value moved from this host's environment into the +// fleet record. +type AdoptedSetting struct { + Key string `json:"key"` + Value string `json:"value"` + // Skipped says why a setting was left alone, when it was. + Skipped string `json:"skipped,omitempty"` +} + +// AdoptEnv records this host's settings for the whole fleet. +// +// It exists because crq predates the dashboard: a fleet configured before any +// of this has every answer in one machine's env file, and the dashboard says +// "env" beside all of them — which is true, and useless. Adopting copies those +// values into the shared record, so they become the fleet's answer and every +// host reads the same one. +// +// Only settings that CAN be fleet-wide are taken. Identity (which repository +// holds the queue) and per-host values (paths, this machine's name, the fix +// agent's binary) are reported as skipped rather than silently dropped, since +// "why is that one still env" is the obvious next question. +// +// Values equal to the default are skipped too: recording them would pin a +// default that a later crq might improve, and pin it invisibly. +// +// So is a value shared state already decides. The CAS below deliberately leaves +// an existing record alone — it was set on purpose and outranks whatever this +// machine happens to carry — and reporting that as adopted told an operator +// their differing value had become the fleet's when state had kept the old one. +func (s *Service) AdoptEnv(ctx context.Context, dryRun bool) ([]AdoptedSetting, error) { + host := s.cfg.Env() + // Per-bot REQUIRED keys remain valid file-level compatibility aliases, but + // the dashboard has one required-reviewer editor and one typed state field. + // Fold those aliases into that canonical list at the adoption boundary + // rather than publishing controls whose generic writes the typed field + // would silently override. + if strings.TrimSpace(host["CRQ_REQUIRED_BOTS"]) == "" && hasPerBotRequiredEnv(host) { + host["CRQ_REQUIRED_BOTS"] = strings.Join(s.cfg.RequiredBots, ",") + } + defaults, err := BuildConfig(map[string]string{}) + if err != nil { + return nil, err + } + defaultEnv := map[string]string{} + for _, k := range EnvKeys() { + defaultEnv[k.Key] = defaultValueOf(defaults, k.Key) + } + // Read BEFORE the results are built, so what this reports and what the write + // below does are decided from one view of the record. + current, _, err := s.store.Load(ctx) + if err != nil { + return nil, err + } + + var adopted []AdoptedSetting + take := map[string]string{} + for _, k := range EnvKeys() { + value := strings.TrimSpace(host[k.Key]) + invalid := validateEnvValue(k.Key, value) + switch { + case k.Identity: + adopted = append(adopted, AdoptedSetting{Key: k.Key, Value: value, + Skipped: "identity: it says where the queue lives, not how it behaves"}) + case k.PerHost: + adopted = append(adopted, AdoptedSetting{Key: k.Key, Value: value, + Skipped: "per-host: recording one machine's answer would break the others"}) + case value == "": + // Nothing set here, so there is nothing of this host's to adopt. + case invalid != nil: + adopted = append(adopted, AdoptedSetting{Key: k.Key, Value: value, + Skipped: "invalid: " + invalid.Error()}) + case value == defaultEnv[k.Key]: + adopted = append(adopted, AdoptedSetting{Key: k.Key, Value: value, + Skipped: "same as the default: recording it would pin today's default invisibly"}) + case fleetGoverns(current.Fleet, k.Key): + adopted = append(adopted, AdoptedSetting{Key: k.Key, Value: value, + Skipped: "the fleet already records this setting, and a record outranks one host's value"}) + default: + take[k.Key] = value + adopted = append(adopted, AdoptedSetting{Key: k.Key, Value: value}) + } + } + if dryRun || len(take) == 0 { + return adopted, nil + } + + // Adoption can change the effective reviewers through either their typed + // fields or a generic setting such as CRQ_BOT or a trigger policy. Fetch + // the live PR set before the write; the CAS closure cannot ask GitHub. + preview, _ := adoptFleetEnv(current.Fleet, take, s.cfg, nil) + open, err := s.openPRsForReviewerChange(ctx, current, preview) + if err != nil { + return nil, err + } + + now := s.clock().UTC() + // What the write actually landed. The classification above answers from the + // record as it was READ, and between that and the CAS a key can gain a + // record — or a typed value can fail to parse into its field — leaving a + // setting reported as adopted that shared state never took. + applied := map[string]bool{} + st, err := s.store.Update(ctx, func(st *State) error { + clear(applied) // a CAS conflict runs this closure again + before := map[string]Config{} + for _, repo := range s.reposFollowingFleet(*st) { + before[repo] = s.cfgFor(*st, repo) + } + fd, changed := adoptFleetEnv(st.Fleet, take, s.cfg, applied) + if !changed { + return ErrNoChange + } + if err := s.rejectClaimedReviewerChanges(st, fd, now); err != nil { + return err + } + st.SetFleetDefaults(fd, s.cfg.Host, now) + for repo, was := range before { + s.reopenForChangedReviewers(st, repo, was, s.cfgFor(*st, repo), open[repo]) + } + return nil + }) + if err != nil && !errors.Is(err, ErrNoChange) { + return nil, err + } + if err == nil { + s.sync(ctx, st) + } + for i := range adopted { + if adopted[i].Skipped == "" && take[adopted[i].Key] != "" && !applied[adopted[i].Key] { + adopted[i].Skipped = "shared state kept the value it already had" + } + } + return adopted, nil +} + +func hasPerBotRequiredEnv(env map[string]string) bool { + for _, co := range dialect.KnownCoReviewers() { + key := "CRQ_COBOT_" + strings.ToUpper(co.Name) + "_REQUIRED" + if boolEnv(env, key, false) { + return true + } + } + return false +} + +// adoptFleetEnv folds the settings this host offered onto a fleet record. +// Keeping preview and CAS application on one path makes the open-PR prefetch +// describe the reviewer change that can actually land. +func adoptFleetEnv(fd FleetDefaults, take map[string]string, cfg Config, applied map[string]bool) (FleetDefaults, bool) { + fd.Env = maps.Clone(fd.Env) + changed := false + record := func(key string) { + if applied != nil { + applied[key] = true + } + changed = true + } + set := func(key, value string) { + // A record already there was set deliberately and outranks a value this + // host happens to carry. + if fd.Env == nil { + fd.Env = map[string]string{} + } + if _, exists := fd.Env[key]; exists { + return + } + fd.Env[key] = value + record(key) + } + + // Required-reviewer aliases are resolved against the effective primary. + // Apply a newly adopted primary first instead of letting map iteration + // decide which primary CRQ_REQUIRED_BOTS sees. + if value, ok := take["CRQ_BOT"]; ok { + set("CRQ_BOT", value) + } + for key, value := range take { + if key == "CRQ_BOT" { + continue + } + // Four settings have a typed home. Recording them in Env as well would + // give one setting two places to live, one of them shadowed. + switch key { + case "CRQ_COBOTS": + if !fd.SetCoBots { + if logins, err := resolveCoBotLogins(splitCommas(value)); err == nil { + fd.CoBots, fd.SetCoBots = logins, true + record(key) + } + } + case "CRQ_REQUIRED_BOTS": + if !fd.SetRequired { + if logins, err := resolveRequiredLogins(splitCommas(value), cfg.WithFleet(fd).Bot); err == nil { + fd.Required, fd.SetRequired = logins, true + record(key) + } + } + case "CRQ_MIN_INTERVAL": + if fd.MinInterval == "" { + fd.MinInterval = value + record(key) + } + case "CRQ_WEEKLY_LIMIT": + if fd.WeeklyLimit == nil { + if n, err := strconv.Atoi(strings.TrimSpace(value)); err == nil { + fd.WeeklyLimit = &n + record(key) + } + } + default: + set(key, value) + } + } + return fd, changed +} + +// fleetGoverns reports whether the fleet record already decides key, in either +// of the two places a setting can live: its typed field, or the generic map. +// Both have to be asked — a value adopted into the typed field is invisible in +// Env, and reporting it as unrecorded would offer to adopt it again on every +// run. +func fleetGoverns(fd FleetDefaults, key string) bool { + if _, ok := fleetValueOf(fd, key); ok { + return true + } + _, ok := fd.Env[key] + return ok +} + +// defaultValueOf renders what a setting would be with nothing configured, so +// adoption can tell "this host chose this" from "this is just the default". +func defaultValueOf(defaults Config, key string) string { + return configValueOf(defaults, key) +} + +// configValueOf renders a parsed configuration back into the dashboard's +// setting vocabulary. The dashboard must show the effective default too: +// absence in the raw environment is not an empty value (notably for booleans +// whose default is on). +func configValueOf(cfg Config, key string) string { + switch key { + case "CRQ_MIN_INTERVAL": + return cfg.MinInterval.String() + case "CRQ_INFLIGHT_TIMEOUT": + return cfg.InflightTimeout.String() + case "CRQ_RL_FALLBACK": + return cfg.RateLimitFallback.String() + case "CRQ_CALIBRATE_TTL": + return cfg.CalibrationTTL.String() + case "CRQ_WEEKLY_LIMIT": + return fmt.Sprint(cfg.WeeklyReviewLimit) + case "CRQ_AUTOREVIEW_POLL": + return cfg.AutoReviewPoll.String() + case "CRQ_AUTOREVIEW_MAX_SCAN": + return fmt.Sprint(cfg.AutoReviewMaxScan) + case "CRQ_LEADER_TTL": + return cfg.LeaderTTL.String() + case "CRQ_BOT": + return cfg.Bot + case "CRQ_REVIEW_CMD": + return cfg.ReviewCommand + case "CRQ_COBOTS": + names := make([]string, 0, len(cfg.CoBots)) + for _, bot := range cfg.CoBots { + names = append(names, bot.Name) + } + return strings.Join(names, ",") + case "CRQ_REQUIRED_BOTS": + return strings.Join(cfg.RequiredBots, ",") + case "CRQ_FEEDBACK_BOTS": + return strings.Join(cfg.FeedbackBots, ",") + case "CRQ_SETTLE": + return cfg.SettleWindow.String() + case "CRQ_FEEDBACK_WAIT_TIMEOUT": + return cfg.FeedbackWaitTimeout.String() + case "CRQ_RL_CO_DEGRADE": + return boolString(cfg.RateLimitCoDegrade) + case "CRQ_AUTOREVIEW_SKIP_AUTHORS": + return strings.Join(sortedTrueKeys(cfg.SkipAuthors), ",") + case "CRQ_WATCH_INTERVAL": + return cfg.WatchInterval.String() + case "CRQ_DISPATCH_MAX_ATTEMPTS": + return fmt.Sprint(cfg.DispatchMaxAttempts) + case "CRQ_DISPATCH_FORKS": + return boolString(cfg.DispatchForks) + case "CRQ_DISPATCH_CONCURRENCY": + return fmt.Sprint(cfg.DispatchConcurrency) + case "CRQ_AUTOREVIEW_SKIP_MARKER": + return cfg.SkipMarker + case "CRQ_TZ": + return cfg.Timezone + case "CRQ_TIDY": + return boolString(cfg.Tidy) + case "CRQ_REPO": + return cfg.GateRepo + case "CRQ_STATE_REF": + return cfg.StateRef + case "CRQ_ISSUE": + return fmt.Sprint(cfg.DashboardIssue) + case "CRQ_SCOPE": + return strings.Join(cfg.Scope, ",") + case "CRQ_REPOS": + return strings.Join(sortedTrueKeys(cfg.AllowRepos), ",") + case "CRQ_EXCLUDE": + return strings.Join(sortedTrueKeys(cfg.ExcludeRepos), ",") + case "CRQ_HOST": + return cfg.Host + case "CRQ_WORKSPACE": + return cfg.WorkspaceRoot + case "CRQ_DISPATCH_CMD": + return strings.Join(cfg.DispatchCommand, " ") + } + for _, known := range dialect.KnownCoReviewers() { + prefix := "CRQ_COBOT_" + strings.ToUpper(known.Name) + "_" + if !strings.HasPrefix(key, prefix) { + continue + } + bot, _ := cfg.knownCoBot(known.Name) + switch strings.TrimPrefix(key, prefix) { + case "REQUIRED": + return boolString(bot.Required) + case "TRIGGER": + if bot.TriggerExplicit { + return string(bot.Trigger) + } + return "" + case "GRACE": + return bot.SelfHealGrace.String() + case "CMD": + return bot.Command + } + } + return "" +} + +func boolString(value bool) string { + if value { + return "1" + } + return "0" +} + +func sortedTrueKeys(values map[string]bool) []string { + out := make([]string, 0, len(values)) + for value, on := range values { + if on { + out = append(out, value) + } + } + sort.Strings(out) + return out +} + +// normalizeEnvEdit validates one raw setting edit and applies the dashboard and +// CLI convention that a blank non-empty-capable value means "inherit". +func normalizeEnvEdit(key, value string, unset bool) (string, string, bool, error) { + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if !fleetSettable(key) { + if _, known := envKeyByName(key); known { + return "", "", false, fmt.Errorf("%s is not a fleet setting: it belongs to one machine, or says where the queue lives", key) + } + return "", "", false, fmt.Errorf("%s is not a setting crq knows", key) + } + if !unset { + if err := validateEnvValue(key, value); err != nil { + return "", "", false, err + } + } + envKey, _ := envKeyByName(key) + if value == "" && !envKey.AllowEmpty { + unset = true + } + return key, value, unset, nil +} + +// typedFleetChangeForEnv maps the four settings with a typed home into the +// same change model used by the fleet editor. +func typedFleetChangeForEnv(key, value string, unset bool) (FleetChange, bool, error) { + if !typedEnvKey(key) { + return FleetChange{}, false, nil + } + change := FleetChange{} + // Keys with a typed home go there, so one setting never lives in two places + // with one of them silently shadowed by the other. + // Unset is its own instruction, not a change to some other value. Encoding + // it as one meant a cleared list read as "leave it alone" and a cleared + // weekly limit wrote 60 — so a host whose env said 90 never got it back. + switch key { + case "CRQ_COBOTS": + if unset { + change.UnsetCoBots = true + } else { + change.CoBots = append([]string{}, splitCommas(value)...) + } + case "CRQ_REQUIRED_BOTS": + if unset { + change.UnsetRequired = true + } else { + change.Required = splitCommas(value) + } + case "CRQ_MIN_INTERVAL": + if unset { + change.UnsetMinInterval = true + } else { + v := value + change.MinInterval = &v + } + case "CRQ_WEEKLY_LIMIT": + if unset { + change.UnsetWeeklyLimit = true + } else { + n, err := strconv.Atoi(value) + if err != nil { + return FleetChange{}, true, fmt.Errorf("%s: %w", key, err) + } + change.WeeklyLimit = &n + } + } + return change, true, nil +} + +// PreviewEnv reports the reviewer and requeue impact of a raw environment-key +// edit without writing it. +func (s *Service) PreviewEnv(ctx context.Context, key, value string, unset bool) (FleetImpact, error) { + key, value, unset, err := normalizeEnvEdit(key, value, unset) + if err != nil { + return FleetImpact{}, err + } + st, _, err := s.store.Load(ctx) + if err != nil { + return FleetImpact{}, err + } + change, typed, err := typedFleetChangeForEnv(key, value, unset) + if err != nil { + return FleetImpact{}, err + } + var next FleetDefaults + if typed { + next, err = s.applyFleetChange(st, change) + if err != nil { + return FleetImpact{}, err + } + } else { + next = fleetEnvSet(st.Fleet, key, value, unset) + } + open, err := s.openPRsForReviewerChange(ctx, st, next) + if err != nil { + return FleetImpact{}, err + } + return s.fleetImpact(st, next, open), nil +} + +// SetEnv records or clears one fleet setting against the latest state. CLI +// callers use this form; dashboard confirmations use SetEnvAt. +func (s *Service) SetEnv(ctx context.Context, key, value string, unset bool) (FleetView, error) { + view, _, err := s.SetEnvAt(ctx, key, value, unset, nil) + return view, err +} + +// SetEnvAt records a setting only if the confirmed preview revision is still +// current, and returns the impact that was applied. +func (s *Service) SetEnvAt(ctx context.Context, key, value string, unset bool, expectedRev *int64) (FleetView, FleetImpact, error) { + key, value, unset, err := normalizeEnvEdit(key, value, unset) + if err != nil { + return FleetView{}, FleetImpact{}, err + } + change, typed, err := typedFleetChangeForEnv(key, value, unset) + if err != nil { + return FleetView{}, FleetImpact{}, err + } + if typed { + change.ExpectedRev = expectedRev + return s.SetFleetSettings(ctx, change) + } + + // A generic setting can still change WHO reviews: CRQ_BOT names the primary, + // and a per-bot trigger key decides whether a co-reviewer runs at all. That + // is a reviewer change like the typed ones, and a completed round is the + // "this head was reviewed" marker — left alone, the reviewer just configured + // is never asked until somebody happens to push. Read the open pull requests + // before the write, for the same reason SetFleetSettings does: the requeue + // may only touch live pull requests and the CAS closure cannot ask GitHub. + loaded, _, err := s.store.Load(ctx) + if err != nil { + return FleetView{}, FleetImpact{}, err + } + if err := checkFleetPreviewRevision(loaded, expectedRev); err != nil { + return FleetView{}, FleetImpact{}, err + } + next := fleetEnvSet(loaded.Fleet, key, value, unset) + open, err := s.openPRsForReviewerChange(ctx, loaded, next) + if err != nil { + return FleetView{}, FleetImpact{}, err + } + impact := s.fleetImpact(loaded, next, open) + + now := s.clock().UTC() + st, err := s.store.Update(ctx, func(st *State) error { + if err := checkFleetPreviewRevision(*st, expectedRev); err != nil { + return err + } + if _, ok := st.Fleet.Env[key]; unset && !ok { + return ErrNoChange + } + if cur, ok := st.Fleet.Env[key]; !unset && ok && cur == value { + return ErrNoChange + } + before := map[string]Config{} + // Recompute this set from the state revision the write will update. A + // concurrent override clear can make a repository start following the + // fleet after the open-PR prefetch above. It has no prefetched open set, + // so reopenForChangedReviewers conservatively marks its completed rounds + // for reopening when that PR is next observed alive. + applied := fleetEnvSet(st.Fleet, key, value, unset) + if err := s.rejectClaimedReviewerChanges(st, applied, now); err != nil { + return err + } + for _, repo := range s.reposWithChangedReviewers(*st, applied) { + before[repo] = s.cfgFor(*st, repo) + } + st.SetFleetDefaults(applied, s.cfg.Host, now) + for repo, was := range before { + s.reopenForChangedReviewers(st, repo, was, s.cfgFor(*st, repo), open[repo]) + } + return nil + }) + if err != nil && !errors.Is(err, ErrNoChange) { + return FleetView{}, FleetImpact{}, err + } + if err == nil { + s.sync(ctx, st) + } else if st, _, err = s.store.Load(ctx); err != nil { + return FleetView{}, FleetImpact{}, err + } + return s.fleetViewOf(st), impact, nil +} + +// rejectClaimedReviewerChanges keeps a committed configuration edit from being +// followed by a trigger chosen under the old configuration. Co-review posts are +// claimed before their network call, so the claim is the exact interval in +// which a reviewer-changing fleet write must wait. +func (s *Service) rejectClaimedReviewerChanges(st *State, next FleetDefaults, now time.Time) error { + for _, repo := range s.reposWithChangedReviewers(*st, next) { + if claimedTriggerRepo(st, repo, now) { + return fmt.Errorf("a review trigger is already being posted for %s; wait for it to finish before changing fleet reviewers", repo) + } + } + return nil +} + +// fleetEnvSet returns fd with one generic setting recorded or removed. +// +// The map is COPIED rather than written through. A caller comparing the result +// against the record it came from would otherwise be comparing one map with +// itself, and the whole point of building it is to ask what the change would do. +func fleetEnvSet(fd FleetDefaults, key, value string, unset bool) FleetDefaults { + env := make(map[string]string, len(fd.Env)+1) + for k, v := range fd.Env { + env[k] = v + } + if unset { + delete(env, key) + } else { + env[key] = value + } + if len(env) == 0 { + env = nil + } + fd.Env = env + return fd +} + +// splitCommas is the list form every reviewer setting uses. +func splitCommas(value string) []string { + var out []string + for _, part := range strings.Split(value, ",") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + return out +} diff --git a/internal/crq/fleetsettings_test.go b/internal/crq/fleetsettings_test.go new file mode 100644 index 00000000..82cdfe51 --- /dev/null +++ b/internal/crq/fleetsettings_test.go @@ -0,0 +1,1364 @@ +package crq + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" +) + +func TestFleetUnsetInstructionsWinOverValues(t *testing.T) { + minimum := "10m" + savedWeekly := 60 + weekly := 80 + service := &Service{} + got, err := service.applyFleetChange(State{Fleet: FleetDefaults{ + MinInterval: "5m", + WeeklyLimit: &savedWeekly, + }}, FleetChange{ + MinInterval: &minimum, + UnsetMinInterval: true, + WeeklyLimit: &weekly, + UnsetWeeklyLimit: true, + }) + if err != nil { + t.Fatal(err) + } + if got.MinInterval != "" || got.WeeklyLimit != nil { + t.Fatalf("unset did not take precedence: %+v", got) + } +} + +func TestFleetRequiredReviewersResolveAgainstTheEffectivePrimary(t *testing.T) { + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", "CRQ_COBOTS": "", + }) + if err != nil { + t.Fatal(err) + } + const current = "replacement-reviewer[bot]" + st := DefaultState(cfg) + st.Fleet = fleetEnvSet(st.Fleet, "CRQ_BOT", current, false) + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + + next, err := svc.applyFleetChange(st, FleetChange{Required: []string{current}}) + if err != nil { + t.Fatalf("current fleet primary was rejected: %v", err) + } + if got := cfg.WithFleet(next).RequiredBots; len(got) != 1 || !sameBot(got[0], current) { + t.Fatalf("required = %v, want the effective primary", got) + } + if _, err := svc.applyFleetChange(st, FleetChange{Required: []string{cfg.Bot}}); err == nil { + t.Fatal("the retired startup primary was accepted") + } +} + +func TestFleetReviewerEditPreservesExistingCustomRequiredBot(t *testing.T) { + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot],sonar[bot]", + }) + if err != nil { + t.Fatal(err) + } + svc := &Service{cfg: cfg} + next, err := svc.applyFleetChange(DefaultState(cfg), FleetChange{ + Required: []string{"coderabbitai[bot]", "sonar[bot]", "codex"}, + }) + if err != nil { + t.Fatalf("unrelated fleet reviewer edit rejected the existing custom gate: %v", err) + } + if got := strings.Join(next.Required, ","); !strings.Contains(got, "sonar[bot]") { + t.Fatalf("required = %q, want the existing custom gate preserved", got) + } +} + +func TestMigratedFleetScopeAndRepoPolicyStillApplies(t *testing.T) { + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_SCOPE": "local", + "CRQ_REPOS": "local/repo", + "CRQ_EXCLUDE": "local/paused", + }) + if err != nil { + t.Fatal(err) + } + effective := cfg.WithFleet(FleetDefaults{Env: map[string]string{ + "CRQ_SCOPE": "owner,friend", + "CRQ_REPOS": "owner/repo,friend/private", + "CRQ_EXCLUDE": "owner/paused", + }}) + if strings.Join(effective.Scope, ",") != "owner,friend" || + !effective.AllowRepos["owner/repo"] || !effective.AllowRepos["friend/private"] || + !effective.ExcludeRepos["owner/paused"] { + t.Fatalf("migrated v4 fleet policy was not applied: %+v", effective) + } +} + +func TestEnvSettingsRenderEffectiveDefaults(t *testing.T) { + cfg, err := BuildConfig(map[string]string{"CRQ_REPO": "owner/gate"}) + if err != nil { + t.Fatal(err) + } + settings := (&Service{cfg: cfg}).EnvSettings(State{}) + foundCalibration := false + foundDegrade := false + for _, setting := range settings { + if setting.Key == "CRQ_CALIBRATE_TTL" { + foundCalibration = true + if setting.Value != "2m0s" || setting.Source != "default" { + t.Fatalf("calibration setting = %+v, want the effective default", setting) + } + } + if setting.Key != "CRQ_RL_CO_DEGRADE" { + continue + } + foundDegrade = true + if setting.Value != "1" || setting.Source != "default" { + t.Fatalf("degrade setting = %+v, want the effective on-by-default value", setting) + } + } + if !foundCalibration { + t.Fatal("CRQ_CALIBRATE_TTL was not listed") + } + if !foundDegrade { + t.Fatal("CRQ_RL_CO_DEGRADE was not listed") + } +} + +func TestEnvSettingsUseOnlyTheTypedRequiredReviewerControl(t *testing.T) { + for _, setting := range EnvKeys() { + if strings.HasPrefix(setting.Key, "CRQ_COBOT_") && strings.HasSuffix(setting.Key, "_REQUIRED") { + t.Fatalf("%s exposes a shadowed per-bot required control; use CRQ_REQUIRED_BOTS", setting.Key) + } + } +} + +func TestFleetViewRecognizesGenericFleetProvenance(t *testing.T) { + cfg, err := BuildConfig(map[string]string{"CRQ_REPO": "owner/gate"}) + if err != nil { + t.Fatal(err) + } + svc := &Service{cfg: cfg} + view := svc.fleetViewOf(State{Fleet: FleetDefaults{Env: map[string]string{ + "CRQ_MIN_INTERVAL": "4m", + "CRQ_WEEKLY_LIMIT": "90", + "CRQ_COBOTS": "codex", + }}}) + for _, key := range []string{"min_interval", "weekly_limit", "reviewers"} { + if view.Sources[key] != "fleet" { + t.Errorf("source[%s] = %q, want fleet for a generic fleet record", key, view.Sources[key]) + } + } +} + +// The three layers are the whole feature, so this pins their order and — just +// as importantly — that an absent fleet setting changes nothing at all. A fleet +// that never writes a record must behave exactly as it did before the record +// existed. +func TestFleetDefaultsLayering(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.CoBots = codexCoBots(nil) + cfg.MinInterval = 90 * time.Second + cfg.WeeklyReviewLimit = 60 + cfg.AllowRepos = map[string]bool{"o/plain": true, "o/opinionated": true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + // No record: every value is this host's env, and .sources says so. + view, err := svc.FleetSettings(ctx) + if err != nil { + t.Fatal(err) + } + if view.Recorded || view.MinInterval != "1m30s" || view.WeeklyLimit != 60 { + t.Fatalf("view = %+v, want the env values with no record", view) + } + // "default" and "env" are different answers: firingConfig sets none of + // these, so nothing here comes from a file and saying "env" would send a + // reader looking for a line that does not exist. + for key, src := range view.Sources { + if src != "default" { + t.Errorf("source[%s] = %q, want default when nothing sets it", key, src) + } + } + if !view.AutofixDefault { + t.Error("autofix defaults on, as it always has") + } + + // Inheritance is per SETTING. A repository that answers the reviewer + // question itself is still paced by the fleet — there is one queue and one + // fire slot — so a pacing change reaches it, and a preview that called it + // "unaffected" would be understating where the change lands. + if _, err := svc.SetReviewers(ctx, "o/opinionated", []string{"codex"}, []string{"codex"}, nil); err != nil { + t.Fatal(err) + } + impact, err := svc.PreviewFleet(ctx, FleetChange{MinInterval: strptr("3m")}) + if err != nil { + t.Fatal(err) + } + if impact.Repos != 2 || impact.Overridden != 0 { + t.Errorf("repos/overridden = %d/%d, want pacing to reach both repositories", impact.Repos, impact.Overridden) + } + if len(impact.Changes) != 1 { + t.Errorf("changes = %v, want the pacing change named", impact.Changes) + } + // Same for the autofix default: what stops it is a repository's own autofix + // switch, and neither repository has one. + autofix, err := svc.PreviewFleet(ctx, FleetChange{AutofixDefault: boolptr(false)}) + if err != nil { + t.Fatal(err) + } + if autofix.Repos != 2 || autofix.Overridden != 0 { + t.Errorf("repos/overridden = %d/%d, want the autofix default to reach both repositories", + autofix.Repos, autofix.Overridden) + } + // A preview must not write. + if v, _ := svc.FleetSettings(ctx); v.Recorded { + t.Fatal("a preview wrote a record") + } + + // Recording it changes the effective config for repositories that follow. + if _, _, err := svc.SetFleetSettings(ctx, FleetChange{ + MinInterval: strptr("3m"), WeeklyLimit: intptr(90), AutofixDefault: boolptr(false), + }); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if got := svc.cfgFor(st, "o/plain").MinInterval; got != 3*time.Minute { + t.Errorf("min interval = %s, want the fleet record to win over env", got) + } + if got := svc.cfgFor(st, "o/plain").WeeklyReviewLimit; got != 90 { + t.Errorf("weekly limit = %d, want 90", got) + } + if st.AutofixEnabled("o/never-ruled-on") { + t.Error("a repository with no switch must follow the fleet default, which is now off") + } + view, _ = svc.FleetSettings(ctx) + if view.Sources["min_interval"] != "fleet" || view.Sources["reviewers"] != "default" { + t.Errorf("sources = %v, want only the recorded settings sourced from the fleet", view.Sources) + } + + // The override still wins over the record. + if got := svc.cfgFor(st, "o/opinionated").RequiredBots; len(got) != 1 { + t.Errorf("required = %v, want the repository's own answer to survive a fleet default", got) + } + + // Gating on nobody is refused here for the same reason it is per repo. + if _, err := svc.PreviewFleet(ctx, FleetChange{Required: []string{}}); err == nil { + t.Error("an empty required set must be refused") + } + // So is pacing fast enough to be meaningless. + if _, err := svc.PreviewFleet(ctx, FleetChange{MinInterval: strptr("1s")}); err == nil { + t.Error("a sub-5s pacing floor must be refused") + } + + // Clearing returns every setting to env. + if _, _, err := svc.SetFleetSettings(ctx, FleetChange{Clear: true}); err != nil { + t.Fatal(err) + } + st, _, _ = store.Load(ctx) + if got := svc.cfgFor(st, "o/plain").MinInterval; got != 90*time.Second { + t.Errorf("min interval = %s, want the env value back", got) + } + if !st.AutofixEnabled("o/never-ruled-on") { + t.Error("clearing must restore the default-on answer") + } +} + +func TestClearFleetSettingsClearsTimestampLessMigratedPolicy(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_SCOPE": "startup-owner", + }) + if err != nil { + t.Fatal(err) + } + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.Fleet.Env = map[string]string{"CRQ_SCOPE": "migrated-owner"} + st.Fleet.UpdatedAt = nil + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, newFakeGitHub(), store, nil) + if view, err := svc.FleetSettings(ctx); err != nil { + t.Fatal(err) + } else if !view.Recorded { + t.Fatal("timestamp-less migrated policy was reported as absent") + } + + view, _, err := svc.SetFleetSettings(ctx, FleetChange{Clear: true}) + if err != nil { + t.Fatal(err) + } + if view.Recorded { + t.Fatal("cleared migrated policy still reports a fleet record") + } + st, _, _ := store.Load(ctx) + if !st.Fleet.Empty() { + t.Fatalf("fleet policy was not cleared: %+v", st.Fleet) + } +} + +func TestFleetViewRecognizesExplicitEmptyEnvironmentOverrides(t *testing.T) { + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_HOST": "testhost", + "CRQ_COBOTS": "", + }) + if err != nil { + t.Fatal(err) + } + view, err := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil). + FleetSettings(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := view.Sources["reviewers"]; got != "env" { + t.Fatalf("reviewer source = %q, want env for a present empty override", got) + } +} + +func TestFleetViewRecognizesPrimaryEnvironmentOverride(t *testing.T) { + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_HOST": "testhost", + "CRQ_BOT": "replacement-reviewer[bot]", + }) + if err != nil { + t.Fatal(err) + } + view, err := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil). + FleetSettings(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := view.Sources["reviewers"]; got != "env" { + t.Fatalf("reviewer source = %q, want env for CRQ_BOT", got) + } +} + +func TestSetEnvNormalizesStoredValues(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_HOST": "testhost", + }) + if err != nil { + t.Fatal(err) + } + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if _, err := svc.SetEnv(ctx, "CRQ_INFLIGHT_TIMEOUT", " 5m ", false); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if got := st.Fleet.Env["CRQ_INFLIGHT_TIMEOUT"]; got != "5m" { + t.Fatalf("stored timeout = %q, want normalized value", got) + } + if got := svc.cfgFor(st, "o/repo").InflightTimeout; got != 5*time.Minute { + t.Fatalf("effective timeout = %s, want 5m", got) + } +} + +func TestConfigValueOfIncludesCalibrationTTL(t *testing.T) { + cfg := firingConfig() + cfg.CalibrationTTL = 7 * time.Minute + if got := configValueOf(cfg, "CRQ_CALIBRATE_TTL"); got != "7m0s" { + t.Fatalf("calibration TTL = %q, want 7m0s", got) + } +} + +func TestDashboardReviewerSummaryUsesFleetState(t *testing.T) { + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_HOST": "testhost", + "CRQ_COBOTS": "codex", + }) + if err != nil { + t.Fatal(err) + } + st := DefaultState(cfg) + st.Fleet.CoBots = []string{"cursor[bot]"} + st.Fleet.SetCoBots = true + + body := renderDashboard(st, cfg) + if !strings.Contains(body, "| **Co-reviewers** | bugbot") { + t.Fatalf("dashboard did not render the fleet's reviewer set:\n%s", body) + } + if strings.Contains(body, "| **Co-reviewers** | codex") { + t.Fatalf("dashboard rendered the host's stale startup reviewers:\n%s", body) + } +} + +// Unsetting a fleet setting has to actually unset it. The typed fields — the +// two lists and the weekly limit — are the ones where "leave this alone" and +// "the fleet has no answer" look identical in JSON, so this is where a clear +// could report success and change nothing. +func TestSetEnvClearsTypedFleetSettings(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.WeeklyReviewLimit = 90 // this host's env, which a clear must hand back + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + for _, set := range []struct{ key, value string }{ + {"CRQ_COBOTS", "codex"}, + {"CRQ_REQUIRED_BOTS", "coderabbitai[bot]"}, + {"CRQ_WEEKLY_LIMIT", "60"}, + } { + if _, err := svc.SetEnv(ctx, set.key, set.value, false); err != nil { + t.Fatalf("recording %s: %v", set.key, err) + } + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if !st.Fleet.SetCoBots || !st.Fleet.SetRequired || st.Fleet.WeeklyLimit == nil { + t.Fatalf("fleet = %+v, want all three recorded before the clear", st.Fleet) + } + + for _, key := range []string{"CRQ_COBOTS", "CRQ_REQUIRED_BOTS", "CRQ_WEEKLY_LIMIT"} { + if _, err := svc.SetEnv(ctx, key, "", true); err != nil { + t.Fatalf("clearing %s: %v", key, err) + } + } + st, _, err = store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if st.Fleet.SetCoBots || st.Fleet.CoBots != nil { + t.Errorf("cobots = %v (set=%v), want the record gone", st.Fleet.CoBots, st.Fleet.SetCoBots) + } + if st.Fleet.SetRequired || st.Fleet.Required != nil { + t.Errorf("required = %v (set=%v), want the record gone", st.Fleet.Required, st.Fleet.SetRequired) + } + if st.Fleet.WeeklyLimit != nil { + t.Errorf("weekly limit = %d, want the pointer removed rather than a default written", + *st.Fleet.WeeklyLimit) + } + // The point of removing it: this host's own 90 answers again. + if got := svc.cfgFor(st, "o/plain").WeeklyReviewLimit; got != 90 { + t.Errorf("weekly limit = %d, want this host's env value back", got) + } +} + +func TestSetEnvBlankClearsGenericFleetSetting(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", + "CRQ_REVIEW_CMD": "@host review", "CRQ_COBOTS": "", + }) + if err != nil { + t.Fatal(err) + } + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if _, err := svc.SetEnv(ctx, "CRQ_REVIEW_CMD", "@fleet review", false); err != nil { + t.Fatal(err) + } + if _, err := svc.SetEnv(ctx, "CRQ_REVIEW_CMD", "", false); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if _, ok := st.Fleet.Env["CRQ_REVIEW_CMD"]; ok { + t.Fatalf("fleet env = %v, want the blank value removed", st.Fleet.Env) + } + if got := svc.cfgFor(st, "owner/repo").ReviewCommand; got != "@host review" { + t.Fatalf("review command = %q, want host inheritance restored", got) + } +} + +func TestSetEnvPreservesMeaningfulEmptyFleetSettings(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", + "CRQ_AUTOREVIEW_SKIP_AUTHORS": "dependabot[bot]", + "CRQ_AUTOREVIEW_SKIP_MARKER": "", + }) + if err != nil { + t.Fatal(err) + } + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + for _, key := range []string{"CRQ_AUTOREVIEW_SKIP_AUTHORS", "CRQ_AUTOREVIEW_SKIP_MARKER"} { + if _, err := svc.SetEnv(ctx, key, "", false); err != nil { + t.Fatalf("recording empty %s: %v", key, err) + } + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{"CRQ_AUTOREVIEW_SKIP_AUTHORS", "CRQ_AUTOREVIEW_SKIP_MARKER"} { + if value, ok := st.Fleet.Env[key]; !ok || value != "" { + t.Errorf("fleet[%s] = %q (present=%v), want an explicit empty value", key, value, ok) + } + } + effective := svc.cfgFor(st, "owner/repo") + if len(effective.SkipAuthors) != 0 || effective.SkipMarker != "" { + t.Fatalf("effective skip policy = authors %v marker %q, want both disabled", + effective.SkipAuthors, effective.SkipMarker) + } +} + +func TestFleetSettingsNamesLaggingAutofixWatchers(t *testing.T) { + now := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) + cfg := firingConfig() + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + svc.now = func() time.Time { return now } + st := DefaultState(cfg) + st.SetFleetDefaults(FleetDefaults{AutofixDefault: boolptr(false)}, "operator", now) + st.SetHostReport(HostReport{ + Host: "old-watcher", Caps: CapsFleetDefaults - 1, Roles: []string{"autofix"}, + }, now) + + view := svc.fleetViewOf(st) + if len(view.Lagging) != 1 || view.Lagging[0] != "old-watcher" { + t.Fatalf("lagging = %v, want the autofix watcher that ignores fleet defaults", view.Lagging) + } +} + +// A fleet default reaches the repositories that follow the fleet, and a +// repository somebody turned off follows nothing. It has both an enrollment +// record and completed rounds, so it used to reach the requeue set twice over — +// and saving an unrelated default then spent quota there. +func TestReposFollowingFleetExcludesDisabledRepositories(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/on": true, "o/off": true, "o/excluded": true} + cfg.ExcludeRepos = map[string]bool{"o/excluded": true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if _, err := svc.SetEnrollment(ctx, "o/off", false, "not reviewing this one"); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + for _, repo := range svc.reposFollowingFleet(st) { + if repo == "o/off" || repo == "o/excluded" { + t.Fatalf("following = %v, want disabled and host-excluded repositories omitted", svc.reposFollowingFleet(st)) + } + } +} + +// Naming a different primary is a reviewer change, whichever door it comes +// through. It arrives as a generic env setting rather than one of the four +// typed ones, and that path recorded the value and stopped — so every completed +// round stayed a "this head was reviewed" marker and the bot somebody had just +// configured was never asked for one. +func TestSetEnvPrimaryReopensCompletedRounds(t *testing.T) { + ctx := context.Background() + // From an env map, because the generic fleet settings are applied by + // re-parsing the configuration this host was built from. + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", "CRQ_REPOS": "o/r", + "CRQ_COBOTS": "", "CRQ_MIN_INTERVAL": "0s", "CRQ_POLL": "1ms", + }) + if err != nil { + t.Fatal(err) + } + repo, pr, head := "o/r", 4, "aaaaaaaa1" + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + var pull ghapi.Pull + pull.State = "open" + pull.Head.SHA = head + "bcdef1234" + gh.pulls[fakeKey(repo, pr)] = pull + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + seedRound(t, store, cfg, repo, pr, head, PhaseCompleted, time.Now().UTC(), 11) + + impact, err := svc.PreviewEnv(ctx, "CRQ_BOT", "chatgpt-codex-connector[bot]", false) + if err != nil { + t.Fatal(err) + } + if impact.Reopened != 1 || !strings.Contains(strings.Join(impact.Changes, "\n"), "primary reviewer") { + t.Fatalf("impact = %+v, want the primary change and one reopened round", impact) + } + + // A confirmation is tied to exactly what it priced. An unrelated writer + // moving state still makes the operator preview again. + if _, err := store.Update(ctx, func(st *State) error { + st.CalibrationIssue++ + return nil + }); err != nil { + t.Fatal(err) + } + if _, _, err := svc.SetEnvAt(ctx, "CRQ_BOT", "chatgpt-codex-connector[bot]", false, &impact.Rev); err == nil || + !strings.Contains(err.Error(), "preview the change again") { + t.Fatalf("stale save error = %v, want a revision mismatch", err) + } + impact, err = svc.PreviewEnv(ctx, "CRQ_BOT", "chatgpt-codex-connector[bot]", false) + if err != nil { + t.Fatal(err) + } + if _, _, err := svc.SetEnvAt(ctx, "CRQ_BOT", "chatgpt-codex-connector[bot]", false, &impact.Rev); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if got := svc.cfgFor(st, repo).Bot; got != "chatgpt-codex-connector[bot]" { + t.Fatalf("primary = %q, want the fleet record applied", got) + } + round := st.Round(repo, pr) + if round == nil || round.Phase != PhaseQueued || round.Head != head { + t.Fatalf("round = %#v, want the completed round requeued at the same head", round) + } + + // And a setting that decides nothing about reviewers still reopens nothing: + // requeueing on every save would spend the account's quota on a timezone. + if _, err := svc.SetEnv(ctx, "CRQ_TZ", "Europe/Oslo", false); err != nil { + t.Fatal(err) + } + st, _, err = store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if r := st.Round(repo, pr); r == nil || r.Phase != PhaseQueued { + t.Fatalf("round = %#v, want an unrelated setting to leave it exactly as it was", r) + } +} + +func TestSetEnvPrimaryReopensFullyOverriddenRepository(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", "CRQ_REPOS": "o/r", + "CRQ_COBOTS": "", "CRQ_MIN_INTERVAL": "0s", + }) + if err != nil { + t.Fatal(err) + } + repo, pr, head := "o/r", 14, "cccccccc3" + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + var pull ghapi.Pull + pull.State, pull.Head.SHA = "open", head+"def1234" + gh.pulls[fakeKey(repo, pr)] = pull + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + + // Both repository-level reviewer questions are overridden. The primary is + // still inherited unless PrimaryOff is set. + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{cfg.Bot}, nil); err != nil { + t.Fatal(err) + } + seedRound(t, store, cfg, repo, pr, head, PhaseCompleted, time.Now().UTC(), 21) + + if _, err := svc.SetEnv(ctx, "CRQ_BOT", "replacement-reviewer[bot]", false); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round(repo, pr); round == nil || round.Phase != PhaseQueued { + t.Fatalf("round = %#v, want a primary change to requeue the fully overridden repository", round) + } +} + +func TestSetEnvTriggerPolicyReopensCompletedRounds(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", "CRQ_REPOS": "o/r", + "CRQ_COBOTS": "codex", "CRQ_COBOT_CODEX_TRIGGER": "never", + "CRQ_COBOT_CODEX_CMD": "@codex review", "CRQ_MIN_INTERVAL": "0s", + }) + if err != nil { + t.Fatal(err) + } + repo, pr, head := "o/r", 5, "bbbbbbbb2" + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + var pull ghapi.Pull + pull.State, pull.Head.SHA = "open", head+"cdef1234" + gh.pulls[fakeKey(repo, pr)] = pull + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + seedRound(t, store, cfg, repo, pr, head, PhaseCompleted, time.Now().UTC(), 12) + + impact, err := svc.PreviewEnv(ctx, "CRQ_COBOT_CODEX_TRIGGER", "always", false) + if err != nil { + t.Fatal(err) + } + if impact.Reopened != 1 || !strings.Contains(strings.Join(impact.Changes, "\n"), "trigger policy") { + t.Fatalf("impact = %+v, want the trigger change and one reopened round", impact) + } + if _, _, err := svc.SetEnvAt(ctx, "CRQ_COBOT_CODEX_TRIGGER", "always", false, &impact.Rev); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round(repo, pr); round == nil || round.Phase != PhaseQueued { + t.Fatalf("round = %#v, want the trigger policy change to requeue it", round) + } +} + +func TestFleetReviewerChangeRefusesAClaimedCoTrigger(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", "CRQ_REPOS": "o/r", + "CRQ_COBOTS": "codex", "CRQ_REQUIRED_BOTS": "coderabbitai[bot],codex", + "CRQ_COBOT_CODEX_TRIGGER": "always", "CRQ_COBOT_CODEX_CMD": "@codex review", + }) + if err != nil { + t.Fatal(err) + } + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + if _, err := store.Update(ctx, func(st *State) error { + round, err := st.NewRound("o/r", 9, "abcdef123", time.Now()) + if err != nil { + return err + } + round.ClaimCo(dialect.CodexBotLogin, time.Now()) + st.PutRound(*round) + return nil + }); err != nil { + t.Fatal(err) + } + + impact, err := svc.PreviewFleet(ctx, FleetChange{ + CoBots: []string{}, Required: []string{cfg.Bot}, + }) + if err != nil { + t.Fatal(err) + } + _, _, err = svc.SetFleetSettings(ctx, FleetChange{ + CoBots: []string{}, Required: []string{cfg.Bot}, ExpectedRev: &impact.Rev, + }) + if err == nil || !strings.Contains(err.Error(), "trigger is already being posted") { + t.Fatalf("fleet save error = %v, want the claimed post to block reviewer removal", err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if st.Fleet.SetCoBots || st.Fleet.SetRequired { + t.Fatalf("fleet = %+v, want the rejected reviewer edit not to land", st.Fleet) + } +} + +func TestFleetCommandChangeRefusesAClaimedCoTrigger(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", "CRQ_REPOS": "o/r", + "CRQ_COBOTS": "codex", "CRQ_COBOT_CODEX_TRIGGER": "always", + "CRQ_COBOT_CODEX_CMD": "@codex review", + }) + if err != nil { + t.Fatal(err) + } + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + if _, err := store.Update(ctx, func(st *State) error { + round, err := st.NewRound("o/r", 9, "abcdef123", time.Now()) + if err != nil { + return err + } + round.ClaimCo(dialect.CodexBotLogin, time.Now()) + st.PutRound(*round) + return nil + }); err != nil { + t.Fatal(err) + } + + impact, err := svc.PreviewEnv(ctx, "CRQ_COBOT_CODEX_CMD", "@codex full review", false) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(impact.Changes, "\n"), "command") { + t.Fatalf("impact = %+v, want the command change reported", impact) + } + if _, _, err := svc.SetEnvAt( + ctx, "CRQ_COBOT_CODEX_CMD", "@codex full review", false, &impact.Rev, + ); err == nil || !strings.Contains(err.Error(), "trigger is already being posted") { + t.Fatalf("command save error = %v, want the claimed post to block it", err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if _, ok := st.Fleet.Env["CRQ_COBOT_CODEX_CMD"]; ok { + t.Fatalf("fleet env = %+v, want the rejected command edit not to land", st.Fleet.Env) + } +} + +func TestSetEnvRecomputesFleetFollowersInsideCAS(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", "CRQ_REPOS": "o/r", + "CRQ_COBOTS": "", "CRQ_MIN_INTERVAL": "0s", + }) + if err != nil { + t.Fatal(err) + } + repo, pr, head := "o/r", 6, "cccccccc3" + store := NewMemoryStore(cfg) + now := time.Now().UTC() + if _, err := store.Update(ctx, func(st *State) error { + st.SetRepoOverride(repo, RepoReviewers{ + CoBots: []string{}, SetCoBots: true, + Required: []string{cfg.Bot}, SetRequired: true, + UpdatedAt: &now, + }) + return nil + }); err != nil { + t.Fatal(err) + } + seedRound(t, store, cfg, repo, pr, head, PhaseCompleted, now, 13) + + hooked := &hookedStore{StateStore: store} + hooked.hook = func() { + if _, err := store.Update(ctx, func(st *State) error { + st.ClearRepoOverride(repo) + return nil + }); err != nil { + t.Error(err) + } + } + svc := NewService(cfg, newFakeGitHub(), hooked, nil) + if _, err := svc.SetEnv(ctx, "CRQ_BOT", "chatgpt-codex-connector[bot]", false); err != nil { + t.Fatal(err) + } + + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + round := st.Round(repo, pr) + if round == nil || round.Phase != PhaseCompleted || !round.ReviewersChanged { + t.Fatalf("round = %#v, want a conservative reopen marker after the repository began following the fleet", round) + } +} + +func strptr(s string) *string { return &s } +func intptr(n int) *int { return &n } +func boolptr(b bool) *bool { return &b } + +// A fleet-recorded primary has to be the one crq actually asks. The decision +// resolves the record; the apply path used to post this host's startup value — +// so the daemon spent the account's quota reserving a round for one bot and +// then sent the other bot's command, and waited for a review nobody requested. +func TestFireUsesTheFleetResolvedPrimary(t *testing.T) { + ctx := context.Background() + // Built from an env map rather than a literal: the fleet's generic settings + // are applied by re-parsing the configuration crq was built from, which is + // what a host actually has. + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", + "CRQ_COBOTS": "", "CRQ_MIN_INTERVAL": "0s", "CRQ_POLL": "1ms", + }) + if err != nil { + t.Fatal(err) + } + gh := newFakeGitHub() + var pull ghapi.Pull + pull.State = "open" + pull.Head.SHA = "abcdef1234567890" + gh.pulls["owner/repo#12"] = pull + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + + if _, err := svc.SetEnv(ctx, "CRQ_REVIEW_CMD", "@coderabbitai full review", false); err != nil { + t.Fatal(err) + } + if _, err := svc.Enqueue(ctx, "owner/repo", 12); err != nil { + t.Fatal(err) + } + if pumped, err := svc.Pump(ctx); err != nil || pumped.Action != "fired" { + t.Fatalf("pump = %+v, err = %v, want a fired round", pumped, err) + } + if len(gh.posted) != 1 || !strings.HasSuffix(gh.posted[0], "@coderabbitai full review") { + t.Fatalf("posted %q, want the fleet's recorded review command", gh.posted) + } + // And the round records the command as the primary's, so the wait is for a + // bot that was actually asked. + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + round := st.Round("owner/repo", 12) + if round == nil || len(round.PostedCommands) != 1 || round.PostedCommands[0].Bot != cfg.Bot { + t.Errorf("posted commands = %+v, want one attributed to %s", round.PostedCommands, cfg.Bot) + } +} + +// A repository that states ONE half of its reviewers still inherits the other +// from the fleet. Treating either half as a complete override dropped it from +// the impact preview and from the requeue: cfgFor handed it the newly required +// reviewer, its completed round stayed a "this head was reviewed" marker, and +// the reviewer somebody had just required was never asked there. +func TestFleetReachesRepositoriesThatOverrideOnlyOneHalf(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.CoBots = codexCoBots(nil) + cfg.AllowRepos = map[string]bool{"o/half": true, "o/whole": true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + // o/half names its co-reviewers and inherits the required set; o/whole + // answers both questions itself. + if _, err := svc.SetReviewers(ctx, "o/half", []string{"codex"}, nil, nil); err != nil { + t.Fatal(err) + } + if _, err := svc.SetReviewers(ctx, "o/whole", []string{"codex"}, []string{"codex"}, nil); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + following := map[string]bool{} + for _, repo := range svc.reposFollowingFleet(st) { + following[repo] = true + } + if !following["o/half"] { + t.Errorf("following = %v, want the half-overridden repository included: it still inherits the required set", + svc.reposFollowingFleet(st)) + } + if following["o/whole"] { + t.Errorf("following = %v, want the fully-overridden repository excluded", svc.reposFollowingFleet(st)) + } + + // And the preview counts as "unaffected" only the one that really is. + impact, err := svc.PreviewFleet(ctx, FleetChange{Required: []string{"coderabbitai[bot]", "codex"}}) + if err != nil { + t.Fatal(err) + } + if impact.Overridden != 1 { + t.Errorf("overridden = %d, want only the repository that answers both questions itself", impact.Overridden) + } +} + +func TestFleetImpactCountsOnlyOpenCompletedRounds(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.CoBots = nil + cfg.RequiredBots = []string{cfg.Bot} + cfg.AllowRepos = map[string]bool{"o/repo": true} + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: "o/repo", Number: 1}} + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + if _, err := store.Update(ctx, func(st *State) error { + for _, pr := range []int{1, 2} { + round, err := st.NewRound("o/repo", pr, fmt.Sprintf("head-%d", pr), time.Now()) + if err != nil { + return err + } + round.Phase = PhaseCompleted + st.PutRound(*round) + } + return nil + }); err != nil { + t.Fatal(err) + } + + impact, err := svc.PreviewFleet(ctx, FleetChange{CoBots: []string{"codex"}}) + if err != nil { + t.Fatal(err) + } + if impact.Reopened != 1 { + t.Fatalf("reopened = %d, want only the completed round whose PR is still open", impact.Reopened) + } +} + +func TestFleetSaveRejectsAStalePreviewRevision(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + impact, err := svc.PreviewFleet(ctx, FleetChange{MinInterval: strptr("3m")}) + if err != nil { + t.Fatal(err) + } + if _, err := store.Update(ctx, func(st *State) error { + st.HostReports = map[string]HostReport{"other": {Host: "other"}} + return nil + }); err != nil { + t.Fatal(err) + } + if _, _, err := svc.SetFleetSettings(ctx, FleetChange{ + MinInterval: strptr("3m"), ExpectedRev: &impact.Rev, + }); err == nil || !strings.Contains(err.Error(), "preview") { + t.Fatalf("stale preview save error = %v, want a preview-again refusal", err) + } +} + +func TestClearFleetSettingsReopensFullyOverriddenRepoForNewPrimary(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_REPOS": "o/repo", + "CRQ_BOT": "env-primary[bot]", + "CRQ_REVIEW_CMD": "@env-primary review", + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": "env-primary[bot],codex", + }) + if err != nil { + t.Fatal(err) + } + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: "o/repo", Number: 1}} + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + now := time.Now().UTC() + if _, err := store.Update(ctx, func(st *State) error { + st.Fleet.Env = map[string]string{ + "CRQ_BOT": "fleet-primary[bot]", + "CRQ_REVIEW_CMD": "@fleet-primary review", + "CRQ_REQUIRED_BOTS": "fleet-primary[bot],codex", + } + st.SetRepoOverride("o/repo", RepoReviewers{ + CoBots: []string{"codex"}, SetCoBots: true, + Required: []string{"codex"}, SetRequired: true, + }) + round, err := st.NewRound("o/repo", 1, "head-1", now) + if err != nil { + return err + } + round.Phase = PhaseCompleted + st.PutRound(*round) + return nil + }); err != nil { + t.Fatal(err) + } + + impact, err := svc.PreviewFleet(ctx, FleetChange{Clear: true}) + if err != nil { + t.Fatal(err) + } + if impact.Reopened != 1 || !strings.Contains(strings.Join(impact.Changes, " "), "primary reviewer") { + t.Fatalf("clear impact = %+v, want the inherited primary change and one reopened round", impact) + } + if _, _, err := svc.SetFleetSettings(ctx, FleetChange{Clear: true, ExpectedRev: &impact.Rev}); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round("o/repo", 1); round == nil || round.Phase != PhaseQueued { + t.Fatalf("round after clear = %+v, want it reopened for the env primary", round) + } +} + +// A save the fleet will ignore is worse than a refused one: the settings page +// then reports a value that no daemon is running. Every setting below is read +// back through a "> 0" guard, so a zero — or a negative that Atoi happily +// parses — leaves each host on its own startup value while the page says +// otherwise. +func TestEnvValidationRejectsValuesTheFleetWouldIgnore(t *testing.T) { + for _, tc := range []struct { + key, value string + wantErr bool + }{ + {"CRQ_AUTOREVIEW_MAX_SCAN", "-1", true}, + {"CRQ_AUTOREVIEW_MAX_SCAN", "0", true}, + {"CRQ_AUTOREVIEW_MAX_SCAN", "200", false}, + {"CRQ_WATCH_INTERVAL", "0s", true}, + {"CRQ_WATCH_INTERVAL", "5m", false}, + {"CRQ_INFLIGHT_TIMEOUT", "0s", true}, + {"CRQ_DISPATCH_MAX_ATTEMPTS", "0", true}, + {"CRQ_WEEKLY_LIMIT", "-1", true}, + // Zero is a stated answer for these two — no pacing, no settling — so + // they stay accepted. A blanket "positive only" would take a documented + // setting away. + {"CRQ_MIN_INTERVAL", "0s", false}, + {"CRQ_SETTLE", "0s", false}, + {"CRQ_WEEKLY_LIMIT", "0", false}, + {"CRQ_TZ", "Europe/Oslo", false}, + {"CRQ_TZ", "Local", true}, + {"CRQ_TZ", "Europe/Not_A_Real_Place", true}, + // Empty is "unset here", which every key allows. + {"CRQ_WATCH_INTERVAL", "", false}, + } { + err := validateEnvValue(tc.key, tc.value) + if tc.wantErr && err == nil { + t.Errorf("%s=%q was accepted; the fleet would ignore it", tc.key, tc.value) + } + if !tc.wantErr && err != nil { + t.Errorf("%s=%q was refused: %v", tc.key, tc.value, err) + } + } +} + +func TestAdoptEnvSkipsInvalidValues(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_INFLIGHT_TIMEOUT": "not-a-duration", + }) + if err != nil { + t.Fatal(err) + } + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + adopted, err := svc.AdoptEnv(ctx, false) + if err != nil { + t.Fatal(err) + } + got, ok := findAdopted(adopted, "CRQ_INFLIGHT_TIMEOUT") + if !ok || !strings.Contains(got.Skipped, "invalid") { + t.Fatalf("invalid setting report = %+v, want an explicit skip", got) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if _, recorded := st.Fleet.Env["CRQ_INFLIGHT_TIMEOUT"]; recorded { + t.Fatal("invalid environment value was persisted into fleet state") + } +} + +// Adopting is a report as much as a write, and the CAS behind it deliberately +// leaves an existing record alone. Saying "adopted" for a value shared state +// never took is the one way this command can mislead: an operator reads their +// number back and believes the fleet now runs on it. +func TestAdoptEnvReportsWhatStateActuallyTook(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", "CRQ_COBOTS": "", + "CRQ_MIN_INTERVAL": "90s", "CRQ_SETTLE": "45s", + }) + if err != nil { + t.Fatal(err) + } + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + // The fleet has already ruled on both, in each of the two places a setting + // can live: the typed field and the generic map. + if _, err := svc.SetEnv(ctx, "CRQ_MIN_INTERVAL", "5m", false); err != nil { + t.Fatal(err) + } + if _, err := svc.SetEnv(ctx, "CRQ_SETTLE", "2m", false); err != nil { + t.Fatal(err) + } + + adopted, err := svc.AdoptEnv(ctx, false) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{"CRQ_MIN_INTERVAL", "CRQ_SETTLE"} { + got, ok := findAdopted(adopted, key) + if !ok { + t.Fatalf("%s missing from the report", key) + } + if got.Skipped == "" { + t.Errorf("%s reported as adopted, but state kept its own value", key) + } + } + // And it really did keep them. + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if st.Fleet.MinInterval != "5m0s" || st.Fleet.Env["CRQ_SETTLE"] != "2m" { + t.Errorf("fleet = %q / %q, want the recorded values untouched", + st.Fleet.MinInterval, st.Fleet.Env["CRQ_SETTLE"]) + } +} + +func TestAdoptEnvSkipsMalformedValues(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", "CRQ_COBOTS": "", + "CRQ_MIN_INTERVAL": "not-a-duration", + }) + if err != nil { + t.Fatal(err) + } + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + + adopted, err := svc.AdoptEnv(ctx, false) + if err != nil { + t.Fatal(err) + } + setting, ok := findAdopted(adopted, "CRQ_MIN_INTERVAL") + if !ok || !strings.Contains(setting.Skipped, "invalid") { + t.Fatalf("adoption = %+v, want malformed interval skipped", setting) + } +} + +func TestFleetViewNamesGenericFleetValuesAsFleet(t *testing.T) { + cfg := firingConfig() + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + st := State{} + st.Fleet.Env = map[string]string{"CRQ_MIN_INTERVAL": "2m"} + + if got := svc.fleetViewOf(st).Sources["min_interval"]; got != "fleet" { + t.Fatalf("min interval source = %q, want generic fleet record", got) + } +} + +func TestAdoptEnvResolvesRequiredBotsAgainstTheFleetPrimary(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_HOST": "testhost", + "CRQ_COBOTS": "", + "CRQ_REQUIRED_BOTS": "replacement-reviewer[bot]", + }) + if err != nil { + t.Fatal(err) + } + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + fd := st.Fleet + fd.Env = map[string]string{"CRQ_BOT": "replacement-reviewer[bot]"} + st.SetFleetDefaults(fd, "other-host", time.Now()) + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if _, err := svc.AdoptEnv(ctx, false); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(st.Fleet.Required, ","); got != "replacement-reviewer[bot]" { + t.Fatalf("required = %q, want the fleet-effective primary", got) + } +} + +func TestAdoptEnvAppliesPrimaryBeforeRequiredBotsInOneWrite(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_HOST": "testhost", + "CRQ_COBOTS": "", + "CRQ_BOT": "replacement-reviewer[bot]", + "CRQ_REQUIRED_BOTS": "replacement-reviewer[bot]", + }) + if err != nil { + t.Fatal(err) + } + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if _, err := svc.AdoptEnv(ctx, false); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if st.Fleet.Env["CRQ_BOT"] != "replacement-reviewer[bot]" { + t.Fatalf("primary = %q, want replacement reviewer", st.Fleet.Env["CRQ_BOT"]) + } + if got := strings.Join(st.Fleet.Required, ","); got != "replacement-reviewer[bot]" { + t.Fatalf("required = %q, want newly adopted primary", got) + } +} + +func TestAdoptEnvIncludesPerBotRequiredSettings(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", + "CRQ_COBOTS": "", "CRQ_COBOT_BUGBOT_REQUIRED": "1", + }) + if err != nil { + t.Fatal(err) + } + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if _, err := svc.AdoptEnv(ctx, false); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if !st.Fleet.SetRequired || !hasLogin(st.Fleet.Required, "cursor[bot]") { + t.Fatalf("fleet required = %v (set=%v), want the compatibility alias folded into the typed required set", + st.Fleet.Required, st.Fleet.SetRequired) + } + if _, ok := st.Fleet.Env["CRQ_COBOT_BUGBOT_REQUIRED"]; ok { + t.Fatalf("fleet env = %+v, want no shadowed per-bot required setting", st.Fleet.Env) + } + neutral, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "other-host", "CRQ_COBOTS": "", + }) + if err != nil { + t.Fatal(err) + } + effective := neutral.WithFleet(st.Fleet) + if !hasLogin(effective.RequiredBots, "cursor[bot]") { + t.Fatalf("required reviewers = %v, want adopted bugbot requirement on another host", effective.RequiredBots) + } +} + +func TestAdoptEnvReopensCompletedRoundsForNewReviewers(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_HOST": "testhost", + "CRQ_REPOS": "o/r", + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": "coderabbitai,codex", + "CRQ_MIN_INTERVAL": "0s", + }) + if err != nil { + t.Fatal(err) + } + repo, pr, head := "o/r", 7, "dddddddd4" + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + seedRound(t, store, cfg, repo, pr, head, PhaseCompleted, time.Now().UTC(), 14) + + if _, err := svc.AdoptEnv(ctx, false); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round(repo, pr); round == nil || round.Phase != PhaseQueued { + t.Fatalf("round = %#v, want adoption of a required reviewer to requeue it", round) + } +} + +func findAdopted(list []AdoptedSetting, key string) (AdoptedSetting, bool) { + for _, a := range list { + if a.Key == key { + return a, true + } + } + return AdoptedSetting{}, false +} diff --git a/internal/crq/hold_test.go b/internal/crq/hold_test.go index c7f2485a..a923b906 100644 --- a/internal/crq/hold_test.go +++ b/internal/crq/hold_test.go @@ -2,6 +2,7 @@ package crq import ( "context" + "errors" "testing" "time" @@ -317,6 +318,69 @@ func TestHoldAndUnholdDryRunDoNotMutateState(t *testing.T) { } } +func TestClarificationHoldRequiresDispatchOwnership(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) + cfg := firingConfig() + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + svc.now = func() time.Time { return now } + repo, pr, head := "o/r", 9, "ffffffff1" + report := NextReport{Repo: repo, PR: pr, Head: head} + seedRound(t, store, cfg, repo, pr, head, PhaseQueued, now.Add(-time.Minute), 0) + setHoldCapableLeader(t, ctx, store, now) + + if _, err := store.Update(ctx, func(st *State) error { + round := st.Round(repo, pr) + if ok, why := round.ClaimDispatch("other", "new-token", now, 3); !ok { + t.Fatalf("claim: %s", why) + } + st.RememberDispatch(repo, pr, *round.Dispatch) + st.PutRound(*round) + return nil + }); err != nil { + t.Fatal(err) + } + + if _, err := svc.holdDispatch(ctx, report, "old-token", "needs clarification"); err == nil { + t.Fatal("clarification hold succeeded after the dispatch claim changed owners") + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if _, held := st.HeldPR(repo, pr); held { + t.Fatal("rejected clarification persisted a hold") + } + + now = now.Add(DispatchTTL) + if _, err := svc.holdDispatch(ctx, report, "new-token", "needs clarification"); !errors.Is(err, errDispatchClaimLost) { + t.Fatalf("clarification hold with expired claim error = %v, want %v", err, errDispatchClaimLost) + } + if _, err := store.Update(ctx, func(st *State) error { + round := st.Round(repo, pr) + if ok, taken := round.HeartbeatDispatch("new-token", now); !ok || taken { + t.Fatalf("heartbeat: ok %v taken %v", ok, taken) + } + st.RememberDispatch(repo, pr, *round.Dispatch) + st.PutRound(*round) + return nil + }); err != nil { + t.Fatal(err) + } + + if _, err := svc.holdDispatch(ctx, report, "new-token", "needs clarification"); err != nil { + t.Fatalf("claim owner could not hold the PR: %v", err) + } + st, _, err = store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if _, held := st.HeldPR(repo, pr); !held { + t.Fatal("claim owner did not persist a clarification hold") + } +} + // A rolling deployment can leave an older daemon holding the leader lease. // Such a daemon preserves Holds in JSON but does not enforce them, so the // command must not claim success until a capable leader owns the fleet. diff --git a/internal/crq/hostreport.go b/internal/crq/hostreport.go new file mode 100644 index 00000000..a28deca0 --- /dev/null +++ b/internal/crq/hostreport.go @@ -0,0 +1,168 @@ +package crq + +import ( + "context" + "errors" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +// probedTools is what crq wants to know about a host. The purpose text lives in +// the dashboard; what matters here is the list, and that every host probes the +// same one so two reports can be compared. +var probedTools = []string{"crq", "git", "gh", "claude", "codex", "coderabbit", "macroscope"} + +var toolProbeCache struct { + sync.Mutex + at time.Time + tools []ToolReport +} + +// ReportHost records what THIS machine can reach, so the fleet can be read as a +// fleet rather than as whichever host you happened to ask. +// +// The PATH it probes is the reporting process's own, which is the point: a +// daemon reports the PATH its service runs with, and that is the one that +// decides whether a fix session can start. A tool installed for the shell and +// invisible to the service is the failure this exists to make visible, and it +// cannot be seen from the shell. +func (s *Service) ReportHost(ctx context.Context, roles ...string) { + // By name, not by path: what a reader wants to know is which agent this + // machine fixes with, and the name is also what the tool probes below answer + // for. A process with no fix agent says nothing rather than guessing — the + // record keeps whatever the autofix service on this host reported. + agent := "" + if path := s.cfg.fixAgent(); path != "" { + agent = filepath.Base(path) + } + report := HostReport{ + Host: s.cfg.Host, + Version: Version, + Caps: WriterCaps, + Roles: roles, + Agent: agent, + } + report.Tools = cachedToolReports(ctx, s.clock().UTC()) + + now := s.clock().UTC() + if s.cfg.DryRun { + return + } + if _, err := s.store.Update(ctx, func(st *State) error { + // Compared against what the merge WOULD produce, not against this + // process's own view: another service on this host may have added a + // role since, and treating that as "nothing changed" would leave the + // merged record un-refreshed until it aged out. + // + // Freshness is asked of THIS reporter's own roles, not of the record. + // A host running two services has the other one refreshing At on every + // pass, so a record-wide check suppressed this write until the role + // being reported crossed the TTL — and then whichever service wrote + // next pruned a service that never stopped running. + if prev, ok := st.HostReports[report.Host]; ok && sameHostReport(prev, report) && + !prev.StaleRole(now) && prev.RolesFresh(report.Roles, now, HostReportTTL/2) { + // Nothing changed and the record is still fresh. Rewriting it would + // bump the state revision on every pass and make the dashboard's + // change feed report a fleet that is constantly doing something. + return ErrNoChange + } + st.SetHostReport(report, now) + return nil + }); err != nil && !errors.Is(err, ErrNoChange) && s.log != nil { + s.log.Printf("warning: reporting host tools: %v", err) + } +} + +func cachedToolReports(ctx context.Context, now time.Time) []ToolReport { + toolProbeCache.Lock() + if len(toolProbeCache.tools) > 0 && toolProbeCache.at.After(now.Add(-HostReportTTL/2)) { + tools := append([]ToolReport(nil), toolProbeCache.tools...) + toolProbeCache.Unlock() + return tools + } + toolProbeCache.Unlock() + + tools := make([]ToolReport, 0, len(probedTools)) + for _, name := range probedTools { + t := ToolReport{Name: name} + if path, err := exec.LookPath(name); err == nil { + t.Path = path + t.Version = toolVersion(ctx, path) + } + tools = append(tools, t) + } + + toolProbeCache.Lock() + defer toolProbeCache.Unlock() + // Another caller may have refreshed the cache while this one was probing. + // Keep the first complete result rather than making a slower probe extend + // the cache lifetime again. + if len(toolProbeCache.tools) > 0 && toolProbeCache.at.After(now.Add(-HostReportTTL/2)) { + return append([]ToolReport(nil), toolProbeCache.tools...) + } + toolProbeCache.at = now + toolProbeCache.tools = append(toolProbeCache.tools[:0], tools...) + return tools +} + +// toolVersion asks a tool what it is, briefly. Anything that does not answer in +// two seconds, or answers with something unhelpful, reports no version rather +// than holding up a pass: this is for a person reading a table. +func toolVersion(ctx context.Context, path string) string { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, path, "--version").Output() + if err != nil { + return "" + } + line := strings.TrimSpace(strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0]) + if len(line) > 60 { + line = line[:60] + } + return line +} + +// sameHostReport reports whether the stored record already says what this +// reporter is about to say. The tool comparison is per ROLE: the record's own +// Tools list is resolved across every service on the host, so comparing against +// it made a reporter whose PATH another role outranks see a difference on every +// pass and rewrite state for ever. +func sameHostReport(prev, next HostReport) bool { + // Only when this reporter has an answer: a service that runs no fix sessions + // says nothing about the agent, and the record keeps what the autofix service + // on this host said — so its silence is not a difference to write out. + agentAuthoritative := false + for _, role := range next.Roles { + agentAuthoritative = agentAuthoritative || role == "autofix" + } + if (next.Agent != "" || agentAuthoritative) && prev.Agent != next.Agent { + return false + } + for _, role := range next.Roles { + if prev.VersionFor(role) != next.Version { + return false + } + if prev.CapsFor(role) != next.Caps { + return false + } + stored := prev.ToolsReportedBy(role) + if len(stored) != len(next.Tools) { + return false + } + for i := range stored { + // Field by field: a ToolReport carries the members a newer binary + // added, and those are not this reporter's to have an opinion about. + // Comparing whole values would read a carried member as a difference + // and rewrite state on every pass to say the same thing. + if stored[i].Name != next.Tools[i].Name || + stored[i].Path != next.Tools[i].Path || + stored[i].Version != next.Tools[i].Version { + return false + } + } + } + return true +} diff --git a/internal/crq/hostreport_test.go b/internal/crq/hostreport_test.go new file mode 100644 index 00000000..78ba7883 --- /dev/null +++ b/internal/crq/hostreport_test.go @@ -0,0 +1,33 @@ +package crq + +import "testing" + +func TestSameHostReportComparesCapabilitiesForTheReportingRole(t *testing.T) { + prev := HostReport{ + Version: "2.1.0", Caps: 3, + RoleCaps: map[string]int{"autofix": 3, "serve": 1}, + RoleVersions: map[string]string{"autofix": "2.1.0", "serve": "2.0.0"}, + } + serve := HostReport{Version: "2.0.0", Caps: 1, Roles: []string{"serve"}} + if !sameHostReport(prev, serve) { + t.Fatal("an unchanged older serve role was treated as different from another role's newer report") + } + serve.Caps = 2 + if sameHostReport(prev, serve) { + t.Fatal("a changed serve capability was not detected") + } + serve.Caps, serve.Version = 1, "2.1.0" + if sameHostReport(prev, serve) { + t.Fatal("an upgraded serve binary was not detected when its capabilities stayed the same") + } +} + +func TestSameHostReportLetsAutofixClearItsAgent(t *testing.T) { + prev := HostReport{Agent: "claude", Roles: []string{"autofix"}} + if sameHostReport(prev, HostReport{Roles: []string{"autofix"}}) { + t.Fatal("autofix removing its agent must be recorded") + } + if !sameHostReport(prev, HostReport{Roles: []string{"serve"}}) { + t.Fatal("a role that does not choose the fix agent must preserve it silently") + } +} diff --git a/internal/crq/init.go b/internal/crq/init.go index a0a1dab6..ecc7a565 100644 --- a/internal/crq/init.go +++ b/internal/crq/init.go @@ -14,12 +14,6 @@ type InitResult struct { DashboardIssue int `json:"dashboard_issue"` CalibrationPR int `json:"calibration_pr,omitempty"` StateRef string `json:"state_ref"` - // Scope is the owner list in force after the fleet's policy is applied, not - // the one this host started with. Joining an existing queue is exactly when - // the two differ, and the setup lines this result prints are copied into a - // config file verbatim — printing the host's own would install a fallback - // that scans the wrong account the moment the fleet key is unset. - Scope []string `json:"scope,omitempty"` } func Init(ctx context.Context, cfg Config, gh *ghapi.GitHub, store StateStore) (InitResult, error) { @@ -44,7 +38,6 @@ func Init(ctx context.Context, cfg Config, gh *ghapi.GitHub, store StateStore) ( if err != nil { return InitResult{}, err } - cfg = applyFleet(cfg, state.FleetConfig, nil) if cfg.DashboardIssue <= 0 { body, err := issueBody(state, cfg) if err != nil { @@ -55,7 +48,7 @@ func Init(ctx context.Context, cfg Config, gh *ghapi.GitHub, store StateStore) ( return InitResult{}, err } cfg.DashboardIssue = issue.Number - } else if err := store.SyncDashboard(ctx, state, cfg.storeConfig()); err != nil { + } else if err := store.SyncDashboard(ctx, state); err != nil { return InitResult{}, err } return InitResult{ @@ -63,7 +56,6 @@ func Init(ctx context.Context, cfg Config, gh *ghapi.GitHub, store StateStore) ( DashboardIssue: cfg.DashboardIssue, CalibrationPR: cfg.CalibrationPR, StateRef: cfg.StateRef, - Scope: cfg.Scope, }, nil } diff --git a/internal/crq/init_test.go b/internal/crq/init_test.go deleted file mode 100644 index ade05ece..00000000 --- a/internal/crq/init_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package crq - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/kristofferR/coderabbit-queue/internal/dialect" - ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" -) - -func TestInitSyncsDashboardWithFleetReviewers(t *testing.T) { - ctx := context.Background() - cfg := isolatedConfig(t, map[string]string{ - "CRQ_REPO": "owner/gate", - "CRQ_ISSUE": "7", - "CRQ_CAL_PR": "8", - "CRQ_COBOTS": "codex", - "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", - }) - inner := NewMemoryStore(cfg) - if _, err := inner.Update(ctx, func(st *State) error { - st.SetFleetValue("required-bots", "coderabbitai[bot],"+dialect.CodexBotLogin) - return nil - }); err != nil { - t.Fatal(err) - } - store := &dashboardConfigStore{StateStore: inner} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{}`)) - })) - defer server.Close() - - if _, err := Init(ctx, cfg, ghapi.NewTestClient(server.URL, server.Client()), store); err != nil { - t.Fatal(err) - } - if !strings.Contains(store.render.CoReviewers, "codex (required") { - t.Fatalf("dashboard co-reviewers = %q, want fleet-required codex", store.render.CoReviewers) - } -} - -// The setup lines init prints are copied into a config file verbatim. Joining a -// queue whose fleet scans another account and printing this host's own scope -// installs a fallback that targets the wrong one the moment the fleet key is -// unset — and reports a divergence immediately. -func TestInitReportsTheFleetScope(t *testing.T) { - ctx := context.Background() - cfg := isolatedConfig(t, map[string]string{ - "CRQ_REPO": "owner/gate", - "CRQ_ISSUE": "7", - "CRQ_CAL_PR": "8", - "CRQ_SCOPE": "this-host", - }) - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("scope", "the-fleet") - return nil - }); err != nil { - t.Fatal(err) - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{}`)) - })) - defer server.Close() - - result, err := Init(ctx, cfg, ghapi.NewTestClient(server.URL, server.Client()), store) - if err != nil { - t.Fatal(err) - } - if strings.Join(result.Scope, ",") != "the-fleet" { - t.Fatalf("scope = %v, want the fleet's", result.Scope) - } -} diff --git a/internal/crq/next.go b/internal/crq/next.go index 30ec0efd..ddd9f566 100644 --- a/internal/crq/next.go +++ b/internal/crq/next.go @@ -22,6 +22,10 @@ type NextReport struct { Repo string `json:"repo"` PR int `json:"pr"` Head string `json:"head,omitempty"` + // Fork is watch-only trust context. It is deliberately not part of the + // `crq next` wire contract: Next does not fetch the head repository, while + // watch already has the Pull object and must carry that fact into the CAS. + Fork bool `json:"-"` // RecheckAfter is when to call `crq next` again — the ONE time field, set // for both hold and wait so there is never a question of which to read. crq @@ -174,7 +178,14 @@ func (s *Service) Next(ctx context.Context, repo string, pr int) (NextReport, er // settleUntil is when a convergence verdict may be trusted: the newest review // plus the configured quiet period. Nil when settling is disabled or nothing has // been observed to settle from. -func settleUntil(feedback FeedbackReport, window time.Duration) *time.Time { +// +// The window comes from the configuration this report was built with, which is +// the state-backed one blocking `crq loop` already settles on. Reading the +// startup value here let the two forms disagree: an agent on the documented +// `next` loop returned done before a fleet-lengthened window, or kept waiting +// through one that had been shortened. +func (s *Service) settleUntil(feedback FeedbackReport) *time.Time { + window := feedback.config.SettleWindow if window <= 0 || feedback.LastEvidenceAt.IsZero() { return nil } @@ -251,16 +262,22 @@ func (s *Service) nextFromState(ctx context.Context, repo string, pr int) (NextR report.Dismissed = feedback.Dismissed in := engine.NextInput{ - Obs: engine.Observation{Head: feedback.Head, Open: feedback.Open}, - Completion: engine.CompletionStatus{ReviewedBy: feedback.ReviewedBy, Done: allReviewed(feedback.ReviewedBy)}, - Findings: feedback.Findings, - Global: s.global(st, now), - Primary: s.cfg.Bot, + Obs: engine.Observation{Head: feedback.Head, Open: feedback.Open}, + Completion: engine.CompletionStatus{ReviewedBy: feedback.ReviewedBy, Done: allReviewed(feedback.ReviewedBy)}, + Findings: feedback.Findings, + Global: s.global(st, now), + // The reviewer THIS report was built with, not the one this process + // started with. The engine uses it to tell the quota-gated reviewer from + // the co-reviewers, so a fleet-changed CRQ_BOT left Feedback classifying + // evidence under one login while the verdict was decided under another — + // holding a degraded round through the account-block window, and blaming + // an expired wait on a bot that was never the primary. + Primary: feedback.config.Bot, LocalWork: report.LocalWork, Deferred: feedback.CodeRabbitDeferred, DeferredUntil: feedback.DeferredUntil, MinDelay: s.cfg.PollInterval, - SettleUntil: settleUntil(feedback, feedback.config.SettleWindow), + SettleUntil: s.settleUntil(feedback), } // Only a round that still tracks THIS head may shape the verdict. A stale // fired/reviewing round carries its own phase and deadline, and an elapsed diff --git a/internal/crq/observe.go b/internal/crq/observe.go index 866f27d2..3a1ed0d0 100644 --- a/internal/crq/observe.go +++ b/internal/crq/observe.go @@ -89,6 +89,7 @@ func (s *Service) observe(ctx context.Context, cfg Config, repo string, pr int, CodeRabbit: s.cr, Bot: cfg.Bot, ReviewCommand: cfg.ReviewCommand, + Primary: cfg.classifierPrimary(), CoReviewers: cfg.classifierCoReviewers(), } presentComments := make(map[int64]bool, len(comments)) @@ -214,6 +215,18 @@ func (s *Service) observe(ctx context.Context, cfg Config, repo string, pr int, return o, nil } +// classifierPrimary returns registry wording hooks when the configured primary +// is itself a known reviewer. CodeRabbit has no registry entry and continues +// through the dedicated CodeRabbit classifier. +func (c Config) classifierPrimary() *dialect.CoReviewer { + primary, ok := dialect.CoReviewerByName(c.Bot) + if !ok { + return nil + } + primary.Command = c.ReviewCommand + return &primary +} + // classifierCoReviewers resolves the enabled registry entries with their // config-resolved trigger commands. func (c Config) classifierCoReviewers() []dialect.CoReviewer { @@ -233,6 +246,9 @@ func (c Config) classifierCoReviewers() []dialect.CoReviewer { // so the extra REST fetch (ETag'd — repeat polls are 304s) is only spent when // a bot's evidence can live there. func (c Config) coChecksRelevant() bool { + if primary := c.classifierPrimary(); primary != nil && primary.AppSlug != "" { + return true + } for _, cb := range c.CoBots { if co, ok := dialect.CoReviewerByName(cb.Name); ok && co.AppSlug != "" { return true @@ -243,6 +259,9 @@ func (c Config) coChecksRelevant() bool { // coBotEnabled reports whether login is one of the enabled co-reviewers. func (c Config) coBotEnabled(login string) bool { + if primary := c.classifierPrimary(); primary != nil && primary.Is(login) { + return true + } for _, cb := range c.CoBots { if dialect.NormalizeBotName(cb.Login) == dialect.NormalizeBotName(login) { return true diff --git a/internal/crq/replay_test.go b/internal/crq/replay_test.go index 98a6331c..2a7e9b0c 100644 --- a/internal/crq/replay_test.go +++ b/internal/crq/replay_test.go @@ -241,7 +241,6 @@ func (f *replayFixture) autoReviewEnqueue(repo string, pr int) bool { if err != nil { f.t.Fatalf("load: %v", err) } - fleetRevision := f.svc.fleetCfg(st).FleetRevision need, head, err := f.svc.needsReview(f.ctx, st, repo, pr, true) if err != nil { f.t.Fatalf("needsReview %s#%d: %v", repo, pr, err) @@ -249,7 +248,7 @@ func (f *replayFixture) autoReviewEnqueue(repo string, pr int) bool { if !need { return false } - if err := f.svc.enqueueBatch(f.ctx, []queueCandidate{{Repo: repo, PR: pr, Head: head}}, fleetRevision); err != nil { + if err := f.svc.enqueueBatch(f.ctx, []queueCandidate{{Repo: repo, PR: pr, Head: head}}); err != nil { f.t.Fatalf("enqueueBatch %s#%d: %v", repo, pr, err) } return true diff --git a/internal/crq/repoconfig.go b/internal/crq/repoconfig.go index 9ae02f5e..18d2e8e5 100644 --- a/internal/crq/repoconfig.go +++ b/internal/crq/repoconfig.go @@ -23,10 +23,18 @@ type ReviewerView struct { // per-repo overrides — an older binary loads the field, writes it back // untouched, and keeps deciding from its own fleet-wide configuration. The // override is real; those hosts will not honour it until they are upgraded. - Lagging []string `json:"lagging_hosts,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` - By string `json:"by,omitempty"` - Reviewers []ReviewerDetail `json:"reviewers"` + Lagging []string `json:"lagging_hosts,omitempty"` + // PrimaryOff says the metered primary does not review this repository — + // which is why it is absent from Reviewers below. Without it the list reads + // as a fleet that never had one. + PrimaryOff bool `json:"primary_off,omitempty"` + Primary string `json:"primary,omitempty"` + // LaggingPrimaryOff names hosts that understand per-repo overrides but not + // this switch, and would still fire the primary here. + LaggingPrimaryOff []string `json:"lagging_primary_off,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + By string `json:"by,omitempty"` + Reviewers []ReviewerDetail `json:"reviewers"` } // ReviewerDetail is one reviewer as it will actually be used. @@ -53,10 +61,14 @@ func (s *Service) Reviewers(ctx context.Context, repo string) (ReviewerView, err return ReviewerView{}, err } cfg := s.cfgFor(st, repo) - view := ReviewerView{Repo: repo, Reviewers: []ReviewerDetail{}} + view := ReviewerView{Repo: repo, Reviewers: []ReviewerDetail{}, Primary: cfg.Bot} view.Lagging = st.LaggingWriters(CapsRepoOverrides, s.clock().UTC()) if ov, ok := st.RepoOverride(repo); ok { view.Overridden = true + view.PrimaryOff = ov.PrimaryOff + if ov.PrimaryOff { + view.LaggingPrimaryOff = st.LaggingWriters(CapsPrimaryOff, s.clock().UTC()) + } view.By = ov.By if ov.UpdatedAt != nil { view.UpdatedAt = ov.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z") @@ -77,149 +89,331 @@ func (s *Service) Reviewers(ctx context.Context, repo string) (ReviewerView, err // convergence. A nil list means "leave that half alone"; an empty non-nil list // means "none here", which is a different thing and has to survive as one. // -// The primary is not settable. Its markers and command are injected into the -// dialect classifiers when the Service is built, so a per-repo primary would -// mean per-repo classifiers. -func (s *Service) SetReviewers(ctx context.Context, repo string, coBots, required []string) (ReviewerView, error) { +// primary is nil to leave the primary switch alone, or points at whether the +// primary runs here at all. WHICH bot is primary is still not settable: its +// markers and command are injected into the dialect classifiers when the +// Service is built, so a per-repo primary would mean per-repo classifiers. +// Turning it off is a different question, and one a private repository on a +// free plan has to be able to answer. +func (s *Service) SetReviewers(ctx context.Context, repo string, coBots, required []string, primary *bool) (ReviewerView, error) { + view, _, err := s.SetReviewersAt(ctx, repo, coBots, required, primary, nil) + return view, err +} + +// PreviewReviewers reports which completed open rounds a repository edit would +// invalidate, without writing the override. +func (s *Service) PreviewReviewers(ctx context.Context, repo string, coBots, required []string, primary *bool) (FleetImpact, error) { repo = NormalizeRepo(repo) if err := checkRepoShape(repo); err != nil { - return ReviewerView{}, err + return FleetImpact{}, err } - if coBots == nil && required == nil { - // Neither half was named, so there is nothing to set. Writing anyway would - // stamp a fresh UpdatedAt, and that timestamp IS the override's identity: - // applyFire revalidates against it, so a no-op call would discard every - // in-flight fire decision for the repository. - return s.Reviewers(ctx, repo) - } - // Accept either spelling: the login (chatgpt-codex-connector[bot]) or the - // short config name (codex), which is what CRQ_COBOTS already takes. - // Resolve against the REGISTRY, not the fleet's enabled list. Restricting a - // project to bots the fleet already runs would make this feature only ever - // subtract, when the point is choosing different reviewers per project. - // Either spelling works: the login, or the short name CRQ_COBOTS takes. - known := map[string]string{} - for _, co := range dialect.KnownCoReviewers() { - known[dialect.NormalizeBotName(co.Login)] = co.Login - known[strings.ToLower(strings.TrimSpace(co.Name))] = co.Login + setCoBots, err := validateReviewerEdit(coBots, required) + if err != nil { + return FleetImpact{}, err } - resolve := func(allowed map[string]string, list []string, what string) ([]string, error) { - out := make([]string, 0, len(list)) - for _, name := range list { - name = strings.TrimSpace(name) - if name == "" { - continue - } - login, ok := allowed[dialect.NormalizeBotName(name)] - if !ok { - login, ok = allowed[strings.ToLower(name)] - } - if !ok { - return nil, fmt.Errorf("%s: unknown reviewer %q (known: %s)", what, name, strings.Join(knownLogins(allowed), ", ")) - } - out = append(out, login) - } - return out, nil + st, _, err := s.store.Load(ctx) + if err != nil { + return FleetImpact{}, err } - - // Both halves are resolved here; the MERGE happens inside the CAS closure, - // because two hosts setting different halves would otherwise derive from the - // same snapshot and the later write would drop the earlier one's half — and - // a retry that reuses an already-merged value cannot fix that. - var setCoBots, setRequired []string - if coBots != nil { - resolved, err := resolve(known, coBots, "--bots") - if err != nil { - return ReviewerView{}, err - } - setCoBots = resolved + _, before, after, changed, err := s.applyReviewerEdit(st, repo, coBots, setCoBots, required, primary) + if err != nil { + return FleetImpact{}, err } - if required != nil { - if len(required) == 0 { - // Gating on nobody means Feedback reports converged before anything - // runs, so `crq next` says done and the reviewers chosen by --bots - // never review at all. - return ReviewerView{}, errors.New("--required cannot be empty: a round that gates on nobody converges before any reviewer runs (crq reviewers clear to drop the override)") - } - // The primary may gate here even though it cannot be replaced here. - allowed := map[string]string{dialect.NormalizeBotName(s.cfg.Bot): s.cfg.Bot} - for k, v := range known { - allowed[k] = v - } - resolved, err := resolve(allowed, required, "--required") - if err != nil { - return ReviewerView{}, err - } - setRequired = resolved + if !changed { + return FleetImpact{Rev: st.Rev, Changes: []string{}, Summary: "nothing would change"}, nil } + impact, _, err := s.analyzeReviewerChange(ctx, st, repo, before, after) + return impact, err +} - // Read before the write: the requeue below may only touch live pull requests, - // and the CAS closure cannot ask GitHub. A failed lookup fails the whole - // command, which is retryable — writing the override and skipping the requeue - // would strand exactly the PRs the requeue exists for. - open, err := s.openPRs(ctx, repo) +// SetReviewersAt binds a dashboard save to the state revision its consequence +// preview described. CLI callers omit expectedRev and retain CAS-merged edits. +func (s *Service) SetReviewersAt( + ctx context.Context, + repo string, + coBots, required []string, + primary *bool, + expectedRev *int64, +) (ReviewerView, FleetImpact, error) { + repo = NormalizeRepo(repo) + if err := checkRepoShape(repo); err != nil { + return ReviewerView{}, FleetImpact{}, err + } + if coBots == nil && required == nil && primary == nil { + view, err := s.Reviewers(ctx, repo) + return view, FleetImpact{}, err + } + setCoBots, err := validateReviewerEdit(coBots, required) if err != nil { - return ReviewerView{}, err + return ReviewerView{}, FleetImpact{}, err + } + loaded, _, err := s.store.Load(ctx) + if err != nil { + return ReviewerView{}, FleetImpact{}, err + } + if err := checkFleetPreviewRevision(loaded, expectedRev); err != nil { + return ReviewerView{}, FleetImpact{}, err + } + _, before, after, changed, err := s.applyReviewerEdit(loaded, repo, coBots, setCoBots, required, primary) + if err != nil { + return ReviewerView{}, FleetImpact{}, err + } + if !changed { + view, err := s.Reviewers(ctx, repo) + return view, FleetImpact{Rev: loaded.Rev, Changes: []string{}, Summary: "nothing would change"}, err + } + impact, open, err := s.analyzeReviewerChange(ctx, loaded, repo, before, after) + if err != nil { + return ReviewerView{}, FleetImpact{}, err } now := s.clock().UTC() state, err := s.store.Update(ctx, func(st *State) error { - base := s.fleetCfg(*st) - ov, _ := st.RepoOverride(repo) - beforeOverride := ov - if coBots != nil { - ov.CoBots, ov.SetCoBots = setCoBots, true - } - if required != nil { - ov.Required, ov.SetRequired = setRequired, true - } - if ov.SetCoBots == beforeOverride.SetCoBots && - ov.SetRequired == beforeOverride.SetRequired && - sameLogins(ov.CoBots, beforeOverride.CoBots) && - sameLogins(ov.Required, beforeOverride.Required) { + if err := checkFleetPreviewRevision(*st, expectedRev); err != nil { + return err + } + ov, was, is, changed, err := s.applyReviewerEdit(*st, repo, coBots, setCoBots, required, primary) + if err != nil { + return err + } + if !changed { return ErrNoChange } + if claimedTriggerRepo(st, repo, now) { + return errors.New("a review trigger is already being posted; wait for it to finish before changing this repository's reviewers") + } ov.UpdatedAt, ov.By = &now, s.cfg.Host - before := base.ForRepo(mustOverride(st, repo)) st.SetRepoOverride(repo, ov) - s.reopenForChangedReviewers(st, repo, before, base.ForRepo(ov), open) + s.reopenForChangedReviewers(st, repo, was, is, open) return nil }) if err != nil { - return ReviewerView{}, err + return ReviewerView{}, FleetImpact{}, err } s.sync(ctx, state) - return s.Reviewers(ctx, repo) + view, err := s.Reviewers(ctx, repo) + return view, impact, err +} + +func validateReviewerEdit(coBots, required []string) ([]string, error) { + var setCoBots []string + if coBots != nil { + resolved, err := resolveCoBotLogins(coBots) + if err != nil { + return nil, err + } + setCoBots = resolved + } + if required != nil && len(required) == 0 { + return nil, errors.New("--required cannot be empty: a round that gates on nobody converges before any reviewer runs (crq reviewers clear to drop the override)") + } + return setCoBots, nil +} + +// applyReviewerEdit resolves both halves from one state revision. SetReviewersAt +// calls it again inside CAS so two CLI hosts editing different halves merge +// instead of the later write dropping the earlier one. +func (s *Service) applyReviewerEdit( + st State, + repo string, + coBots, setCoBots, required []string, + primary *bool, +) (RepoReviewers, Config, Config, bool, error) { + ov, _ := st.RepoOverride(repo) + beforeOverride := ov + before := s.cfgFor(st, repo) + if coBots != nil { + ov.CoBots, ov.SetCoBots = setCoBots, true + } + if required != nil { + resolved, err := resolveRequiredLoginsPreserving(required, before.Bot, before.RequiredBots) + if err != nil { + return RepoReviewers{}, Config{}, Config{}, false, err + } + ov.Required, ov.SetRequired = resolved, true + } + if primary != nil { + ov.PrimaryOff = !*primary + } + changed := ov.SetCoBots != beforeOverride.SetCoBots || + ov.SetRequired != beforeOverride.SetRequired || + ov.PrimaryOff != beforeOverride.PrimaryOff || + !sameLogins(ov.CoBots, beforeOverride.CoBots) || + !sameLogins(ov.Required, beforeOverride.Required) + base := s.cfg.WithFleet(st.Fleet) + after := base.ForRepo(ov) + if len(after.RequiredBots) == 0 { + return RepoReviewers{}, Config{}, Config{}, false, fmt.Errorf( + "that would leave %s with no required reviewer, so every round would converge before any bot answers — require a co-reviewer first", repo) + } + return ov, before, after, changed, nil } // ClearReviewers returns repo to the fleet default. func (s *Service) ClearReviewers(ctx context.Context, repo string) (ReviewerView, error) { + view, _, err := s.ClearReviewersAt(ctx, repo, nil) + return view, err +} + +// PreviewClearReviewers reports which inherited reviewers a reset restores and +// how many completed open rounds need another review. +func (s *Service) PreviewClearReviewers(ctx context.Context, repo string) (FleetImpact, error) { + repo = NormalizeRepo(repo) + if err := checkRepoShape(repo); err != nil { + return FleetImpact{}, err + } + st, _, err := s.store.Load(ctx) + if err != nil { + return FleetImpact{}, err + } + impact, _, err := s.analyzeClearReviewers(ctx, st, repo) + return impact, err +} + +// ClearReviewersAt binds the reset to the state revision its preview described. +func (s *Service) ClearReviewersAt(ctx context.Context, repo string, expectedRev *int64) (ReviewerView, FleetImpact, error) { repo = NormalizeRepo(repo) // A typo like "owner-repo" would otherwise clear a key nothing uses and exit // 0, so automation believes it restored the fleet default while the real // override is still in force. if err := checkRepoShape(repo); err != nil { - return ReviewerView{}, err + return ReviewerView{}, FleetImpact{}, err } - open, err := s.openPRs(ctx, repo) + loaded, _, err := s.store.Load(ctx) if err != nil { - return ReviewerView{}, err + return ReviewerView{}, FleetImpact{}, err + } + if err := checkFleetPreviewRevision(loaded, expectedRev); err != nil { + return ReviewerView{}, FleetImpact{}, err + } + impact, open, err := s.analyzeClearReviewers(ctx, loaded, repo) + if err != nil { + return ReviewerView{}, FleetImpact{}, err } + now := s.clock().UTC() state, err := s.store.Update(ctx, func(st *State) error { - base := s.fleetCfg(*st) - before := base.ForRepo(mustOverride(st, repo)) - if !st.ClearRepoOverride(repo) { + if err := checkFleetPreviewRevision(*st, expectedRev); err != nil { + return err + } + // Clearing returns the repository to the FLEET default, so that is what + // the "after" side has to be — s.cfg is this host's env, which is only + // the same thing when the fleet has recorded nothing. + base := s.cfg.WithFleet(st.Fleet) + override, ok := st.RepoOverride(repo) + if !ok { return ErrNoChange } + before := base.ForRepo(override) + if claimedTriggerRepo(st, repo, now) { + return errors.New("a review trigger is already being posted; wait for it to finish before resetting this repository's reviewers") + } + st.ClearRepoOverride(repo) s.reopenForChangedReviewers(st, repo, before, base, open) return nil }) if err != nil && !errors.Is(err, ErrNoChange) { - return ReviewerView{}, err + return ReviewerView{}, FleetImpact{}, err } if err == nil { s.sync(ctx, state) } - return s.Reviewers(ctx, repo) + view, err := s.Reviewers(ctx, repo) + return view, impact, err +} + +// analyzeClearReviewers returns both the user-visible consequence preview and +// the exact open-PR snapshot used to apply it. A confirmed reset must not ask +// GitHub a second time and silently act on a different set than it displayed. +func (s *Service) analyzeClearReviewers(ctx context.Context, st State, repo string) (FleetImpact, map[int]bool, error) { + impact := FleetImpact{Rev: st.Rev, Changes: []string{}} + ov, ok := st.RepoOverride(repo) + if !ok { + impact.Summary = "nothing would change" + return impact, nil, nil + } + base := s.cfg.WithFleet(st.Fleet) + before, after := base.ForRepo(ov), base + beforeRuns := before.reviewerLogins(func(Reviewer) bool { return true }) + afterRuns := after.reviewerLogins(func(Reviewer) bool { return true }) + if !sameLogins(beforeRuns, afterRuns) { + impact.Changes = append(impact.Changes, fmt.Sprintf("reviewers running: %s → %s", + shortBots(beforeRuns), shortBots(afterRuns))) + } + if !sameLogins(before.RequiredBots, after.RequiredBots) { + impact.Changes = append(impact.Changes, fmt.Sprintf("required reviewers: %s → %s", + shortBots(before.RequiredBots), shortBots(after.RequiredBots))) + } + if sameLogins(beforeRuns, afterRuns) && !sameTriggerSettings(before.Reviewers, after.Reviewers) { + impact.Changes = append(impact.Changes, "reviewer trigger policy or command changed") + } + if len(impact.Changes) == 0 { + impact.Summary = "removes the override; effective reviewers stay the same" + return impact, nil, nil + } + impact.Repos = 1 + beforeCo := before.reviewerLogins(func(r Reviewer) bool { return !r.Metered() }) + afterCo := after.reviewerLogins(func(r Reviewer) bool { return !r.Metered() }) + var open map[int]bool + if addedReviewers(before, after, beforeCo, afterCo) { + var err error + open, err = s.openPRs(ctx, repo) + if err != nil { + return FleetImpact{}, nil, err + } + for _, round := range st.Rounds { + if NormalizeRepo(round.Repo) == repo && round.Phase == PhaseCompleted && open[round.PR] { + impact.Reopened++ + } + } + } + if impact.Reopened > 0 { + impact.Summary = fmt.Sprintf("resets to the fleet default; %d completed round(s) would be reopened and reviewed again", impact.Reopened) + } else { + impact.Summary = "resets to the fleet default; no round would be reopened" + } + return impact, open, nil +} + +func (s *Service) analyzeReviewerChange( + ctx context.Context, + st State, + repo string, + before, after Config, +) (FleetImpact, map[int]bool, error) { + impact := FleetImpact{Rev: st.Rev, Repos: 1, Changes: []string{}} + beforeRuns := before.reviewerLogins(func(Reviewer) bool { return true }) + afterRuns := after.reviewerLogins(func(Reviewer) bool { return true }) + if !sameLogins(beforeRuns, afterRuns) { + impact.Changes = append(impact.Changes, fmt.Sprintf("reviewers running: %s → %s", + shortBots(beforeRuns), shortBots(afterRuns))) + } + if !sameLogins(before.RequiredBots, after.RequiredBots) { + impact.Changes = append(impact.Changes, fmt.Sprintf("required reviewers: %s → %s", + shortBots(before.RequiredBots), shortBots(after.RequiredBots))) + } + if sameLogins(beforeRuns, afterRuns) && !sameTriggerSettings(before.Reviewers, after.Reviewers) { + impact.Changes = append(impact.Changes, "reviewer trigger policy or command changed") + } + var open map[int]bool + beforeCo := before.reviewerLogins(func(r Reviewer) bool { return !r.Metered() }) + afterCo := after.reviewerLogins(func(r Reviewer) bool { return !r.Metered() }) + if addedReviewers(before, after, beforeCo, afterCo) { + var err error + open, err = s.openPRs(ctx, repo) + if err != nil { + return FleetImpact{}, nil, err + } + for _, round := range st.Rounds { + if NormalizeRepo(round.Repo) == repo && round.Phase == PhaseCompleted && open[round.PR] { + impact.Reopened++ + } + } + } + if impact.Reopened > 0 { + impact.Summary = fmt.Sprintf("%d completed round(s) would be reopened and reviewed again", impact.Reopened) + } else { + impact.Summary = "no completed round would be reopened" + } + return impact, open, nil } // checkRepoShape is the one repository-shape check every reviewers path applies, @@ -230,8 +424,7 @@ func (s *Service) ClearReviewers(ctx context.Context, repo string) (ReviewerView // otherwise print the fleet default and exit 0, reading as a report about a // project crq follows. func checkRepoShape(repo string) error { - owner, name, ok := strings.Cut(repo, "/") - if !ok || owner == "" || name == "" || strings.Contains(name, "/") { + if !validRepoSlug(repo) { return fmt.Errorf("repo must be owner/name, got %q", repo) } return nil @@ -292,9 +485,19 @@ func mustOverride(st *State, repo string) RepoReviewers { func (s *Service) reopenForChangedReviewers(st *State, repo string, before, after Config, open map[int]bool) { beforeCo := before.reviewerLogins(func(r Reviewer) bool { return !r.Metered() }) afterCo := after.reviewerLogins(func(r Reviewer) bool { return !r.Metered() }) - if sameLogins(before.RequiredBots, after.RequiredBots) && sameLogins(beforeCo, afterCo) { + if sameReviewers(before, after) { return } + // Only ADDING a reviewer can invalidate a finished round. A round that + // converged did so with the reviewers it had; taking one away leaves it + // converged with MORE evidence than the new configuration asks for, and + // re-reviewing it buys nothing. + // + // This is not a nicety. Removing two co-reviewers from a fleet default + // proposed reopening seventeen completed rounds — seventeen metered + // reviews, against a shared allowance, to reach the answer already on + // record. A narrowing must be free. + added := addedReviewers(before, after, beforeCo, afterCo) for _, round := range st.Rounds { if NormalizeRepo(round.Repo) != NormalizeRepo(repo) { continue @@ -309,6 +512,10 @@ func (s *Service) reopenForChangedReviewers(st *State, repo string, before, afte } continue case PhaseCompleted: + if !added { + // Narrowed only: the round's answer still stands. + continue + } default: continue } @@ -393,6 +600,44 @@ func (s *Service) openPRs(ctx context.Context, repo string) (map[int]bool, error return open, nil } +// sameReviewers reports whether two resolved configurations ask the same bots to +// review and gate. It is the question every requeue path starts from, wherever +// the change came from: a per-repo override, a fleet default, or a fleet-wide +// env setting that happens to name the primary. +func sameReviewers(before, after Config) bool { + return sameLogins(before.RequiredBots, after.RequiredBots) && + sameLogins(before.reviewerLogins(func(Reviewer) bool { return true }), + after.reviewerLogins(func(Reviewer) bool { return true })) && + sameTriggerSettings(before.Reviewers, after.Reviewers) +} + +// sameTriggerSettings covers both whether crq posts and what it posts. A +// command is captured before the network call, so changing it while a trigger +// claim is live is the same configuration race as changing its mode. +func sameTriggerSettings(a, b []Reviewer) bool { + if len(a) != len(b) { + return false + } + type setting struct { + trigger engine.TriggerMode + command string + } + triggers := make(map[string]setting, len(a)) + for _, reviewer := range a { + triggers[dialect.NormalizeBotName(reviewer.Login)] = setting{ + trigger: reviewer.Trigger, + command: strings.TrimSpace(reviewer.Command), + } + } + for _, reviewer := range b { + got, ok := triggers[dialect.NormalizeBotName(reviewer.Login)] + if !ok || got.trigger != reviewer.Trigger || got.command != strings.TrimSpace(reviewer.Command) { + return false + } + } + return true +} + // sameLogins compares two reviewer lists as sets, since order is presentation. func sameLogins(a, b []string) bool { if len(a) != len(b) { @@ -411,3 +656,129 @@ func sameLogins(a, b []string) bool { } return true } + +// resolveCoBotLogins turns whichever spelling a caller used — the login +// (chatgpt-codex-connector[bot]) or the short config name (codex) — into +// logins, rejecting anything the registry does not know. +// +// Resolved against the REGISTRY, not the fleet's enabled list: restricting a +// choice to bots the fleet already runs would make the feature only ever +// subtract, when the point is choosing DIFFERENT reviewers. +func resolveCoBotLogins(list []string) ([]string, error) { + return resolveBotList(coReviewerNames(), list, "--bots") +} + +// resolveRequiredLogins additionally accepts the primary, which may gate even +// though it cannot be replaced per repository. +func resolveRequiredLogins(list []string, primary string) ([]string, error) { + allowed := coReviewerNames() + if primary != "" { + allowed[dialect.NormalizeBotName(primary)] = primary + } + return resolveBotList(allowed, list, "--required") +} + +// resolveRequiredLoginsPreserving additionally accepts non-registry gates that +// already exist in the repository's effective configuration. They cannot be +// added through the per-repository editor, but an unrelated edit must not make +// an inherited CRQ_REQUIRED_BOTS login impossible to retain. +func resolveRequiredLoginsPreserving(list []string, primary string, existing []string) ([]string, error) { + allowed := coReviewerNames() + if primary != "" { + allowed[dialect.NormalizeBotName(primary)] = primary + } + for _, login := range existing { + login = strings.TrimSpace(login) + if login == "" { + continue + } + allowed[dialect.NormalizeBotName(login)] = login + allowed[strings.ToLower(login)] = login + } + return resolveBotList(allowed, list, "--required") +} + +// coReviewerNames maps every accepted spelling to its login. Each bot appears +// twice, which is why knownLogins deduplicates by value. +func coReviewerNames() map[string]string { + known := map[string]string{} + for _, co := range dialect.KnownCoReviewers() { + known[dialect.NormalizeBotName(co.Login)] = co.Login + known[strings.ToLower(strings.TrimSpace(co.Name))] = co.Login + } + return known +} + +func resolveBotList(allowed map[string]string, list []string, what string) ([]string, error) { + out := make([]string, 0, len(list)) + seen := map[string]bool{} + for _, name := range list { + name = strings.TrimSpace(name) + if name == "" { + continue + } + login, ok := allowed[dialect.NormalizeBotName(name)] + if !ok { + login, ok = allowed[strings.ToLower(name)] + } + if !ok { + return nil, fmt.Errorf("%s: unknown reviewer %q (known: %s)", what, name, strings.Join(knownLogins(allowed), ", ")) + } + // Both spellings of one bot resolve to one login, so a caller that sent + // "codex" and "chatgpt-codex-connector[bot]" named one reviewer, not two. + // Storing it twice would render it twice and read as a configuration + // mistake nobody made. + if seen[dialect.NormalizeBotName(login)] { + continue + } + seen[dialect.NormalizeBotName(login)] = true + out = append(out, login) + } + return out, nil +} + +// addedReviewers reports whether the new configuration asks for evidence the +// old one did not — a newly required reviewer, a newly enabled co-reviewer, or +// a trigger policy that newly asks an existing reviewer to run. A pure removal +// returns false, which is what makes narrowing free. +func addedReviewers(before, after Config, beforeCo, afterCo []string) bool { + for _, login := range after.RequiredBots { + if !containsBot(before.RequiredBots, login) { + return true + } + } + for _, login := range afterCo { + if !containsBot(beforeCo, login) { + return true + } + } + for _, reviewer := range after.Reviewers { + if reviewer.Metered() { + if !containsBot(before.reviewerLogins(func(r Reviewer) bool { return r.Metered() }), reviewer.Login) { + return true + } + continue + } + if reviewer.Trigger == engine.TriggerNever { + continue + } + for _, old := range before.Reviewers { + if sameBot(old.Login, reviewer.Login) && + triggerPolicyRank(reviewer.Trigger) > triggerPolicyRank(old.Trigger) { + return true + } + } + } + return false +} + +func triggerPolicyRank(mode engine.TriggerMode) int { + switch mode { + case engine.TriggerAlways: + return 2 + case engine.TriggerSelfHeal: + return 1 + default: + return 0 + } +} diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index 7d0e590f..6a61b0e9 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -67,38 +67,6 @@ func TestRepoOverrideReachesTheDecision(t *testing.T) { } } -func TestClearReviewersComparesAgainstFleetPolicy(t *testing.T) { - ctx := context.Background() - cfg := isolatedConfig(t, map[string]string{ - "CRQ_COBOTS": "codex", - "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", - }) - repo, pr := "owner/repo", 19 - now := time.Now().UTC() - gh := newFakeGitHub() - gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} - store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("required-bots", "coderabbitai[bot],"+dialect.CodexBotLogin) - st.SetRepoOverride(repo, RepoReviewers{ - Required: []string{"coderabbitai[bot]"}, - SetRequired: true, - UpdatedAt: &now, - }) - st.PutRound(Round{Repo: repo, PR: pr, Head: "abcdef123", Phase: PhaseCompleted}) - return nil - }); err != nil { - t.Fatal(err) - } - if _, err := NewService(cfg, gh, store, nil).ClearReviewers(ctx, repo); err != nil { - t.Fatal(err) - } - st, _, _ := store.Load(ctx) - if round := st.Round(repo, pr); round == nil || round.Phase != PhaseQueued { - t.Fatalf("round = %+v, want clearing to the fleet reviewer set to reopen it", round) - } -} - // The override must be able to ADD a reviewer, not only subtract one. Resolving // choices against the fleet's enabled list made "which bots for which project" // a one-way filter: with CRQ_COBOTS=codex, asking for bugbot was rejected as @@ -356,7 +324,7 @@ func TestChangingRequirementsReopensACompletedRound(t *testing.T) { svc := NewService(cfg, gh, store, nil) seedRound(t, store, cfg, repo, pr, head, PhaseCompleted, time.Now().UTC(), 11) - if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}, nil); err != nil { t.Fatal(err) } st, _, err := store.Load(ctx) @@ -389,7 +357,7 @@ func TestChangingRequirementsReopensACompletedRound(t *testing.T) { }); err != nil { t.Fatal(err) } - if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}, nil); err != nil { t.Fatal(err) } st, _, _ = store.Load(ctx) @@ -398,6 +366,98 @@ func TestChangingRequirementsReopensACompletedRound(t *testing.T) { } } +func TestPreviewReviewersPricesReenablingThePrimary(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", "CRQ_REPOS": "o/r", + "CRQ_COBOTS": "codex", "CRQ_REQUIRED_BOTS": "codex", + }) + if err != nil { + t.Fatal(err) + } + repo, pr, head := "o/r", 18, "abcdef124" + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + off := false + if _, err := svc.SetReviewers(ctx, repo, nil, nil, &off); err != nil { + t.Fatal(err) + } + seedRound(t, store, cfg, repo, pr, head, PhaseCompleted, time.Now().UTC(), 42) + + on := true + impact, err := svc.PreviewReviewers(ctx, repo, nil, nil, &on) + if err != nil { + t.Fatal(err) + } + if impact.Reopened != 1 || !strings.Contains(strings.Join(impact.Changes, "\n"), "coderabbitai") { + t.Fatalf("impact = %+v, want the primary and one reopened completed round", impact) + } + if _, _, err := svc.SetReviewersAt(ctx, repo, nil, nil, &on, &impact.Rev); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round(repo, pr); round == nil || round.Phase != PhaseQueued { + t.Fatalf("round = %#v, want the confirmed edit to reopen it", round) + } +} + +func TestPreviewClearReviewersPricesRestoredFleetReviewers(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", "CRQ_REPOS": "o/r", + "CRQ_COBOTS": "codex", "CRQ_REQUIRED_BOTS": "coderabbitai[bot],codex", + }) + if err != nil { + t.Fatal(err) + } + repo, pr, head := "o/r", 17, "abcdef123" + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + if _, err := svc.SetReviewers(ctx, repo, []string{}, []string{cfg.Bot}, nil); err != nil { + t.Fatal(err) + } + seedRound(t, store, cfg, repo, pr, head, PhaseCompleted, time.Now().UTC(), 41) + + impact, err := svc.PreviewClearReviewers(ctx, repo) + if err != nil { + t.Fatal(err) + } + if impact.Reopened != 1 || !strings.Contains(strings.Join(impact.Changes, "\n"), "codex") { + t.Fatalf("impact = %+v, want the inherited Codex reviewer and one reopened round", impact) + } + if _, err := store.Update(ctx, func(st *State) error { + st.CalibrationIssue++ + return nil + }); err != nil { + t.Fatal(err) + } + if _, _, err := svc.ClearReviewersAt(ctx, repo, &impact.Rev); err == nil || + !strings.Contains(err.Error(), "preview the change again") { + t.Fatalf("stale reset error = %v, want preview-again refusal", err) + } + impact, err = svc.PreviewClearReviewers(ctx, repo) + if err != nil { + t.Fatal(err) + } + if _, _, err := svc.ClearReviewersAt(ctx, repo, &impact.Rev); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round(repo, pr); round == nil || round.Phase != PhaseQueued { + t.Fatalf("round = %#v, want the confirmed reset to reopen it", round) + } +} + func TestSettingIdenticalReviewersPreservesOverrideIdentity(t *testing.T) { ctx := context.Background() cfg := firingConfig() @@ -406,7 +466,7 @@ func TestSettingIdenticalReviewersPreservesOverrideIdentity(t *testing.T) { first := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) svc.now = func() time.Time { return first } - if _, err := svc.SetReviewers(ctx, "o/r", []string{"codex"}, []string{"codex"}); err != nil { + if _, err := svc.SetReviewers(ctx, "o/r", []string{"codex"}, []string{"codex"}, nil); err != nil { t.Fatal(err) } before, _, err := store.Load(ctx) @@ -419,7 +479,7 @@ func TestSettingIdenticalReviewersPreservesOverrideIdentity(t *testing.T) { } svc.now = func() time.Time { return first.Add(time.Hour) } - if _, err := svc.SetReviewers(ctx, "o/r", []string{"codex"}, []string{"codex"}); err != nil { + if _, err := svc.SetReviewers(ctx, "o/r", []string{"codex"}, []string{"codex"}, nil); err != nil { t.Fatal(err) } after, _, err := store.Load(ctx) @@ -448,7 +508,7 @@ func TestChangingEnabledCoReviewersReopensACompletedRound(t *testing.T) { svc := NewService(cfg, gh, store, nil) seedRound(t, store, cfg, repo, pr, head, PhaseCompleted, time.Now().UTC(), 11) - if _, err := svc.SetReviewers(ctx, repo, []string{"bugbot"}, nil); err != nil { + if _, err := svc.SetReviewers(ctx, repo, []string{"bugbot"}, nil, nil); err != nil { t.Fatal(err) } st, _, err := store.Load(ctx) @@ -490,7 +550,7 @@ func TestChangingReviewersForcesExistingActiveRounds(t *testing.T) { } } - if _, err := svc.SetReviewers(ctx, repo, []string{"bugbot"}, []string{"bugbot"}); err != nil { + if _, err := svc.SetReviewers(ctx, repo, []string{"bugbot"}, []string{"bugbot"}, nil); err != nil { t.Fatal(err) } st, _, err := store.Load(ctx) @@ -537,7 +597,7 @@ func TestDedupeIsRevalidatedAgainstAReviewerChange(t *testing.T) { } // Now the override lands after the decision was made from svc.cfg. - if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}, nil); err != nil { t.Fatal(err) } st, _, err = store.Load(ctx) @@ -594,7 +654,7 @@ func TestDedupeIsRevalidatedInsideItsWrite(t *testing.T) { } round := *st.Round(repo, pr) hooked.hook = func() { - if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}, nil); err != nil { t.Error(err) } } @@ -623,7 +683,7 @@ func TestReviewersRejectAnIncompleteRepository(t *testing.T) { if _, err := svc.Reviewers(ctx, repo); err == nil { t.Errorf("Reviewers(%q) = nil error, want the malformed target refused", repo) } - if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, nil); err == nil { + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, nil, nil); err == nil { t.Errorf("SetReviewers(%q) = nil error, want the malformed target refused", repo) } if _, err := svc.ClearReviewers(ctx, repo); err == nil { @@ -651,7 +711,7 @@ func TestAReopenedPRPicksUpRequirementsChangedWhileItWasClosed(t *testing.T) { } // Both PRs are closed while the required set changes: no round is requeued. - if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}, nil); err != nil { t.Fatal(err) } st, _, err := store.Load(ctx) @@ -694,7 +754,7 @@ func TestAReopenedPRPicksUpRequirementsChangedWhileItWasClosed(t *testing.T) { if !need || gotHead != head { t.Fatalf("needsReview = %v %q, want the reopened PR enqueued at %q", need, gotHead, head) } - if err := svc.enqueueBatch(ctx, []queueCandidate{{Repo: repo, PR: auto, Head: gotHead}}, svc.fleetCfg(st).FleetRevision); err != nil { + if err := svc.enqueueBatch(ctx, []queueCandidate{{Repo: repo, PR: auto, Head: gotHead}}); err != nil { t.Fatal(err) } @@ -729,7 +789,7 @@ func TestChangingRequirementsLeavesClosedPRsAlone(t *testing.T) { seedRound(t, store, cfg, repo, open, "aaaaaaaa1", PhaseCompleted, time.Now().UTC(), 11) seedRound(t, store, cfg, repo, merged, "bbbbbbbb2", PhaseCompleted, time.Now().UTC(), 12) - if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}, nil); err != nil { t.Fatal(err) } st, _, err := store.Load(ctx) @@ -801,6 +861,32 @@ func TestOverrideAddsTheSilencedPrimaryEntryTheFleetLacked(t *testing.T) { } } +func TestRequiredReviewersResolveAgainstTheCurrentFleetPrimary(t *testing.T) { + ctx := context.Background() + repo := "o/repo" + cfg := isolatedConfig(t, map[string]string{ + "CRQ_REPO": "o/gate", "CRQ_REPOS": repo, "CRQ_COBOTS": "", + }) + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + const current = "replacement-reviewer[bot]" + if _, err := svc.SetEnv(ctx, "CRQ_BOT", current, false); err != nil { + t.Fatal(err) + } + view, err := svc.SetReviewers(ctx, repo, nil, []string{current}, nil) + if err != nil { + t.Fatalf("current fleet primary was rejected using the process's retired startup primary: %v", err) + } + found := false + for _, reviewer := range view.Reviewers { + found = found || (sameBot(reviewer.Login, current) && reviewer.Required) + } + if !found { + t.Fatalf("reviewers = %+v, want the current fleet primary required", view.Reviewers) + } +} + // An operator who sets CRQ_COBOT__TRIGGER=never has disabled that bot's // command everywhere: the fleet parse already lets that value win over the // registry's required trigger. A repository requiring the bot must not be able @@ -850,7 +936,7 @@ func TestCompletionIsRevalidatedInsideItsWrite(t *testing.T) { } stale := svc.cfgFor(st, repo) hooked.hook = func() { - if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}, nil); err != nil { t.Error(err) } } @@ -865,57 +951,6 @@ func TestCompletionIsRevalidatedInsideItsWrite(t *testing.T) { } } -// A retry is policy-dependent too: its RetryAt and account block are computed -// from the fleet's rate-limit fallback, so a widened window landing in the same -// decision/write gap would be persisted at the old, earlier deadline — and the -// account unblocked in time for another metered review the fleet meant to defer. -func TestRetryIsRevalidatedInsideItsWrite(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - store := NewMemoryStore(cfg) - hooked := &hookedStore{StateStore: store} - svc := NewService(cfg, newFakeGitHub(), hooked, nil) - now := time.Now().UTC() - repo, pr, head := "o/r", 14, "aaaaaaaa1" - seedRound(t, store, cfg, repo, pr, head, PhaseFired, now, 22) - - st, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - stale := svc.cfgFor(st, repo) - hooked.hook = func() { - if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("rate-limit-fallback", "2h") - return nil - }); err != nil { - t.Error(err) - } - } - until := now.Add(15 * time.Minute) - retry := engine.Transition{ - Outcome: engine.OutRetry, - Reason: dialect.ReasonRateLimited, - RetryAt: until, - Blocked: &engine.AccountBlock{Until: until, CommentID: 99, CommentUpdated: now}, - } - if _, err := hooked.Update(ctx, func(st *State) error { - return svc.applyTransition(st, st.Round(repo, pr), retry, now, stale) - }); err != nil { - t.Fatal(err) - } - if got := roundPhase(t, store, repo, pr); got != PhaseFired { - t.Fatalf("phase = %s, want the stale retry dropped so it is recomputed under the fleet's window", got) - } - st, _, err = store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if st.Account.BlockedUntil != nil { - t.Fatalf("the block computed from the old window must not be recorded, got %v", st.Account.BlockedUntil) - } -} - // Feedback and its completion write have the same decision/write window as // Progress. A reviewer override landing in that window must leave the in-flight // round open so the next observation can ask the newly required reviewer. @@ -938,7 +973,7 @@ func TestFeedbackCompletionIsRevalidatedInsideItsWrite(t *testing.T) { } stale := svc.cfgFor(st, repo) hooked.hook = func() { - if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}, nil); err != nil { t.Error(err) } } @@ -977,7 +1012,7 @@ func TestSelfHealTriggerIsRevalidatedInsideItsClaim(t *testing.T) { obs := engine.Observation{Head: head, Open: true} hooked.hook = func() { // The operator drops the co-reviewer, keeping only the primary. - if _, err := svc.SetReviewers(ctx, repo, []string{}, []string{cfg.Bot}); err != nil { + if _, err := svc.SetReviewers(ctx, repo, []string{}, []string{cfg.Bot}, nil); err != nil { t.Error(err) } } @@ -997,73 +1032,183 @@ func TestSelfHealTriggerIsRevalidatedInsideItsClaim(t *testing.T) { } } -func TestSelfHealDoesNotTriggerForFleetExcludedRepository(t *testing.T) { +// TestTurningThePrimaryOff pins the switch a private repository on a free plan +// needs: the metered reviewer stops running there, stops gating convergence, +// and cannot be turned off when nobody else would be left to answer. +func TestTurningThePrimaryOff(t *testing.T) { ctx := context.Background() cfg := firingConfig() - cfg.RequiredBots = []string{cfg.Bot, dialect.CodexBotLogin} - cfg.CoBots = codexCoBots(cfg.RequiredBots) - cfg.ExcludeRepos = map[string]bool{"o/r": true} + cfg.CoBots = codexCoBots(nil) store := NewMemoryStore(cfg) gh := newFakeGitHub() svc := NewService(cfg, gh, store, nil) - now := time.Now().UTC() - seedRound(t, store, cfg, "o/r", 13, "aaaaaaaa1", PhaseFired, now, 22) + repo := "o/private" + off, on := false, true + + // Nobody else is required yet, so turning the primary off would leave a + // round gating on nobody — refused at edit time, not discovered later. + if _, err := svc.SetReviewers(ctx, repo, nil, nil, &off); err == nil { + t.Fatal("turning off the only required reviewer must be refused") + } + + // With a co-reviewer required, the switch takes. + view, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex", cfg.Bot}, &off) + if err != nil { + t.Fatal(err) + } + if !view.PrimaryOff { + t.Error("view must say the primary is off, or the empty Reviewers list reads as a fleet without one") + } + for _, r := range view.Reviewers { + if sameBot(r.Login, cfg.Bot) { + t.Fatalf("reviewers = %+v, want the primary absent", view.Reviewers) + } + } + + // The fleet default required set names the primary. A repository that never + // rewrote that list must still converge, so a reviewer that does not run + // here cannot gate here. st, _, err := store.Load(ctx) if err != nil { t.Fatal(err) } + ov, _ := st.RepoOverride(repo) + got := cfg.ForRepo(ov) + for _, bot := range got.RequiredBots { + if sameBot(bot, cfg.Bot) { + t.Errorf("RequiredBots = %v, want the primary dropped once it stopped running here", got.RequiredBots) + } + } + if _, ok := got.Primary(); ok { + t.Error("Primary() must find nothing to fire on a repository that turned it off") + } - svc.selfHealCoReviewers(ctx, cfg, *st.Round("o/r", 13), engine.Observation{ - Head: "aaaaaaaa1", Open: true, - }, now) + // And it is reversible without dropping the rest of the override. + view, err = svc.SetReviewers(ctx, repo, nil, nil, &on) + if err != nil { + t.Fatal(err) + } + if view.PrimaryOff { + t.Error("primary must be back on") + } + if len(view.Reviewers) == 0 { + t.Fatal("reviewers must list the primary again") + } +} - if len(gh.posted) != 0 { - t.Fatalf("excluded repository received a co-review trigger: %v", gh.posted) +func TestTurningThePrimaryOffRefusesAClaimedTriggerPost(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.RequiredBots = []string{cfg.Bot, dialect.CodexBotLogin} + cfg.CoBots = codexCoBots(cfg.RequiredBots) + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + repo := "o/private" + if _, err := store.Update(ctx, func(st *State) error { + round, err := st.NewRound(repo, 7, "abcdef123", time.Now()) + if err != nil { + return err + } + round.Phase = PhaseReserved + st.PutRound(*round) + return nil + }); err != nil { + t.Fatal(err) } - st, _, err = store.Load(ctx) + + off := false + if _, err := svc.SetReviewers(ctx, repo, nil, nil, &off); err == nil { + t.Fatal("turning the primary off succeeded while its trigger was being posted") + } + view, err := svc.Reviewers(ctx, repo) if err != nil { t.Fatal(err) } - if c := st.Round("o/r", 13).Co(dialect.CodexBotLogin); c.ClaimedAt != nil || c.CommandID != 0 { - t.Fatalf("excluded repository retained a co-review claim: %+v", c) + if view.PrimaryOff { + t.Fatal("the rejected primary-off action still changed the repository override") } } -// The co-review wait is the one fire verdict with no later reconciliation to -// undo it: the round is still queued when a reviewer change lands, requeuing a -// queued round is a no-op, and the stale decision then parks it in reviewing -// with commands adopted for the reviewer set that no longer gates it. -func TestCoReviewWaitIsRevalidatedInsideItsWrite(t *testing.T) { +func TestRemovingACoReviewerRefusesItsClaimedTriggerPost(t *testing.T) { ctx := context.Background() cfg := firingConfig() - cfg.CoBots = codexCoBots(nil) + cfg.RequiredBots = []string{cfg.Bot, dialect.CodexBotLogin} + cfg.CoBots = codexCoBots(cfg.RequiredBots) store := NewMemoryStore(cfg) - hooked := &hookedStore{StateStore: store} - svc := NewService(cfg, newFakeGitHub(), hooked, nil) - now := time.Now().UTC() - repo, pr, head := "o/r", 12, "aaaaaaaa2" - seedRound(t, store, cfg, repo, pr, head, PhaseQueued, now, 0) + svc := NewService(cfg, newFakeGitHub(), store, nil) + repo := "o/private" + if _, err := store.Update(ctx, func(st *State) error { + round, err := st.NewRound(repo, 8, "abcdef123", time.Now()) + if err != nil { + return err + } + round.ClaimCo(dialect.CodexBotLogin, time.Now()) + st.PutRound(*round) + return nil + }); err != nil { + t.Fatal(err) + } + if _, err := svc.SetReviewers(ctx, repo, []string{}, []string{cfg.Bot}, nil); err == nil { + t.Fatal("removing Codex succeeded while its trigger was being posted") + } st, _, err := store.Load(ctx) if err != nil { t.Fatal(err) } - round := *st.Round(repo, pr) - hooked.hook = func() { - if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { - t.Error(err) - } + if _, changed := st.RepoOverride(repo); changed { + t.Fatal("the rejected edit still persisted a repository override") } - wait := engine.FireDecision{Verdict: engine.FireCoReviewWait, Reason: "awaiting co-review"} - obs := engine.Observation{Open: true, Head: head, HeadAt: now.Add(-time.Minute)} - result, err := svc.applyFire(ctx, svc.cfgFor(st, repo), round, obs, wait, now) +} + +func TestReviewerEditPreservesAnExistingCustomRequiredLogin(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.RequiredBots = []string{cfg.Bot, "sonar[bot]"} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + view, err := svc.SetReviewers( + ctx, + "o/custom-gate", + []string{"codex"}, + []string{cfg.Bot, "sonar[bot]"}, + nil, + ) if err != nil { t.Fatal(err) } - if result.Action != "lost_race" { - t.Errorf("action = %q, want the wait voided by the override that beat its write", result.Action) + found := false + for _, reviewer := range view.Reviewers { + if reviewer.Login == "sonar[bot]" && reviewer.Required { + found = true + } + } + if !found { + t.Fatalf("reviewers = %+v, want the existing custom gate retained", view.Reviewers) + } +} + +func TestTurningThePrimaryBackOnReopensCompletedRounds(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.CoBots = codexCoBots(nil) + store := NewMemoryStore(cfg) + gh := newFakeGitHub() + repo, pr, head := "o/private", 7, "aaaaaaaa1" + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + svc := NewService(cfg, gh, store, nil) + off, on := false, true + + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}, &off); err != nil { + t.Fatal(err) + } + seedRound(t, store, cfg, repo, pr, head, PhaseCompleted, time.Now().UTC(), 11) + + if _, err := svc.SetReviewers(ctx, repo, nil, nil, &on); err != nil { + t.Fatal(err) } if got := roundPhase(t, store, repo, pr); got != PhaseQueued { - t.Fatalf("phase = %s, want the round left queued so the new reviewer set decides it", got) + t.Fatalf("phase = %s, want the completed round reopened for the newly enabled primary", got) } } diff --git a/internal/crq/reviewers.go b/internal/crq/reviewers.go index 1a998acd..f8450eb3 100644 --- a/internal/crq/reviewers.go +++ b/internal/crq/reviewers.go @@ -1,6 +1,7 @@ package crq import ( + "context" "strings" "time" @@ -92,7 +93,7 @@ func (c Config) reviewerLogins(want func(Reviewer) bool) []string { // once metered, once free-running; // - the primary's trigger lives in ReviewCommand, and an empty Command means // "crq cannot ask this reviewer", which is false for it. -func buildReviewers(primary, primaryCommand string, required []string, coBots []CoBotConfig) []Reviewer { +func buildReviewers(primary, primaryCommand string, required []string, coBots []CoBotConfig, primaryOff bool) []Reviewer { requiredSet := map[string]bool{} for _, login := range required { if login = strings.TrimSpace(login); login != "" { @@ -102,17 +103,26 @@ func buildReviewers(primary, primaryCommand string, required []string, coBots [] seen := map[string]bool{} out := make([]Reviewer, 0, len(coBots)+len(required)+1) + // A repository that turned the primary off has no metered reviewer at all: + // leaving the entry in place with Required false would still let Primary() + // find something to fire. if primary != "" { key := dialect.NormalizeBotName(primary) + // Even when this repository disables the primary, reserve its identity. + // A registry-backed primary also has a silenced CoBots entry carrying + // classifier hooks; without this marker the loop below rebuilt that + // entry as a participating free reviewer. seen[key] = true - out = append(out, Reviewer{ - Login: primary, - Name: key, - Budget: dialect.BudgetAccount, - Required: requiredSet[key], - Command: primaryCommand, - Trigger: engine.TriggerAlways, - }) + if !primaryOff { + out = append(out, Reviewer{ + Login: primary, + Name: key, + Budget: dialect.BudgetAccount, + Required: requiredSet[key], + Command: primaryCommand, + Trigger: engine.TriggerAlways, + }) + } } for _, cb := range coBots { key := dialect.NormalizeBotName(cb.Login) @@ -183,16 +193,25 @@ func (c Config) evidenceBots() map[string]struct{} { // per-repo primary would mean per-repo classifiers — a much larger change than // "which co-reviewers run here", which is what the request actually is. func (c Config) ForRepo(ov RepoReviewers) Config { - if !ov.SetCoBots && !ov.SetRequired { + if !ov.SetCoBots && !ov.SetRequired && !ov.PrimaryOff { return c } out := c + out.PrimaryOff = ov.PrimaryOff // The effective required set, whichever half the override named. required := c.RequiredBots if ov.SetRequired { required = ov.Required } + // A reviewer that does not run here cannot gate here. The fleet default + // required set names the primary, so a repository that turns it off without + // also rewriting that list would otherwise wait for ever on a bot crq never + // asks. Dropping it is not a silent override of the operator's choice: it is + // the same choice, read consistently. + if ov.PrimaryOff { + required = withoutBot(required, c.Bot) + } // The effective co-reviewer set. Required implies enabled — the rule the // fleet parse already follows, and it has to hold when only the required @@ -270,10 +289,10 @@ func (c Config) ForRepo(ov RepoReviewers) Config { // Rebuild the derived views from the overridden lists, exactly as // LoadConfig does, so no view can answer differently from another. - out.Reviewers = buildReviewers(out.Bot, out.ReviewCommand, out.RequiredBots, out.CoBots) + out.Reviewers = buildReviewers(out.Bot, out.ReviewCommand, out.RequiredBots, out.CoBots, out.PrimaryOff) out.RequiredBots = out.reviewerLogins(func(r Reviewer) bool { return r.Required }) if !c.FeedbackBotsExplicit { - out.FeedbackBots = out.reviewerLogins(func(r Reviewer) bool { return r.Required || !r.Metered() }) + out.FeedbackBots = out.reviewerLogins(func(Reviewer) bool { return true }) } return out } @@ -322,10 +341,13 @@ func containsBot(logins []string, login string) bool { } // cfgFor is the configuration crq should use for one repository: this host's -// own settings, then the fleet's recorded policy, then that repository's -// override. Each layer is narrower than the one before it. +// env, then the fleet's recorded defaults, then that repository's override. +// +// The order is the whole point. Env is the oldest and least specific layer, a +// per-host file that predates any of this; the fleet record is one answer every +// host shares; the repository override is the most specific and wins outright. func (s *Service) cfgFor(st State, repo string) Config { - base := s.fleetCfg(st) + base := s.cfg.WithFleet(st.Fleet).withSolver(st.EffectiveSolver(repo)) ov, ok := st.RepoOverride(repo) if !ok { return base @@ -335,50 +357,71 @@ func (s *Service) cfgFor(st State, repo string) Config { return out } -// fleetCfg is this host's configuration with the fleet's policy applied. It is -// the base every other decision starts from, and the reason a setting only has -// to be changed in one place for every machine to follow it. -func (s *Service) fleetCfg(st State) Config { - cfg := s.cfg - if len(st.FleetConfig) != 0 { - cfg = applyFleet(cfg, st.FleetConfig, s.warnOnce) - } - cfg.FleetRevision = fleetRevision(st) - return cfg -} - -func fleetRevision(st State) string { - var b strings.Builder - for _, key := range st.FleetKeys() { - b.WriteString(key) - b.WriteByte(0) - b.WriteString(st.FleetConfig[key]) - b.WriteByte(0) - } - return b.String() -} - -// warnOnce logs a fleet-configuration complaint at most once per process. The -// same unreadable setting is met on every pass, and a daemon repeating it every -// two minutes for a week is how a log stops being read. -func (s *Service) warnOnce(msg string) { - if s.log == nil { - return - } - s.fleetWarned.Lock() - defer s.fleetWarned.Unlock() - if s.fleetWarnedOf == nil { - s.fleetWarnedOf = map[string]bool{} - } - if s.fleetWarnedOf[msg] { - return - } - s.fleetWarnedOf[msg] = true - s.log.Printf("config: %s", msg) +// WithFleet applies the fleet's recorded defaults to this host's configuration. +// An absent field keeps the env value, so a fleet that has never written a +// record behaves exactly as it did before the record existed. +// Each field decides its own absence, deliberately: gating the whole function +// on UpdatedAt would make a PROPOSED record — one built for a preview and not +// yet stamped — read as no change at all, which is the one answer a preview +// must never give. +func (c Config) WithFleet(fd FleetDefaults) Config { + out := c + // The generic layer first, since the typed fields below are refinements of + // the same settings and must win over it. Re-parsing the merged map rather + // than patching fields is what keeps one meaning per setting: a duration + // string becomes a duration the same way whichever layer supplied it. + if len(fd.Env) > 0 && c.env != nil { + merged := c.Env() + applied := false + for key, value := range fd.Env { + if !fleetReadable(key) { + continue // identity and per-host settings are not the fleet's to set + } + merged[key] = value + applied = true + } + if applied { + if rebuilt, err := BuildConfig(merged); err == nil { + // Everything a caller already resolved for THIS process stays: + // the rebuild answers for settings, not for which repository or + // host is being acted on. + rebuilt.WorkDir, rebuilt.OverrideAt = out.WorkDir, out.OverrideAt + out = rebuilt + } + } + } + // Stamped whatever the record says, and before the early return below: what + // a fire revalidates is that the record it decided from is still the record, + // and a fleet that changed only settings this build ignores has still + // replaced the one it read. Set after the rebuild above, which returns a + // configuration parsed from scratch. + out.FleetAt = fd.UpdatedAt + if d, err := time.ParseDuration(strings.TrimSpace(fd.MinInterval)); err == nil && fd.MinInterval != "" { + out.MinInterval = d + } + if fd.WeeklyLimit != nil { + out.WeeklyReviewLimit = *fd.WeeklyLimit + } + if !fd.SetCoBots && !fd.SetRequired { + return out + } + // Reuse ForRepo's resolution rather than reimplementing it: "which bots run + // and which of them gate" is one algorithm, and the fleet default and a + // per-repo override are the same question asked at different scopes. + out = out.ForRepo(RepoReviewers{ + CoBots: fd.CoBots, SetCoBots: fd.SetCoBots, + Required: fd.Required, SetRequired: fd.SetRequired, + }) + // ForRepo stamps nothing, but this is a DEFAULT, not an override: a + // repository with no record of its own must still read as "following the + // fleet", and OverrideAt is what says otherwise. + out.OverrideAt = nil + return out } -// overrideChanged reports whether repo's reviewer override differs from the one -// cfg was built from. +// reviewersChanged reports whether the recorded configuration repo's reviewers +// are resolved from — the fleet defaults, then repo's own override — differs +// from the one cfg was built from. // // Deciding and writing are two steps. An operator removing a co-reviewer between // them would otherwise have crq claim and post that bot's trigger anyway, on the @@ -386,18 +429,27 @@ func (s *Service) warnOnce(msg string) { // from INSIDE the CAS mutation that commits the decision, where the state it // reads is the state the write lands on; a separate read beforehand would leave // the same window one step earlier. -func overrideChanged(st *State, repo string, cfg Config) bool { - if fleetRevision(*st) != cfg.FleetRevision { - return true - } +// +// BOTH layers, because both decide who reviews: the fleet record names the +// primary, the co-reviewers and the required set for every repository that has +// no override of its own, so asking only about the override let a fleet change +// that had already reported success be overtaken by the decision it replaced. +func reviewersChanged(st *State, repo string, cfg Config) bool { ov, _ := st.RepoOverride(repo) + return stampChanged(ov.UpdatedAt, cfg.OverrideAt) || + stampChanged(st.Fleet.UpdatedAt, cfg.FleetAt) +} + +// stampChanged compares a record's write time with the one a configuration was +// built from, treating absent and present as different answers. +func stampChanged(recorded, built *time.Time) bool { switch { - case ov.UpdatedAt == nil && cfg.OverrideAt == nil: + case recorded == nil && built == nil: return false - case ov.UpdatedAt == nil || cfg.OverrideAt == nil: + case recorded == nil || built == nil: return true default: - return !ov.UpdatedAt.Equal(*cfg.OverrideAt) + return !recorded.Equal(*built) } } @@ -414,3 +466,110 @@ func (c Config) knownCoBot(login string) (CoBotConfig, bool) { } return CoBotConfig{}, false } + +// withoutBot drops login from a reviewer list, comparing the way every other +// reviewer path does. +func withoutBot(list []string, login string) []string { + if login == "" { + return list + } + out := make([]string, 0, len(list)) + for _, l := range list { + if !sameBot(l, login) { + out = append(out, l) + } + } + return out +} + +// withSolver applies the resolved fix-session settings — the fleet default with +// this repository's own record already layered over it by EffectiveSolver. +// +// An absent field keeps the env value, so a fleet that records nothing runs +// exactly the sessions it ran before. +func (c Config) withSolver(sv SolverSettings) Config { + out := c + if sv.SetModels || len(sv.Models) > 0 || sv.Model != "" { + out.FixModels = sv.RankedModels() + out.FixModel = "" + if len(out.FixModels) > 0 { + out.FixModel = out.FixModels[0] + } + } + if sv.SetEffort || sv.Effort != "" { + out.FixEffort = sv.Effort + } + if sv.SetPrompt || sv.Prompt != "" { + out.FixPrompt = sv.Prompt + } + if sv.MaxAttempts != nil { + out.DispatchMaxAttempts = *sv.MaxAttempts + } + if sv.SetSeverities || len(sv.Severities) > 0 { + out.FixSeverities = severitySet(strings.Join(sv.Severities, ",")) + } + if sv.SetAskMode || sv.AskMode != "" { + out.FixAskMode = sv.AskMode + } + if sv.Forks != nil { + out.DispatchForks = *sv.Forks + } + if sv.SetSkipAuthors { + out.SkipAuthors = authorSet(strings.Join(sv.SkipAuthors, ",")) + } + return out +} + +// repoCfg is cfgFor for a caller that has no state in hand. It reads the ref, +// so it belongs on paths that act on ONE repository at a time — a dispatch, a +// fork check — never inside a loop over the fleet. +// +// A read failure falls back to this host's own configuration rather than +// failing the operation: the settings it would have applied are refinements, +// and refusing to fix a pull request because a setting could not be read would +// turn a cosmetic outage into a functional one. +// +// DispatchForks is the exception, because it is not a refinement. It is the +// line between running an agent over code the operator wrote and running it +// over a stranger's, and the record that turns it off is exactly what a failed +// read cannot see. Falling back to a permissive env value would re-enable fork +// dispatches precisely while the shared safety policy is unavailable, so the +// fallback denies them instead: the cost of being wrong is one contributor pull +// request left unfixed until the ref reads again. +// It takes the caller's context because the read behind it retries: a state ref +// that has become unreachable would otherwise keep a stopped watcher — or a +// cancelled session — waiting on a fallback it has already decided to take. +func (s *Service) repoCfg(ctx context.Context, repo string) Config { + st, _, err := s.store.Load(ctx) + if err != nil { + fallback := s.cfg + fallback.DispatchForks = false + return fallback + } + return s.cfgFor(st, repo) +} + +// fleetCfg is repoCfg for a caller acting on no particular repository: this +// host's environment with the fleet record applied and no override on top. +// +// A read failure falls back to this host's own configuration for the same +// reason repoCfg does — the fleet layer refines settings, and refusing to act +// because the ref could not be read turns a cosmetic outage into a functional +// one. DispatchForks does not arise here: nothing that asks for the fleet's own +// answer is deciding whose code to run an agent over. +func (s *Service) fleetCfg(ctx context.Context) Config { + st, _, err := s.store.Load(ctx) + if err != nil { + return s.cfg + } + return s.cfgFor(st, "") +} + +// ConfigIn is the configuration for one repository against an already-loaded +// state — cfgFor, exported for callers that render many repositories from one +// read. An empty repo answers for the fleet: env with the fleet record applied +// and no repository override, which is exactly what a repository that has never +// been ruled on gets. +func (s *Service) ConfigIn(st State, repo string) Config { + return s.cfgFor(st, repo) +} diff --git a/internal/crq/reviewers_test.go b/internal/crq/reviewers_test.go index 74b4173c..e33358f7 100644 --- a/internal/crq/reviewers_test.go +++ b/internal/crq/reviewers_test.go @@ -3,6 +3,7 @@ package crq import ( "path/filepath" "testing" + "time" "github.com/kristofferR/coderabbit-queue/internal/dialect" "github.com/kristofferR/coderabbit-queue/internal/engine" @@ -169,6 +170,25 @@ func TestDerivationLosesNothing(t *testing.T) { } }) + t.Run("a disabled registry primary does not return as a co-reviewer", func(t *testing.T) { + cfg := isolatedConfig(t, map[string]string{ + "CRQ_BOT": "chatgpt-codex-connector[bot]", + "CRQ_REQUIRED_BOTS": "chatgpt-codex-connector[bot],cursor[bot]", + }) + got := cfg.ForRepo(RepoReviewers{PrimaryOff: true}) + if _, ok := got.Primary(); ok { + t.Fatal("Primary() found a registry-backed primary after the repository disabled it") + } + for _, reviewer := range got.Reviewers { + if sameBot(reviewer.Login, cfg.Bot) { + t.Fatalf("reviewers = %+v, want the disabled primary absent rather than rebuilt as a co-reviewer", got.Reviewers) + } + } + if !hasLogin(got.RequiredBots, "cursor[bot]") { + t.Fatalf("required = %v, want the remaining co-reviewer to keep gating", got.RequiredBots) + } + }) + t.Run("a primary that is also a registry bot is asked once", func(t *testing.T) { // Appearing once in Reviewers is not enough: CoBots still drives the // co-reviewer trigger post, so an entry left there means DecideFire posts @@ -197,22 +217,20 @@ func TestDerivationLosesNothing(t *testing.T) { } }) - t.Run("an omitted primary contributes no findings", func(t *testing.T) { - // Leaving the primary out of CRQ_REQUIRED_BOTS is how an operator says - // "do not wait for CodeRabbit here". Deriving feedback from every reviewer - // put it back, so its findings would return code 10 and start a new round - // over a reviewer nobody asked for. + t.Run("an optional primary still contributes findings", func(t *testing.T) { + // Leaving the primary out of CRQ_REQUIRED_BOTS says not to wait for it. + // It does not make findings from a review that DID run disappear. cfg := isolatedConfig(t, map[string]string{ "CRQ_REQUIRED_BOTS": "chatgpt-codex-connector[bot]", }) + found := false for _, login := range cfg.FeedbackBots { if login == cfg.Bot { - t.Errorf("FeedbackBots = %v, want the omitted primary excluded", cfg.FeedbackBots) + found = true } } - // The co-reviewers it did ask for are still there. - if len(cfg.FeedbackBots) == 0 { - t.Error("FeedbackBots is empty; the required co-reviewer must still report") + if !found { + t.Errorf("FeedbackBots = %v, want the optional primary surfaced", cfg.FeedbackBots) } }) @@ -276,3 +294,27 @@ func TestForRepoOverridesEveryDerivedView(t *testing.T) { } }) } + +// Deciding and committing are two steps, and BOTH layers that name reviewers +// can move between them. The repository override was already revalidated inside +// the CAS; the fleet record was not, so an operator changing the fleet primary +// or its co-reviewers could have their save report success and still be +// overtaken by an in-flight decision posting the commands they had just +// replaced. +func TestAFleetReviewerChangeInvalidatesADecisionInFlight(t *testing.T) { + now := time.Date(2026, 7, 28, 9, 0, 0, 0, time.UTC) + st := &State{} + cfg := firingConfig().WithFleet(st.Fleet) + if reviewersChanged(st, "o/repo", cfg) { + t.Fatal("nothing has been recorded, so nothing has changed") + } + + st.SetFleetDefaults(FleetDefaults{CoBots: []string{"codex"}, SetCoBots: true}, "atlas", now) + if !reviewersChanged(st, "o/repo", cfg) { + t.Error("a fleet co-reviewer change must invalidate a decision made before it") + } + // And the configuration built from the new record commits against it. + if reviewersChanged(st, "o/repo", firingConfig().WithFleet(st.Fleet)) { + t.Error("the decision made from the current record must still commit") + } +} diff --git a/internal/crq/serveinstall.go b/internal/crq/serveinstall.go new file mode 100644 index 00000000..81865bd5 --- /dev/null +++ b/internal/crq/serveinstall.go @@ -0,0 +1,384 @@ +package crq + +import ( + "context" + "fmt" + "html" + "os" + "os/exec" + osuser "os/user" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + "unicode/utf8" +) + +// ServeInstall is the plan for keeping a crq service running across a logout +// and a reboot. It covers both the dashboard and the review daemon: they are +// the same shape of thing — one long-running crq subcommand — and the only +// differences are the unit name and the arguments. +// +// Deliberately much thinner than the autofix install beside it. The dashboard +// reads the same config file every other command reads, so editing that file +// changes it after a restart. Only CRQ_* overrides from the installing shell +// are also carried: otherwise those values disappear under a service manager. +type ServeInstall struct { + // Service is the crq subcommand this unit runs: "serve" or "autoreview". + Service string `json:"service"` + Platform string `json:"platform"` + Unit string `json:"unit"` + LogDir string `json:"log_dir"` + Binary string `json:"binary"` + Addr string `json:"addr"` + Poll string `json:"poll,omitempty"` + // AllowHosts are the extra names the dashboard accepts actions on. Part of + // the unit rather than of the config file because it belongs to the address + // this instance is served at, which is also where --addr lives. + AllowHosts []string `json:"allow_hosts,omitempty"` + Config string `json:"config,omitempty"` + // ReadOnly installs a dashboard that refuses every write, for pointing at a + // fleet you do not administer. + ReadOnly bool `json:"read_only,omitempty"` + // SkipAuthCheck installs without proving the service can authenticate. Same + // escape hatch as the autofix install: a macOS host reached over SSH cannot + // read the GUI session's keychain, so an expired token and a perfectly good + // one look identical from there. + SkipAuthCheck bool `json:"skip_auth_check,omitempty"` + Commands []string `json:"commands"` + DryRun bool `json:"dry_run,omitempty"` + Started bool `json:"started,omitempty"` + home string + account string + environment map[string]string +} + +// InstallServe writes the service definition for `crq serve` and starts it. +func (s *Service) InstallServe(ctx context.Context, addr string, allowHosts []string, readOnly bool, poll time.Duration, dryRun, skipAuth bool) (ServeInstall, error) { + return s.installUnit(ctx, "serve", addr, allowHosts, readOnly, poll, dryRun || s.cfg.DryRun, skipAuth) +} + +// InstallAutoReview writes the service definition for `crq autoreview` and +// starts it — the daemon that finds pull requests needing a review and fires +// the queue. +// +// Which host runs it is a real choice: it takes the leader lease, so the fleet +// only fires while that machine is awake. A laptop that sleeps is the wrong +// host for it, and nothing about the queue says so until reviews quietly stop. +func (s *Service) InstallAutoReview(ctx context.Context, dryRun, skipAuth bool) (ServeInstall, error) { + return s.installUnit(ctx, "autoreview", "", nil, false, 0, dryRun || s.cfg.DryRun, skipAuth) +} + +func (s *Service) installUnit(ctx context.Context, service, addr string, allowHosts []string, readOnly bool, poll time.Duration, dryRun, skipAuth bool) (ServeInstall, error) { + home, err := os.UserHomeDir() + if err != nil { + return ServeInstall{}, fmt.Errorf("resolving home directory: %w", err) + } + self, err := os.Executable() + if err != nil { + return ServeInstall{}, fmt.Errorf("resolving the crq binary: %w", err) + } + if resolved, err := filepath.EvalSymlinks(self); err == nil { + self = resolved + } + if service == "serve" && strings.TrimSpace(addr) == "" { + addr = "127.0.0.1:7777" + } + if service == "serve" && poll <= 0 { + poll = 5 * time.Second + } + pollText := "" + if service == "serve" { + pollText = poll.String() + } + + // systemd refuses to start a unit whose StandardOutput path cannot be + // opened, so the directory has to exist before the unit does. + logDir := filepath.Join(home, ".local", "state", "crq") + plan := ServeInstall{ + Service: service, + Platform: runtime.GOOS, + LogDir: logDir, + Binary: self, + Addr: addr, + Poll: pollText, + AllowHosts: allowHosts, + Config: ConfigPath(), + ReadOnly: readOnly, + SkipAuthCheck: skipAuth, + DryRun: dryRun, + home: home, + environment: serveEnvironment(), + } + if current, uerr := osuser.Current(); uerr == nil { + plan.account = current.Username + } + switch runtime.GOOS { + case "darwin": + plan.Unit = filepath.Join(home, "Library", "LaunchAgents", "no.kristofferr.crq-"+service+".plist") + default: + plan.Unit = filepath.Join(home, ".config", "systemd", "user", "crq-"+service+".service") + } + commands := serveCommands(plan) + for _, command := range commands { + plan.Commands = append(plan.Commands, command.display) + } + if dryRun { + return plan, nil + } + + // Both units read GitHub on every pass and neither inherits this shell's + // variables, so the same check the autofix install makes belongs here. + // Without it `systemctl restart` succeeds, the install prints Started, and + // the process then fails its state reads for ever — no reviews, no + // dashboard, and nothing that says why. + if !skipAuth { + if err := serviceCanAuthenticate(ctx, service); err != nil { + return plan, err + } + } + + for _, dir := range []string{logDir, filepath.Dir(plan.Unit)} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return plan, err + } + } + if err := writeAutofixFile(plan.Unit, serveUnitBody(plan), 0o600); err != nil { + return plan, err + } + + var failed []string + for _, command := range commands { + line, args := command.display, command.argv + if len(args) == 0 { + return plan, fmt.Errorf("serve start command %q has no executable", line) + } + output, err := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput() + if err != nil && launchdJobAbsent(line, output) { + continue + } + if err != nil { + detail := err.Error() + if text := strings.TrimSpace(string(output)); text != "" { + detail += ": " + text + } + failed = append(failed, fmt.Sprintf("%s: %s", line, detail)) + } + } + if len(failed) > 0 { + // The unit file is the durable part and is already written, so say what + // to run rather than pretending the dashboard is up. + return plan, fmt.Errorf("%s is written, but starting it failed — run these by hand: %s", + plan.Unit, strings.Join(failed, "; ")) + } + plan.Started = true + return plan, nil +} + +type serveCommand struct { + display string + argv []string +} + +func serveCommands(plan ServeInstall) []serveCommand { + if plan.Platform == "darwin" { + domain := "gui/" + currentUID() + return []serveCommand{ + { + display: "launchctl bootout " + shellQuote(domain+"/no.kristofferr.crq-"+plan.Service), + argv: []string{"launchctl", "bootout", domain + "/no.kristofferr.crq-" + plan.Service}, + }, + { + display: "launchctl bootstrap " + shellQuote(domain) + " " + shellQuote(plan.Unit), + argv: []string{"launchctl", "bootstrap", domain, plan.Unit}, + }, + } + } + out := []serveCommand{} + if plan.account != "" { + out = append(out, serveCommand{ + display: "loginctl enable-linger " + shellQuote(plan.account), + argv: []string{"loginctl", "enable-linger", plan.account}, + }) + } + return append(out, + serveCommand{display: "systemctl --user daemon-reload", argv: []string{"systemctl", "--user", "daemon-reload"}}, + serveCommand{ + display: "systemctl --user enable crq-" + plan.Service, + argv: []string{"systemctl", "--user", "enable", "crq-" + plan.Service}, + }, + // restart rather than "enable --now", which does nothing to a unit that + // is already running after an address change. + serveCommand{ + display: "systemctl --user restart crq-" + plan.Service, + argv: []string{"systemctl", "--user", "restart", "crq-" + plan.Service}, + }, + ) +} + +// serveArgv is the command the service runs. +func serveArgv(plan ServeInstall) []string { + if plan.Service != "serve" { + return []string{plan.Binary, plan.Service} + } + argv := []string{plan.Binary, "serve", "--addr", plan.Addr} + if plan.Poll != "" { + argv = append(argv, "--poll", plan.Poll) + } + if len(plan.AllowHosts) > 0 { + argv = append(argv, "--allow-host", strings.Join(plan.AllowHosts, ",")) + } + if plan.ReadOnly { + argv = append(argv, "--read-only") + } + return argv +} + +// unitDescription is what the service manager and `systemctl status` show. +func unitDescription(service string) string { + if service == "autoreview" { + return "crq autoreview (find pull requests needing a review and fire the queue)" + } + return "crq dashboard (crq serve)" +} + +func serveUnitBody(plan ServeInstall) string { + home := plan.home + if home == "" { + home, _ = os.UserHomeDir() + } + env := make(map[string]string, len(plan.environment)+3) + for key, value := range plan.environment { + env[key] = value + } + env["HOME"] = home + if plan.Config != "" { + env["CRQ_CONFIG"] = plan.Config + } + // A service manager hands a unit its own minimal PATH, and the dashboard + // shells out to git for the state ref and to gh's credential helper. + if path := os.Getenv("PATH"); path != "" { + env["PATH"] = path + } + keys := make([]string, 0, len(env)) + for k := range env { + keys = append(keys, k) + } + sort.Strings(keys) + + if plan.Platform == "darwin" { + var entries strings.Builder + for _, k := range keys { + fmt.Fprintf(&entries, "\t\t%s%s\n", + html.EscapeString(k), html.EscapeString(env[k])) + } + var argv strings.Builder + for _, a := range serveArgv(plan) { + fmt.Fprintf(&argv, "%s", html.EscapeString(a)) + } + return fmt.Sprintf(` + + + Labelno.kristofferr.crq-%s + ProgramArguments%s + EnvironmentVariables +%s + RunAtLoad + KeepAlive + StandardOutPath%s/%s.log + StandardErrorPath%s/%s.err + +`, html.EscapeString(plan.Service), argv.String(), entries.String(), + html.EscapeString(plan.LogDir), html.EscapeString(plan.Service), + html.EscapeString(plan.LogDir), html.EscapeString(plan.Service)) + } + + var lines strings.Builder + for _, k := range keys { + fmt.Fprintf(&lines, "Environment=%s\n", systemdEnvironment(strings.ReplaceAll(k+"="+env[k], "%", "%%"))) + } + words := make([]string, 0, 4) + for _, a := range serveArgv(plan) { + words = append(words, systemdExecWord(a)) + } + return fmt.Sprintf(`[Unit] +Description=%s +After=network-online.target + +[Service] +Type=simple +%sExecStart=%s +Restart=always +RestartSec=5 +StandardOutput=append:%s/%s.log +StandardError=append:%s/%s.err + +[Install] +WantedBy=default.target +`, unitDescription(plan.Service), lines.String(), strings.Join(words, " "), + plan.LogDir, plan.Service, plan.LogDir, plan.Service) +} + +// systemdEnvironment quotes one Environment= assignment using systemd.syntax +// escapes while leaving printable UTF-8 intact. strconv.Quote can emit Go-only +// escapes for arbitrary shell values that older systemd versions reject. +func systemdEnvironment(value string) string { + var quoted strings.Builder + quoted.WriteByte('"') + for len(value) > 0 { + r, size := utf8.DecodeRuneInString(value) + if r == utf8.RuneError && size == 1 { + fmt.Fprintf("ed, `\x%02x`, value[0]) + value = value[1:] + continue + } + value = value[size:] + switch r { + case '\a': + quoted.WriteString(`\a`) + case '\b': + quoted.WriteString(`\b`) + case '\f': + quoted.WriteString(`\f`) + case '\n': + quoted.WriteString(`\n`) + case '\r': + quoted.WriteString(`\r`) + case '\t': + quoted.WriteString(`\t`) + case '\v': + quoted.WriteString(`\v`) + case '\\', '"', '\'': + quoted.WriteByte('\\') + quoted.WriteRune(r) + default: + quoted.WriteRune(r) + } + } + quoted.WriteByte('"') + return quoted.String() +} + +// serveEnvironment carries configuration that may have reached the install +// through its invoking shell. A service manager does not inherit that shell, +// so pointing at the same config file alone is insufficient. +func serveEnvironment() map[string]string { + env := map[string]string{} + for _, entry := range os.Environ() { + key, value, ok := strings.Cut(entry, "=") + if !ok { + continue + } + if !strings.HasPrefix(key, "CRQ_") { + continue + } + switch key { + case "CRQ_CONFIG", "CRQ_DRY_RUN", "CRQ_NO_OPEN", + "CRQ_DISPATCH_REPO", "CRQ_DISPATCH_PR", "CRQ_DISPATCH_HEAD", "CRQ_DISPATCH_FINDINGS": + continue + } + env[key] = value + } + return env +} diff --git a/internal/crq/serveinstall_test.go b/internal/crq/serveinstall_test.go new file mode 100644 index 00000000..0d2a9944 --- /dev/null +++ b/internal/crq/serveinstall_test.go @@ -0,0 +1,95 @@ +package crq + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestServeUnitCarriesShellProvidedConfiguration(t *testing.T) { + env := map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_STATE_REF": "custom-state", + "CRQ_SCOPE": "owner,second", "CRQ_HOST": "testhost", "CRQ_COBOTS": "", + "CRQ_DRY_RUN": "1", "CRQ_NO_OPEN": "1", + "CRQ_DISPATCH_REPO": "owner/pr", "CRQ_DISPATCH_PR": "7", + "CRQ_DISPATCH_HEAD": "abcdef", "CRQ_DISPATCH_FINDINGS": "/tmp/findings.json", + } + for key, value := range env { + t.Setenv(key, value) + } + cfg, err := BuildConfig(env) + if err != nil { + t.Fatal(err) + } + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + plan, err := svc.InstallAutoReview(context.Background(), true, true) + if err != nil { + t.Fatal(err) + } + unit := serveUnitBody(plan) + for _, want := range []string{ + "CRQ_REPO=owner/gate", + "CRQ_STATE_REF=custom-state", + "CRQ_SCOPE=owner,second", + "CRQ_HOST=testhost", + } { + if !strings.Contains(unit, want) { + t.Errorf("unit does not carry %q:\n%s", want, unit) + } + } + if strings.Contains(unit, "GITHUB_TOKEN") || strings.Contains(unit, "GH_TOKEN") { + t.Errorf("unit carries a GitHub credential:\n%s", unit) + } + for _, excluded := range []string{ + "CRQ_DRY_RUN", "CRQ_NO_OPEN", "CRQ_DISPATCH_REPO", "CRQ_DISPATCH_PR", + "CRQ_DISPATCH_HEAD", "CRQ_DISPATCH_FINDINGS", + } { + if strings.Contains(unit, excluded) { + t.Errorf("unit carries excluded %s:\n%s", excluded, unit) + } + } +} + +func TestServeInstallPreservesPollInterval(t *testing.T) { + plan := ServeInstall{ + Service: "serve", Binary: "/usr/bin/crq", Addr: "127.0.0.1:7777", + Poll: (30 * time.Second).String(), + } + got := strings.Join(serveArgv(plan), " ") + if !strings.Contains(got, "--poll 30s") { + t.Fatalf("serve argv = %q, want the configured poll interval", got) + } +} + +func TestServiceInstallersHonourConfiguredDryRun(t *testing.T) { + cfg := firingConfig() + cfg.DryRun = true + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + + serve, err := svc.InstallServe(context.Background(), "", nil, false, 0, false, true) + if err != nil { + t.Fatal(err) + } + autoreview, err := svc.InstallAutoReview(context.Background(), false, true) + if err != nil { + t.Fatal(err) + } + for _, plan := range []ServeInstall{serve, autoreview} { + if !plan.DryRun || plan.Started { + t.Fatalf("configured dry-run %s installation applied its plan: %+v", plan.Service, plan) + } + } +} + +func TestSystemdEnvironmentPreservesUnicodeAndUsesSupportedEscapes(t *testing.T) { + value := "CRQ_FIX_PROMPT=blå\u0085\t\"quoted\"" + got := systemdEnvironment(value) + want := "\"CRQ_FIX_PROMPT=blå\u0085\\t\\\"quoted\\\"\"" + if got != want { + t.Fatalf("systemd environment = %q, want %q", got, want) + } + if got := systemdEnvironment("CRQ_FIX_PROMPT=" + string([]byte{0xff})); got != `"CRQ_FIX_PROMPT=\xff"` { + t.Fatalf("invalid UTF-8 environment = %q, want a lossless hex escape", got) + } +} diff --git a/internal/crq/service.go b/internal/crq/service.go index 38760d2f..6423d0e4 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -10,7 +10,6 @@ import ( "net/url" "sort" "strings" - "sync" "time" "github.com/kristofferR/coderabbit-queue/internal/dialect" @@ -38,6 +37,9 @@ type GitHubAPI interface { CreateIssue(context.Context, string, string, string) (ghapi.Issue, error) SearchOpenPRs(context.Context, string, bool, int) ([]ghapi.SearchPR, error) EachOpenPR(context.Context, string, bool, func(ghapi.SearchPR) (bool, error)) error + // ListOwnerRepos backs the dashboard's repository picker: it is the only + // call that asks about repositories crq does not already follow. + ListOwnerRepos(context.Context, string, int) ([]ghapi.Repo, error) GraphQL(context.Context, string, map[string]any, any) error // ListPulls finds pull requests, filtered by the query. crq uses it to map a // checkout's branch to the PR it belongs to. @@ -64,11 +66,6 @@ type Service struct { // scanOffset rotates the bounded quota-free rescue scan's window so a round // past the first few is not starved forever; in-memory only, same writer. scanOffset int - // fleetWarned guards fleetWarnedOf, which remembers the fleet-configuration - // complaints already logged. Passes read the fleet policy concurrently with - // dispatched sessions, so this one is genuinely shared. - fleetWarned sync.Mutex - fleetWarnedOf map[string]bool // now overrides the wall clock for the scheduling DECISIONS in the // pump/enqueue/sweep/wait paths (see clock). nil in production; the replay // suite injects a controllable fake so an incident can be re-enacted @@ -112,6 +109,13 @@ const warnRateLimited = dialect.ReasonRateLimited const leaderCapabilityHolds = "administrative-holds" +var errDispatchClaimLost = errors.New("dispatch claim is no longer owned by this watcher") + +type dispatchOwnership struct { + head string + token string +} + // Hold takes a PR out of the review queue in one write. // // Holding used to need two commands that could not be one: the skip marker @@ -122,6 +126,30 @@ const leaderCapabilityHolds = "administrative-holds" // It does not cancel a round already in flight: that review is bought and its // findings are still worth having. It stops the next one. func (s *Service) Hold(ctx context.Context, repo string, pr int, reason string) (HoldResult, error) { + return s.hold(ctx, repo, pr, reason, nil) +} + +// holdDispatch records an agent's clarification only while that agent still +// owns the dispatch claim. The ownership check and hold write share one CAS +// update, so a watcher that lost its claim cannot stop the PR. +func (s *Service) holdDispatch( + ctx context.Context, + report NextReport, + token string, + reason string, +) (HoldResult, error) { + return s.hold(ctx, report.Repo, report.PR, reason, &dispatchOwnership{ + head: report.Head, token: token, + }) +} + +func (s *Service) hold( + ctx context.Context, + repo string, + pr int, + reason string, + owner *dispatchOwnership, +) (HoldResult, error) { repo = NormalizeRepo(repo) reason = strings.TrimSpace(reason) if reason == "" { @@ -133,6 +161,14 @@ func (s *Service) Hold(ctx context.Context, repo string, pr int, reason string) return result, nil } state, err := s.store.Update(ctx, func(st *State) error { + if owner != nil { + round := st.Round(repo, pr) + if round == nil || round.Head != owner.head || + round.Dispatch == nil || round.Dispatch.Token != owner.token || + !round.DispatchHeld(now) { + return errDispatchClaimLost + } + } // An older daemon preserves Holds as unknown JSON but cannot enforce it. // Require a capable live leader: without one, an older standby could // acquire the expired/empty lease immediately after this write. @@ -182,6 +218,30 @@ func (s *Service) Unhold(ctx context.Context, repo string, pr int) (HoldResult, return result, nil } +// Prioritize moves a tracked PR ahead of every other round. The queue sequence +// is shared by autoreview and autofix, so one action accelerates whichever kind +// of work the PR needs next. +func (s *Service) Prioritize(ctx context.Context, repo string, pr int) error { + repo = NormalizeRepo(repo) + if s.cfg.DryRun { + return fmt.Errorf("dry run: would move %s#%d to the top of the queue", repo, pr) + } + updated, err := s.store.Update(ctx, func(st *State) error { + if !st.MoveToFront(repo, pr) { + return fmt.Errorf("%s#%d is not currently queued", repo, pr) + } + return nil + }) + if err != nil { + return err + } + s.sync(ctx, updated) + if s.log != nil { + s.log.Printf("%s#%d moved to the top of the queue", repo, pr) + } + return nil +} + // triggerPostClaimed reports the two claim states after which Hold cannot // promise that no new review command will be posted. The hold and each claim // are CAS writes, so checking them in the hold write makes either ordering @@ -238,11 +298,6 @@ func (s *Service) Enqueue(ctx context.Context, repo string, pr int) (EnqueueResu result.Head = head state, err := s.store.Update(ctx, func(st *State) error { now := s.clock() - if s.fleetCfg(*st).ExcludeRepos[repo] { - result.Held = true - result.Reason = "repository excluded by fleet policy" - return ErrNoChange - } if h, held := st.HeldPR(repo, pr); held { // Enqueueing a held PR would queue a round nothing may fire, which // reads on the dashboard as work waiting its turn. @@ -250,6 +305,21 @@ func (s *Service) Enqueue(ctx context.Context, repo string, pr int) (EnqueueResu result.Reason = "held: " + h.Reason return ErrNoChange } + // An EXPLICIT off switch, asked here for the same reason enqueueBatch + // asks it: turning a repository off abandons its pending rounds, and a + // `crq next` or `crq loop` run against it afterwards recreated one that + // Pump — which asks nothing about enrollment — went on to spend a + // metered review on. Only a record, never a mere absence from this + // host's CRQ_REPOS: a manual run on a repository the fleet does not + // scan is the ordinary way this command is used. + if rec, ok := st.Enrollment(repo); ok && !rec.Enabled { + result.Held = true + result.Reason = "the repository is turned off" + if rec.Reason != "" { + result.Reason += ": " + rec.Reason + } + return ErrNoChange + } r := st.Round(repo, pr) if r != nil && r.Head == head { // A PR reopened after its reviewers changed: the completed round is a @@ -296,47 +366,72 @@ type queueCandidate struct { Repo string PR int Head string + // Title travels with the candidate because the scan already has it: the + // search result carries it, so recording it costs nothing and spares every + // later list a request per row. + Title string } // enqueueBatch appends several PRs in a single compare-and-swap write plus one // dashboard sync, so a large autoreview pass doesn't produce N separate state // writes / issue edits. A PR already tracked at the same head is skipped; a // stale head is superseded. The DecideFire dedup still backstops at pump time. -func (s *Service) enqueueBatch(ctx context.Context, items []queueCandidate, expectedFleetRevision string) error { +func (s *Service) enqueueBatch(ctx context.Context, items []queueCandidate) error { if len(items) == 0 || s.cfg.DryRun { return nil } state, err := s.store.Update(ctx, func(st *State) error { now := s.clock() - cfg := s.fleetCfg(*st) - if cfg.FleetRevision != expectedFleetRevision { - return ErrNoChange - } added := 0 for _, it := range items { repo := NormalizeRepo(it.Repo) - if cfg.ExcludeRepos[repo] { + if _, held := st.HeldPR(repo, it.PR); held { continue } - if _, held := st.HeldPR(repo, it.PR); held { + // Asked again here, against the state this write lands on, for the + // same reason reviewersChanged is: the scan decided from a snapshot, + // and a repository turned off since then has already had its pending + // rounds abandoned. Creating one now would put a metered review back + // in the queue after the off switch reported success. + if !s.reviewsRepo(*st, repo) { continue } if r := st.Round(repo, it.PR); r != nil { + // A title arriving for a round that already exists is still + // news: it may have been renamed, or recorded before titles + // were kept at all. + if it.Title != "" && r.Title != it.Title { + updated := *r + updated.Title = it.Title + st.PutRound(updated) + r = st.Round(repo, it.PR) + added++ + } if r.Head == it.Head { if requeueIfReviewersChanged(st, r) { added++ } continue } - if _, err := st.Supersede(repo, it.PR, it.Head, now); err != nil { + superseded, err := st.Supersede(repo, it.PR, it.Head, now) + if err != nil { return err } + if it.Title != "" { + superseded.Title = it.Title + st.PutRound(*superseded) + } added++ continue } - if _, err := st.NewRound(repo, it.PR, it.Head, now); err != nil { + fresh, err := st.NewRound(repo, it.PR, it.Head, now) + if err != nil { return err } + if it.Title != "" { + fresh.Title = it.Title + st.PutRound(*fresh) + } added++ } if added == 0 { @@ -391,7 +486,7 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { } else if handled { // The quota-free result is the one Pump exposes to its caller, so // preserve the cleanup hook for the slot result it replaces. - if err := s.tidyAfterPump(ctx, res); err != nil { + if err := s.tidyAfterPump(ctx, st, res); err != nil { return res, err } return free, nil @@ -453,11 +548,18 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { if err != nil { return PumpResult{}, err } + // Record which co-reviewers have answered, from the observation this pass + // already paid for. Done HERE and not only on the reviewing sweep: a round + // that never fires — because the account is blocked, or because the primary + // does not run here — is observed on this path and nowhere else, so a + // co-reviewer could review it every time and crq would never notice. That + // is exactly the case that made a working Codex read as "never answered". + s.noteCoAnswers(ctx, cfg, *next, obs.eng, now) // Record a rate-limit notice before deciding, whichever round it answered. // A session's push supersedes the round that asked, and the reply used to be // archived unread — so crq believed the account was free and posted the // command again minutes after being told to wait. - if updated, err := s.recordObservedBlock(ctx, obs, st, now); err != nil { + if updated, err := s.recordObservedBlock(ctx, cfg, obs, st, now); err != nil { return PumpResult{}, err } else if updated != nil { st = *updated @@ -590,36 +692,16 @@ func (s *Service) advanceQuotaFree(ctx context.Context, repo string, pr int) (Pu // allowance is not a property of a round, so any current notice from the primary // counts here. AcceptAccountBlock still decides whether it replaces the standing // window, and never shortens it. -func (s *Service) recordObservedBlock(ctx context.Context, obs observation, st State, now time.Time) (*State, error) { - cfg := s.fleetCfg(st) +func (s *Service) recordObservedBlock(ctx context.Context, cfg Config, obs observation, st State, now time.Time) (*State, error) { blk := engine.ObservedAccountBlock(obs.eng, cfg.policy(), st.Account, now) if blk == nil || s.cfg.DryRun || !observedAccountBlockChanges(st.Account, blk) { return nil, nil } - // Update swallows ErrNoChange, so whether the block was recorded has to be - // carried out of the mutation rather than read from its error. - var recorded *engine.AccountBlock updated, err := s.store.Update(ctx, func(w *State) error { - recorded = nil - current := s.fleetCfg(*w) - // Only the SCOPE decides whose account this notice is evidence about. - // Any other fleet setting moving between the observation and this write - // leaves the notice just as valid, and comparing the whole revision - // discarded it — silently, since a returned ErrNoChange reads as a - // successful update. The account then stayed open for the rest of a - // window the bot had already stated, and the next fire went out inside - // it. So the window is recomputed here instead, under the policy that is - // current at the moment of the write, against the quota this write lands - // on. - if !sameFoldedSet(current.Scope, cfg.Scope) { - return ErrNoChange - } - blk := engine.ObservedAccountBlock(obs.eng, current.policy(), w.Account, now) - if blk == nil || !observedAccountBlockChanges(w.Account, blk) { + if !observedAccountBlockChanges(w.Account, blk) { return ErrNoChange } applyAccountBlock(w, blk, now) - recorded = blk return nil }) if err != nil { @@ -628,19 +710,14 @@ func (s *Service) recordObservedBlock(ctx context.Context, obs observation, st S } return nil, err } - // The state is handed back either way — it is newer than the caller's, and a - // window another host recorded in the meantime is one this decision has to - // respect. Only the report of a WRITE is conditional. - if recorded != nil { - // Like every other writer of this window: the dashboard is where an operator - // reads whether the account is available, and the decision that follows this - // is usually FireNo — which writes nothing further, leaving the issue claiming - // a free account for the whole block while state and `crq status` say - // otherwise. - s.sync(ctx, updated) - if s.log != nil { - s.log.Printf("account blocked until %s (observed, not tied to a round)", recorded.Until.UTC().Format(time.RFC3339)) - } + // Like every other writer of this window: the dashboard is where an operator + // reads whether the account is available, and the decision that follows this + // is usually FireNo — which writes nothing further, leaving the issue claiming + // a free account for the whole block while state and `crq status` say + // otherwise. + s.sync(ctx, updated) + if s.log != nil { + s.log.Printf("account blocked until %s (observed, not tied to a round)", blk.Until.UTC().Format(time.RFC3339)) } return &updated, nil } @@ -760,32 +837,24 @@ func (s *Service) recordDismissal(ctx context.Context, repo string, pr int, head // block counts is engine.AcceptAccountBlock's, and executing it belongs here with // the rest of the CAS writes rather than beside each evidence source. // -// It returns whether anything was written, and the block that stands either way. +// It returns whether anything was written, the block that stands either way, +// and whether the CLI organisation still matches the fleet snapshot. func (s *Service) applyAccountBlock( - ctx context.Context, - until time.Time, - source string, - expected Config, - cliOrg string, -) (bool, *time.Time, error) { + ctx context.Context, until time.Time, source string, cfg Config, cliOrg string, +) (bool, *time.Time, bool, error) { now := s.clock() // Update swallows ErrNoChange, so whether anything was written has to be // recorded by the mutation itself. It is assigned on every attempt because a // CAS conflict runs the closure again. applied := false + matched := true state, err := s.store.Update(ctx, func(st *State) error { - current := s.fleetCfg(*st) - // Only the SCOPE decides which account this block is evidence about, and - // cliOrgMatches asks the sharper form of that question against the policy - // the write lands on. Comparing the whole revision instead refused an - // explicit, organisation-attributed block because an unrelated setting — - // settle, repos, a co-reviewer's command — moved between the read and this - // write: the caller was told to run preflight again while the shared quota - // stayed open, and the daemon could post a metered review inside the window - // the CLI had just reported. - if !sameFoldedSet(current.Scope, expected.Scope) || !cliOrgMatches(current, cliOrg) { - return errFleetQuotaChanged + current := s.cfg.WithFleet(st.Fleet) + if stampChanged(st.Fleet.UpdatedAt, cfg.FleetAt) || !cliOrgMatches(current, cliOrg) { + applied, matched = false, false + return ErrNoChange } + matched = true if !engine.AcceptAccountBlock(st.Account.BlockedUntil, until) { applied = false return ErrNoChange @@ -809,12 +878,12 @@ func (s *Service) applyAccountBlock( return nil }) if err != nil { - return false, nil, err + return false, nil, false, err } if applied { s.sync(ctx, state) } - return applied, state.Account.BlockedUntil, nil + return applied, state.Account.BlockedUntil, matched, nil } // quotaFreeVerdict reports whether a fire verdict can be applied while another @@ -849,6 +918,11 @@ func (s *Service) progressSlotRound(ctx context.Context, slot Round) (PumpResult return PumpResult{}, err } s.selfHealCoReviewers(ctx, cfg, slot, obs.eng, now) + // Here as well as in the reviewing sweep: this observation can take the round + // straight from fired to completed, and a completed round is never looked at + // again. The co-reviewer that answered it would be lost with it, leaving a + // working bot shown as silent for want of the one field that says otherwise. + s.noteCoAnswers(ctx, cfg, slot, obs.eng, now) tr := engine.Progress(slot, st.Account, obs.eng, now, cfg.policy()) if tr.Outcome == engine.KeepWaiting { return PumpResult{Action: "waiting", Repo: slot.Repo, PR: slot.PR, Reason: tr.Reason}, nil @@ -885,29 +959,43 @@ func (s *Service) progressSlotRound(ctx context.Context, slot Round) (PumpResult // where the state it reads is the state the write lands on. func (s *Service) applyTransition(st *State, r *Round, tr engine.Transition, now time.Time, cfg Config) error { key := QueueKey(r.Repo, r.PR) - // Captured before the transitions below clear it: this round's claim on the - // fire slot is what lets it release the slot at the end. - token := r.Token - // Completion and retry are the policy-dependent outcomes, so both are dropped - // when the policy moved under them. The completed round is the "this head was - // reviewed" dedup marker, and reopenForChangedReviewers deliberately leaves an - // in-flight round alone — it is already going to answer — so a reviewer change - // committing between the decision and this write would be answered by neither: - // the marker dedupes the head under the set that no longer gates it. A retry - // carries a RetryAt and an account block computed from the fleet's fallback - // and backoff windows, so a widened window would be persisted at the old, - // earlier deadline and let another metered review fire inside it. Dropping is - // safe either way: the round keeps the slot and the next pump decides again - // under the current policy. The other outcomes record facts — an ack, a closed - // PR, a reservation that never posted — that no policy change revises. - switch tr.Outcome { - case engine.OutComplete, engine.OutRetry: - if overrideChanged(st, r.Repo, cfg) { + ownerToken := r.Token + if tr.Outcome == engine.OutRetry { + current := s.cfgFor(*st, r.Repo) + fallbackChanged := current.RateLimitFallback != cfg.RateLimitFallback + policyChanged := reviewersChanged(st, r.Repo, cfg) || + !sameBot(current.Bot, cfg.Bot) || + current.PrimaryOff != cfg.PrimaryOff || + !sameReviewers(current, cfg) || + current.InflightTimeout != cfg.InflightTimeout || + fallbackChanged + if policyChanged { + // The account block is independent evidence unless its window was + // derived from the fallback that just changed. Preserve a parsed block + // while dropping the stale round transition; a fallback-derived one is + // recomputed from the observation on the next pump. + if tr.Blocked != nil && !fallbackChanged && + observedAccountBlockChanges(st.Account, tr.Blocked) { + applyAccountBlock(st, tr.Blocked, now) + return nil + } return ErrNoChange } + } else if tr.Outcome == engine.OutReleaseSlot && + reviewersChanged(st, r.Repo, cfg) { + return ErrNoChange } switch tr.Outcome { case engine.OutComplete: + // The completed round is the "this head was reviewed" dedup marker, and + // reopenForChangedReviewers deliberately leaves an in-flight round alone — + // it is already going to answer. A reviewer change that commits between + // the decision and this write would therefore be answered by neither: the + // marker dedupes the head under the set that no longer gates it. Drop the + // stale transition; the next pump decides again under the new set. + if reviewersChanged(st, r.Repo, cfg) { + return ErrNoChange + } if err := r.Complete(); err != nil { return err } @@ -919,6 +1007,16 @@ func (s *Service) applyTransition(st *State, r *Round, tr engine.Transition, now if tr.Blocked != nil { applyAccountBlock(st, tr.Blocked, now) } + // Turning a repository off leaves an already-purchased response in + // flight, because that answer is still worth collecting. If the answer + // instead says to retry, the next command would be a new purchase after + // the switch was thrown. Keep any account-wide block learned above, but + // end this repository's round before it becomes fire-eligible again. + if !s.reviewsRepo(*st, r.Repo) { + st.EndRound(r.Repo, r.PR, "repository turned off before retry") + releaseSlot(st, key, ownerToken) + return nil + } if err := r.AwaitRetry(tr.RetryAt, tr.Reason, now); err != nil { return err } @@ -928,27 +1026,19 @@ func (s *Service) applyTransition(st *State, r *Round, tr engine.Transition, now } case engine.OutAbandon: st.EndRound(r.Repo, r.PR, tr.Reason) - releaseSlot(st, key, token) + releaseSlot(st, key, ownerToken) return nil default: return nil } st.PutRound(*r) - releaseSlot(st, key, token) + releaseSlot(st, key, ownerToken) return nil } -// releaseSlot clears the fire slot when it points at key AND token — the round -// that took it is the only one that may give it back. -// -// The key alone is not enough because the slot outlives the round: a hold left -// behind for a metered command whose round was superseded belongs to that -// command, not to the pull request it was posted on. Its replacement round at -// the same key ending — excluded by fleet policy, cancelled, or abandoned with -// the PR — would otherwise drop the hold and let a second metered review fire -// while the first command is still unanswered. Its token is the archived one's, -// so it releases nothing here. Nobody needs to: the hold is bounded by the -// in-flight window, and Normalize reaps the slot once it expires. +// releaseSlot clears only the exact fire-slot claim a round acquired. A +// superseding round reuses the queue key with a new token while the original +// command's HoldUntil deliberately survives it. func releaseSlot(st *State, key, token string) { if st.FireSlot != nil && st.FireSlot.Key == key && st.FireSlot.Token == token { st.FireSlot = nil @@ -965,6 +1055,19 @@ func sameRound(r *Round, want Round) bool { return r != nil && r.Seq == want.Seq && r.Head == want.Head } +// archivedRound returns the exact round a concurrent supersede moved out of +// Rounds. A trigger poster still owns claims on that object until its network +// call returns, so its outcome must be recorded there rather than lost merely +// because the pull request acquired a new head meanwhile. +func archivedRound(st *State, want Round) *Round { + for i := len(st.Archive) - 1; i >= 0; i-- { + if sameRound(&st.Archive[i], want) { + return &st.Archive[i] + } + } + return nil +} + // firable is the fire guard every reserving CAS uses: eligible, and not held. // // Selecting the round and writing the reservation are two steps, and a hold that @@ -1043,6 +1146,7 @@ func (s *Service) sweepReviewing(ctx context.Context, st State, now time.Time) ( return st, nil } s.selfHealCoReviewers(ctx, cfg, *target, obs.eng, now) + s.noteCoAnswers(ctx, cfg, *target, obs.eng, now) tr := engine.Progress(*target, st.Account, obs.eng, now, cfg.policy()) if tr.Outcome == engine.KeepWaiting { return st, nil @@ -1064,7 +1168,7 @@ func (s *Service) sweepReviewing(ctx context.Context, st State, now time.Time) ( // A round completed here is invisible to the caller — Pump goes on to report // whatever it fires next, or idle — so this is the only moment that knows the // PR's trigger comments are spent. - if err := s.tidyProgressed(ctx, target.Repo, target.PR); err != nil { + if err := s.tidyProgressed(ctx, updated, target.Repo, target.PR); err != nil { return updated, err } return updated, nil @@ -1083,15 +1187,12 @@ func firedOrEnqueuedAt(r Round) time.Time { // Acting on it would post a trigger for a co-reviewer an operator has just // removed, skip one they have just required, or record that a head is reviewed // by a set that no longer gates it — so the verdicts the reviewer configuration -// decides revalidate it (overrideChanged) inside their own commit point, the CAS +// decides revalidate it (reviewersChanged) inside their own commit point, the CAS // mutation that claims the trigger, reserves the slot or writes the dedupe // marker. Checking it in a separate read here would leave exactly the window it // is meant to close: SetReviewers commits in between, and the mutation goes on // to apply the decision the new configuration would not have made. func (s *Service) applyFire(ctx context.Context, cfg Config, round Round, obs engine.Observation, d engine.FireDecision, now time.Time) (PumpResult, error) { - if cfg.ExcludeRepos[NormalizeRepo(round.Repo)] { - return s.abandonRound(ctx, round, "repository excluded by fleet policy", "skipped") - } switch d.Verdict { case engine.FireDrop: return s.abandonRound(ctx, round, "pr closed", "skipped") @@ -1174,7 +1275,7 @@ func (s *Service) dedupeRound(ctx context.Context, cfg Config, round Round, now // SetReviewers cannot see it coming, the round is still queued when the // override lands (requeuing a queued round is a no-op), yet the marker is // exactly what stops the newly required reviewer from ever being asked. - if !sameRound(r, round) || !firable(st, r, now) || overrideChanged(st, round.Repo, cfg) { + if !sameRound(r, round) || !firable(st, r, now) || reviewersChanged(st, round.Repo, cfg) { return ErrNoChange } if err := r.Dedupe(now); err != nil { @@ -1243,7 +1344,7 @@ func (s *Service) fireRound(ctx context.Context, cfg Config, round Round, obs en r := st.Round(round.Repo, round.PR) // postCo below was chosen by cfg's reviewers; a change since means the // claims written here are for a set the operator has replaced. - if !sameRound(r, round) || !firable(st, r, now) || overrideChanged(st, round.Repo, cfg) { + if !sameRound(r, round) || !firable(st, r, now) || reviewersChanged(st, round.Repo, cfg) { return ErrNoChange } if err := r.Reserve(token, s.cfg.WriterID(), now); err != nil { @@ -1254,7 +1355,10 @@ func (s *Service) fireRound(ctx context.Context, cfg Config, round Round, obs en } lf := firedAt st.LastFired = &lf - dl := firedAt.Add(s.cfg.FeedbackWaitTimeout) + // The rolling fair-use log. Written in the same CAS as the fire it + // records, so a count can never include a fire that did not land. + st.NoteObservedFire(firedAt, now) + dl := firedAt.Add(s.feedbackWait(*st)) r.WaitDeadline = &dl st.Warn = "" st.FireSlot = &FireSlot{Key: key, Token: token, Since: now} @@ -1307,7 +1411,7 @@ func (s *Service) fireRound(ctx context.Context, cfg Config, round Round, obs en return ErrNoChange } r := st.Round(round.Repo, round.PR) - if !sameRound(r, round) || !firable(st, r, now) || overrideChanged(st, round.Repo, cfg) { + if !sameRound(r, round) || !firable(st, r, now) || reviewersChanged(st, round.Repo, cfg) { return ErrNoChange } if err := r.Reserve(token, s.cfg.WriterID(), now); err != nil { @@ -1325,7 +1429,12 @@ func (s *Service) fireRound(ctx context.Context, cfg Config, round Round, obs en } s.sync(ctx, reserved) - comment, err := s.gh.PostIssueComment(ctx, round.Repo, round.PR, s.cfg.ReviewCommand) + // cfg, not s.cfg: the decision that reserved the account's quota was made + // from the resolved configuration, so the command posted has to be the one it + // decided for. Posting this host's startup value instead asked the previous + // primary for a review the round is not waiting for, and the round then timed + // out waiting for a bot nobody addressed. + comment, err := s.gh.PostIssueComment(ctx, round.Repo, round.PR, cfg.ReviewCommand) if err != nil { updated, uerr := s.store.Update(ctx, func(st *State) error { r := st.Round(round.Repo, round.PR) @@ -1361,7 +1470,7 @@ func (s *Service) fireRound(ctx context.Context, cfg Config, round Round, obs en coPosts = append(coPosts, coPost{login: login, id: id, at: at}) } } - updated, err := s.recordFire(ctx, round, token, comment.ID, coPosts, firedAt, now) + updated, err := s.recordFire(ctx, cfg, round, token, comment.ID, coPosts, firedAt, now) if err != nil { if errors.Is(err, ErrNoChange) { return PumpResult{Action: "lost_race"}, nil @@ -1370,7 +1479,7 @@ func (s *Service) fireRound(ctx context.Context, cfg Config, round Round, obs en } s.sync(ctx, updated) if s.log != nil { - s.log.Printf("fire %s@%s (posted %s)", key, round.Head, strings.TrimSpace(s.cfg.ReviewCommand)) + s.log.Printf("fire %s@%s (posted %s)", key, round.Head, strings.TrimSpace(cfg.ReviewCommand)) } return PumpResult{Action: "fired", Repo: round.Repo, PR: round.PR, Head: round.Head}, nil } @@ -1420,7 +1529,7 @@ func (s *Service) fireCoOnly(ctx context.Context, cfg Config, round Round, login r := st.Round(round.Repo, round.PR) // The claim is what authorizes the posts below, so the reviewer set that // chose them must still be the configured one when it commits. - if !sameRound(r, round) || !firable(st, r, now) || overrideChanged(st, round.Repo, cfg) { + if !sameRound(r, round) || !firable(st, r, now) || reviewersChanged(st, round.Repo, cfg) { return ErrNoChange } for _, login := range logins { @@ -1461,6 +1570,14 @@ func (s *Service) fireCoOnly(ctx context.Context, cfg Config, round Round, login parked, uerr := s.store.Update(ctx, func(st *State) error { r := st.Round(round.Repo, round.PR) if !sameRound(r, round) { + if archived := archivedRound(st, round); archived != nil { + // The network calls returned, so no poster remains behind + // these claims and an archived round cannot retry them. + for _, login := range claimed { + archived.ClearCoClaim(login) + } + return nil + } return ErrNoChange } // AwaitRetry is only legal from reserved/fired/reviewing; pass a @@ -1495,7 +1612,21 @@ func (s *Service) fireCoOnly(ctx context.Context, cfg Config, round Round, login recorded = false r := st.Round(round.Repo, round.PR) if !sameRound(r, round) { - return ErrNoChange + r = archivedRound(st, round) + if r == nil { + return ErrNoChange + } + for _, login := range claimed { + r.ClearCoClaim(login) + } + for _, p := range posts { + r.RecordPosted(p.login, p.id, p.at) + if r.Co(p.login).CommandID == 0 { + r.SetCoCommand(p.login, p.id, p.at) + } + } + recorded = true + return nil } if r.FireEligible(now) { if err := r.Reserve(randomToken(), s.cfg.WriterID(), now); err != nil { @@ -1505,7 +1636,7 @@ func (s *Service) fireCoOnly(ctx context.Context, cfg Config, round Round, login return err } r.Token = "" - dl := firedAt.Add(s.cfg.FeedbackWaitTimeout) + dl := firedAt.Add(s.feedbackWait(*st)) r.WaitDeadline = &dl r.CoOnly = true // no primary review was requested for this round st.Warn = "" @@ -1584,8 +1715,13 @@ func (s *Service) fireCoReviewWait(ctx context.Context, cfg Config, round Round, if !obs.HeadAt.IsZero() { anchor = obs.HeadAt } + // cfg.Bot, not this process's startup one: the primary is a fleet setting + // like any other, and reading the stale one made a review by the primary + // somebody had just changed fail to move the floor — so with no HeadAt to + // fall back on, a co-reviewer answer that had already arrived sat below it + // and the round waited out its timeout for an answer it already had. for _, rv := range obs.Reviews { - if isConfiguredBotLogin(s.cfg.Bot, rv.Bot) && rv.Commit != "" && strings.HasPrefix(rv.Commit, round.Head) && + if isConfiguredBotLogin(cfg.Bot, rv.Bot) && rv.Commit != "" && strings.HasPrefix(rv.Commit, round.Head) && !rv.SubmittedAt.IsZero() && rv.SubmittedAt.Before(anchor) { anchor = rv.SubmittedAt } @@ -1618,15 +1754,10 @@ func (s *Service) fireCoReviewWait(ctx context.Context, cfg Config, round Round, updated, err := s.store.Update(ctx, func(st *State) error { changed = false r := st.Round(round.Repo, round.PR) - // This is the wait's commit point, so the reviewer set that chose it must - // still be the configured one. A change committed since the decision has - // already reconciled the round while it was queued, and nothing later - // undoes this transition: moving it to reviewing with adopted commands - // would leave the round waiting on — and crediting — the former set. - if !sameRound(r, round) || !firable(st, r, now) || overrideChanged(st, round.Repo, cfg) { + if !sameRound(r, round) || !firable(st, r, now) { return ErrNoChange } - deadline := now.Add(s.cfg.FeedbackWaitTimeout) + deadline := now.Add(s.feedbackWait(*st)) if err := r.AwaitCoReview(deadline, anchor); err != nil { return err } @@ -1657,7 +1788,11 @@ func (s *Service) fireCoReviewWait(ctx context.Context, cfg Config, round Round, // on a transient state-write failure so a fired command is never lost. coPosts // are the co-reviewer trigger comments posted alongside (empty when none), // recorded in the same write. -func (s *Service) recordFire(ctx context.Context, round Round, token string, commandID int64, coPosts []coPost, firedAt, now time.Time) (State, error) { +// +// cfg is the configuration the fire was decided and posted from: the command is +// attributed to the primary that configuration names, not to whichever one this +// process happened to start with. +func (s *Service) recordFire(ctx context.Context, cfg Config, round Round, token string, commandID int64, coPosts []coPost, firedAt, now time.Time) (State, error) { record := func(c context.Context) (State, bool, error) { recorded := false st, err := s.store.Update(c, func(st *State) error { @@ -1672,7 +1807,7 @@ func (s *Service) recordFire(ctx context.Context, round Round, token string, com // Recorded as crq's own whatever the round then does with it: the // comment is on the PR either way, and the record is the only proof // crq (rather than a person) wrote it. - r.RecordPosted(s.cfg.Bot, commandID, firedAt) + r.RecordPosted(cfg.Bot, commandID, firedAt) for _, p := range coPosts { r.RecordPosted(p.login, p.id, p.at) if r.Co(p.login).CommandID == 0 { @@ -1681,7 +1816,10 @@ func (s *Service) recordFire(ctx context.Context, round Round, token string, com } lf := firedAt st.LastFired = &lf - dl := firedAt.Add(s.cfg.FeedbackWaitTimeout) + // The rolling fair-use log. Written in the same CAS as the fire it + // records, so a count can never include a fire that did not land. + st.NoteFire(firedAt) + dl := firedAt.Add(s.feedbackWait(*st)) r.WaitDeadline = &dl st.Warn = "" st.PutRound(*r) @@ -1744,6 +1882,22 @@ func (s *Service) fireCoTrigger(ctx context.Context, cfg Config, round Round, lo if id == 0 { // Failed post: KEEP the claim — its TTL is the retry backoff. Clearing it // here would let the very next pump repost, bypassing triggerClaimTTL. + // An archived round has no next pump, and this returning call proves its + // poster is no longer in flight. + updated, err := s.store.Update(ctx, func(st *State) error { + if sameRound(st.Round(round.Repo, round.PR), round) { + return ErrNoChange + } + r := archivedRound(st, round) + if r == nil { + return ErrNoChange + } + r.ClearCoClaim(login) + return nil + }) + if err == nil { + s.sync(ctx, updated) + } return } updated, err := s.store.Update(ctx, func(st *State) error { @@ -1751,7 +1905,10 @@ func (s *Service) fireCoTrigger(ctx context.Context, cfg Config, round Round, lo // Identity guard: a same-head replacement round (new Seq) must not // inherit this post's result. if !sameRound(r, round) { - return ErrNoChange + r = archivedRound(st, round) + if r == nil { + return ErrNoChange + } } r.RecordPosted(login, id, at) if r.Co(login).CommandID == 0 { @@ -1759,7 +1916,9 @@ func (s *Service) fireCoTrigger(ctx context.Context, cfg Config, round Round, lo } else { r.ClearCoClaim(login) } - st.PutRound(*r) + if sameRound(st.Round(round.Repo, round.PR), round) { + st.PutRound(*r) + } return nil }) if err != nil { @@ -1807,7 +1966,7 @@ func (s *Service) fireCoDeferred(ctx context.Context, cfg Config, round Round, d // As in fireCoOnly: the claims and adoptions written here name the // co-reviewers cfg chose, so a reviewer change since voids them. if !sameRound(r, round) || (r.Phase != PhaseQueued && r.Phase != PhaseAwaitingRetry) || - overrideChanged(st, round.Repo, cfg) { + reviewersChanged(st, round.Repo, cfg) { return ErrNoChange } if _, held := st.HeldPR(round.Repo, round.PR); held { @@ -1881,7 +2040,7 @@ func (s *Service) fireCoDeferred(ctx context.Context, cfg Config, round Round, d // command, or an account that reviews on its own all suppress it (see // DecideCoPost) — not a retry counter. func (s *Service) selfHealCoReviewers(ctx context.Context, cfg Config, round Round, obs engine.Observation, now time.Time) { - if s.cfg.DryRun || cfg.ExcludeRepos[NormalizeRepo(round.Repo)] || round.FiredAt == nil || obs.Head != round.Head { + if s.cfg.DryRun || round.FiredAt == nil || obs.Head != round.Head { return } firedAt := round.FiredAt.UTC() @@ -1905,7 +2064,7 @@ func (s *Service) selfHealCoReviewers(ctx context.Context, cfg Config, round Rou claimed := false updated, err := s.store.Update(ctx, func(st *State) error { r := st.Round(round.Repo, round.PR) - if !sameRound(r, round) || r.Co(login).CommandID != 0 || overrideChanged(st, round.Repo, cfg) { + if !sameRound(r, round) || r.Co(login).CommandID != 0 || reviewersChanged(st, round.Repo, cfg) { return ErrNoChange } if _, held := st.HeldPR(round.Repo, round.PR); held { @@ -1934,11 +2093,11 @@ const triggerClaimTTL = 2 * time.Minute func (s *Service) Cancel(ctx context.Context, repo string, pr int) error { repo = NormalizeRepo(repo) state, err := s.store.Update(ctx, func(st *State) error { - r := st.Round(repo, pr) - if r == nil { + round := st.Round(repo, pr) + if round == nil { return ErrNoChange } - token := r.Token + token := round.Token st.EndRound(repo, pr, "cancelled") releaseSlot(st, QueueKey(repo, pr), token) return nil @@ -1955,7 +2114,7 @@ func (s *Service) Status(ctx context.Context) (State, string, error) { if err != nil { return State{}, "", err } - return state, renderDashboard(state, s.fleetCfg(state)), nil + return state, renderDashboard(state, s.cfg), nil } func (s *Service) RefreshQuota(ctx context.Context) (State, error) { @@ -1963,59 +2122,54 @@ func (s *Service) RefreshQuota(ctx context.Context) (State, error) { if err != nil { return State{}, err } + // Dry-run is a local process safety promise. A shared fleet setting must not + // turn it off and let a direct `debug refresh` post or mutate quota state. if s.cfg.DryRun { return state, nil } - cfg := s.fleetCfg(state) - if cfg.CalibrationPR <= 0 { + quotaService := *s + quotaService.cfg = s.cfg.WithFleet(state.Fleet) + if quotaService.cfg.CalibrationPR <= 0 { return state, nil } now := s.clock() // Honor the freshness shortcut only when the last reading was conclusive. If a // probe is still pending (CalibAskedAt set, no reply yet), keep re-checking so a // late "account blocked" reply isn't ignored for the full TTL. - if state.Account.CalibAskedAt == nil && state.Account.CheckedAt != nil && now.Sub(*state.Account.CheckedAt) < cfg.CalibrationTTL { + if state.Account.CalibAskedAt == nil && state.Account.CheckedAt != nil && now.Sub(*state.Account.CheckedAt) < quotaService.cfg.CalibrationTTL { return state, nil } - quota, readAt, err := s.readQuota(ctx, s.calibrationIssue(state), now, state.Account.CalibAskedAt, cfg) + quota, evidenceAt, err := quotaService.readQuota( + ctx, quotaService.calibrationIssue(state), now, state.Account.CalibAskedAt, + ) if err != nil { return state, err } updated, err := s.store.Update(ctx, func(st *State) error { - current := s.fleetCfg(*st) - // Only the scope invalidates the reading: it says WHICH account the probe - // answered for, and a scope that moved under the read already reset the - // quota to be recalibrated, so writing the old account's answer over that - // reset would state a window for an account crq no longer queues. - // - // Every other fleet setting leaves the reading true, and refusing it over - // one — as a whole-revision comparison did — discarded a still-standing - // account block, since Update reports a refused write as success: this - // same pump then went on to post a metered review inside the blocked - // window it had just been told about. - if !sameFoldedSet(current.Scope, cfg.Scope) { + currentCfg := s.cfg.WithFleet(st.Fleet) + if stampChanged(st.Fleet.UpdatedAt, quotaService.cfg.FleetAt) { return ErrNoChange } - currentTTL := current.CalibrationTTL - if st.Account.CalibAskedAt == nil && st.Account.CheckedAt != nil && now.Sub(*st.Account.CheckedAt) < currentTTL { + if evidenceAt != nil && !evidenceAt.After(now.Add(-currentCfg.CalibrationTTL)) { + // The fleet may have shortened the evidence window while the GitHub + // read was in flight. Never make that now-stale reply look fresh by + // stamping CheckedAt below. return ErrNoChange } - // The reading is an existing reply adopted under the TTL the read STARTED - // with. A fleet that shortened calibrate-ttl in the meantime has already - // declared that reply too old to decide from, and committing it stamps - // CheckedAt=now — presenting it as fresh for a whole new window, and - // letting a metered review fire on an availability result the shorter - // window asks crq to re-establish first. Drop it; the next pass reads - // under the TTL that is now current, and probes when nothing qualifies. - if !readAt.IsZero() && now.Sub(readAt) >= currentTTL { + if st.Account.CalibAskedAt == nil && st.Account.CheckedAt != nil && now.Sub(*st.Account.CheckedAt) < currentCfg.CalibrationTTL { return ErrNoChange } - // A fresh reading replaces the whole quota; carry the account-quota comment - // identity over so the engine can still recognise an edited comment it - // already accounted for. + // Calibration owns only its reading fields. Edit those on the existing + // record so the rolling fire history and members written by a newer + // binary survive this older writer's routine probe. rlID, rlUpdated := st.Account.RLCommentID, st.Account.RLCommentUpdated prevBlock, prevRemaining := st.Account.BlockedUntil, st.Account.Remaining - st.Account = quota + st.Account.Scope = quota.Scope + st.Account.BlockedUntil = quota.BlockedUntil + st.Account.Remaining = quota.Remaining + st.Account.Source = quota.Source + st.Account.CheckedAt = quota.CheckedAt + st.Account.CalibAskedAt = quota.CalibAskedAt if st.Account.RLCommentID == 0 { st.Account.RLCommentID = rlID st.Account.RLCommentUpdated = rlUpdated @@ -2103,32 +2257,27 @@ func (s *Service) rotateCalibration(ctx context.Context, oldIssue int) (int, err return issue.Number, nil } -// readQuota reads the account quota off the calibration thread, adopting a reply -// that is still inside the TTL before spending a probe on a fresh one. -// -// It also returns when that adopted reply was written, and the zero time when -// the reading is the answer to a probe this call posted. Only the caller's write -// knows the TTL in force when the reading is committed, and an adopted reply -// accepted under a longer one is exactly what a shortened TTL invalidates. -func (s *Service) readQuota(ctx context.Context, issue int, now time.Time, pendingAsked *time.Time, cfg Config) (AccountQuota, time.Time, error) { - quota := AccountQuota{Scope: strings.Join(cfg.Scope, ","), Source: "calibrate", CheckedAt: &now} - cutoff := now.Add(-cfg.CalibrationTTL) - keepAfter := now.Add(-2 * cfg.CalibrationTTL) +func (s *Service) readQuota( + ctx context.Context, issue int, now time.Time, pendingAsked *time.Time, +) (AccountQuota, *time.Time, error) { + quota := AccountQuota{Scope: strings.Join(s.cfg.Scope, ","), Source: "calibrate", CheckedAt: &now} + cutoff := now.Add(-s.cfg.CalibrationTTL) + keepAfter := now.Add(-2 * s.cfg.CalibrationTTL) if reply, ok, err := s.latestCalibrationReply(ctx, issue, cutoff); err != nil { - return quota, time.Time{}, err + return quota, nil, err } else if ok { remaining, reset := dialect.ParseQuota(reply.Body, reply.UpdatedAt) quota.Remaining = remaining quota.BlockedUntil = reset s.pruneCalibration(ctx, issue, keepAfter, 80) - return quota, reply.UpdatedAt, nil + return quota, &reply.UpdatedAt, nil } // A probe from a previous call is still pending and not yet stale, and no // reply to it was found: keep waiting for its (possibly late) reply instead of // posting another probe every cycle. if pendingAsked != nil && pendingAsked.After(cutoff) { quota.CalibAskedAt = pendingAsked - return quota, time.Time{}, nil + return quota, nil, nil } asked, err := s.gh.PostIssueComment(ctx, s.cfg.GateRepo, issue, s.cfg.RateLimitCommand) if err != nil { @@ -2152,19 +2301,19 @@ func (s *Service) readQuota(ctx context.Context, issue int, now time.Time, pendi if s.log != nil { s.log.Printf("calibration probe on #%d failed: %v", issue, err) } - return quota, time.Time{}, err + return quota, nil, err } } quota.CalibAskedAt = &asked.CreatedAt for i := 0; i < 6; i++ { select { case <-ctx.Done(): - return quota, time.Time{}, ctx.Err() + return quota, nil, ctx.Err() case <-time.After(2 * time.Second): } reply, ok, err := s.latestCalibrationReply(ctx, issue, asked.CreatedAt.Add(-time.Second)) if err != nil { - return quota, time.Time{}, err + return quota, nil, err } if ok { remaining, reset := dialect.ParseQuota(reply.Body, reply.UpdatedAt) @@ -2172,10 +2321,10 @@ func (s *Service) readQuota(ctx context.Context, issue int, now time.Time, pendi quota.BlockedUntil = reset quota.CalibAskedAt = nil s.pruneCalibration(ctx, issue, keepAfter, 80) - return quota, time.Time{}, nil + return quota, &reply.UpdatedAt, nil } } - return quota, time.Time{}, nil + return quota, nil, nil } // pruneCalibration deletes crq's old calibration probe comments and CodeRabbit's @@ -2314,7 +2463,7 @@ func (s *Service) sync(ctx context.Context, state State) { if s.log == nil || s.cfg.DashboardIssue <= 0 { return } - if err := s.store.SyncDashboard(ctx, state, s.fleetCfg(state).storeConfig()); err != nil { + if err := s.store.SyncDashboard(ctx, state); err != nil { s.log.Printf("warning: dashboard sync failed: %v", err) } } @@ -2409,7 +2558,10 @@ func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, in // A real primary review at this head is NOT a poisoned marker even with // a co-bot pending (a deliberate dedupe when Codex is unobtainable). // Deleting it would requeue the same head into ack-and-dedupe churn. - if reviewedByConfiguredBot(report.ReviewedBy, s.cfg.Bot) { + // The primary is the report's, not this process's startup one: a + // review by a primary the fleet changed would otherwise read as + // nobody's, and the marker it justifies would be deleted and rebought. + if reviewedByConfiguredBot(report.ReviewedBy, report.config.Bot) { return PumpResult{Action: "deduped", Repo: repo, PR: pr, Head: result.Head}, 3, nil } // A completed round at this head with no real head review is a poisoned @@ -2618,3 +2770,116 @@ func (s *Service) sweepParkedClosed(ctx context.Context, st State) (PumpResult, res, err := s.abandonRound(ctx, *target, "pr closed", "skipped") return res, true, err } + +// noteCoAnswers records which reviewers have actually answered for this head, +// from an observation crq has already paid for. +// +// It exists because nothing else in the round says a bot did anything. The +// trigger bookkeeping — the command crq posted, the claim it took — is all +// about crq, so a bot with no account behind it looks identical to one working +// perfectly: crq asks, records that it asked, and nothing answers. These are the +// only fields that can tell those apart, which is what the bot guide's setup +// status is read from. +// +// The primary is recorded here too, by the same generic head-evidence test and +// for exactly the same reason. Its round carries no per-bot entry, so the only +// thing left to read it from was the phase — and a required set that omits the +// primary completes on its co-reviewers' answers while the primary has done +// nothing but acknowledge the command. +func (s *Service) noteCoAnswers(ctx context.Context, cfg Config, round Round, obs engine.Observation, now time.Time) { + if s.cfg.DryRun { + return // DryRun writes nothing, bookkeeping included + } + var answered []string + for _, cb := range cfg.CoBots { + // Setup status is not head-scoped: activity on an earlier head still + // proves that the reviewer is installed and working. + if engine.CoReviewerActive(obs, cb.Login) { + answered = append(answered, cb.Login) + } + } + primary := cfg.Bot != "" && + (engine.CoReviewedHead(obs, cfg.Bot) || engine.PrimaryCompletedRound(round, obs, cfg.policy())) + if len(answered) == 0 && !primary { + return + } + if _, err := s.store.Update(ctx, func(st *State) error { + r := st.Round(round.Repo, round.PR) + if r == nil || !sameRound(r, round) { + return ErrNoChange + } + before, beforePrimary := r.CoBots, r.PrimaryAnsweredAt + for _, login := range answered { + r.NoteCoAnswer(login, now) + } + if primary { + r.NotePrimaryAnswer(cfg.Bot, now) + } + if sameCoAnswers(before, r.CoBots) && beforePrimary == r.PrimaryAnsweredAt { + return ErrNoChange + } + st.PutRound(*r) + return nil + }); err != nil && !errors.Is(err, ErrNoChange) && s.log != nil { + // Display-only bookkeeping: worth a line, never worth failing a round. + s.log.Printf("warning: recording reviewer answers for %s#%d: %v", round.Repo, round.PR, err) + } +} + +func sameCoAnswers(before, after map[string]CoBotRound) bool { + for login, a := range after { + b := before[login] + switch { + case a.AnsweredAt == nil && b.AnsweredAt == nil: + case a.AnsweredAt == nil || b.AnsweredAt == nil: + return false + case !a.AnsweredAt.Equal(*b.AnsweredAt): + return false + } + } + return true +} + +// LoadState reads the current state, for callers that need it to resolve a +// setting without acting on it. +func (s *Service) LoadState(ctx context.Context) (State, error) { + st, _, err := s.store.Load(ctx) + return st, err +} + +// noteTitles records pull-request titles on rounds that already exist. +// +// A round is created with the title the scan saw, but a round already at the +// current head is never a candidate, so it would never be written again and +// would read as a bare number for ever. This is the one write that fixes that, +// and it converges: once every round has its title, nothing changes and the +// CAS reports no change. +// +// Best-effort by construction. A title is what a list SAYS, not what the queue +// decides, so failing a pass over one would trade something that matters for +// something that does not. +func (s *Service) noteTitles(ctx context.Context, items []queueCandidate) { + if s.cfg.DryRun || len(items) == 0 { + return + } + _, err := s.store.Update(ctx, func(st *State) error { + changed := false + for _, it := range items { + r := st.Round(NormalizeRepo(it.Repo), it.PR) + if r == nil || it.Title == "" || r.Title == it.Title { + continue + } + updated := *r + updated.Title = it.Title + st.PutRound(updated) + changed = true + } + if !changed { + return ErrNoChange + } + return nil + }) + if err != nil && !errors.Is(err, ErrNoChange) && s.log != nil { + s.log.Printf("warning: recording pull request titles: %v", err) + } +} diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index e52578f1..9a6fb87b 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -20,6 +20,8 @@ import ( type fakeGitHub struct { mu sync.Mutex pulls map[string]ghapi.Pull + pullReads map[string]int + pullErrOnRead map[string]int commits map[string]ghapi.Commit commitErrs map[string]error reviews map[string][]ghapi.Review @@ -48,13 +50,48 @@ type fakeGitHub struct { refReads int reviewReads int searchPRs []ghapi.SearchPR - getComment func(repo string, id int64) (ghapi.IssueComment, error) + // searches counts EachOpenPR calls, which is what says whether a pass went + // looking at all — an empty result set and a search never made are the same + // enqueue count and very different REST bills. + searches int + ownerRepos []ghapi.Repo + owners []string + getComment func(repo string, id int64) (ghapi.IssueComment, error) // now, when set, timestamps posted comments off the same injected clock the // service uses, so a fire's recorded FiredAt tracks the fake wall clock the // replay suite advances. nil falls back to real time (all existing tests). now func() time.Time } +func TestObservedBlockUsesTheStateResolvedPrimaryAndFallback(t *testing.T) { + ctx := context.Background() + startup := firingConfig() + startup.Bot = "startup-primary[bot]" + startup.RateLimitFallback = 5 * time.Minute + store := NewMemoryStore(startup) + svc := NewService(startup, newFakeGitHub(), store, nil) + now := time.Now().UTC() + effective := startup + effective.Bot = "fleet-primary[bot]" + effective.RateLimitFallback = 47 * time.Minute + obs := observation{eng: engine.Observation{Events: []dialect.BotEvent{{ + Kind: dialect.EvRateLimited, Bot: effective.Bot, + CommentID: 91, UpdatedAt: now, + }}}} + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + updated, err := svc.recordObservedBlock(ctx, effective, obs, st, now) + if err != nil { + t.Fatal(err) + } + want := now.Add(effective.RateLimitFallback) + if updated == nil || updated.Account.BlockedUntil == nil || !updated.Account.BlockedUntil.Equal(want) { + t.Fatalf("blocked until = %v, want the fleet-resolved fallback %s", updated, want) + } +} + func (f *fakeGitHub) clock() time.Time { if f.now != nil { return f.now().UTC() @@ -65,6 +102,8 @@ func (f *fakeGitHub) clock() time.Time { func newFakeGitHub() *fakeGitHub { return &fakeGitHub{ pulls: map[string]ghapi.Pull{}, + pullReads: map[string]int{}, + pullErrOnRead: map[string]int{}, commits: map[string]ghapi.Commit{}, commitErrs: map[string]error{}, reviews: map[string][]ghapi.Review{}, @@ -73,7 +112,6 @@ func newFakeGitHub() *fakeGitHub { issueReactions: map[string][]ghapi.Reaction{}, reactions: map[int64][]ghapi.Reaction{}, reactionErrs: map[int64]error{}, - listPullErrs: map[string]error{}, deleteErrs: map[int64]error{}, deleteAfterErrs: map[int64]error{}, } @@ -124,7 +162,15 @@ func (f *fakeGitHub) setCheckRuns(ref string, runs ...ghapi.CheckRun) { func (f *fakeGitHub) GetPull(_ context.Context, repo string, pr int) (ghapi.Pull, error) { f.mu.Lock() defer f.mu.Unlock() - pull, ok := f.pulls[fakeKey(repo, pr)] + key := fakeKey(repo, pr) + if f.pullReads == nil { + f.pullReads = map[string]int{} + } + f.pullReads[key]++ + if f.pullErrOnRead[key] == f.pullReads[key] { + return ghapi.Pull{}, errors.New("injected pull read failure") + } + pull, ok := f.pulls[key] if !ok { return ghapi.Pull{}, errors.New("missing pull") } @@ -265,12 +311,18 @@ func (f *fakeGitHub) SearchOpenPRs(context.Context, string, bool, int) ([]ghapi. return nil, nil } -func (f *fakeGitHub) EachOpenPR(_ context.Context, target string, _ bool, fn func(ghapi.SearchPR) (bool, error)) error { +// ownerRepos backs ListOwnerRepos; empty is a fine default, since only the +// repository picker asks and nothing in the queue depends on it. +func (f *fakeGitHub) ListOwnerRepos(_ context.Context, owner string, _ int) ([]ghapi.Repo, error) { f.mu.Lock() - if err := f.listPullErrs[strings.ToLower(target)]; err != nil { - f.mu.Unlock() - return err - } + defer f.mu.Unlock() + f.owners = append(f.owners, owner) + return append([]ghapi.Repo(nil), f.ownerRepos...), nil +} + +func (f *fakeGitHub) EachOpenPR(_ context.Context, _ string, _ bool, fn func(ghapi.SearchPR) (bool, error)) error { + f.mu.Lock() + f.searches++ prs := append([]ghapi.SearchPR(nil), f.searchPRs...) f.mu.Unlock() for _, pr := range prs { @@ -422,7 +474,7 @@ func (s retryNoChangeStore) Update(_ context.Context, mutate func(*State) error) return second, nil } -func (retryNoChangeStore) SyncDashboard(context.Context, State, StoreConfig) error { return nil } +func (retryNoChangeStore) SyncDashboard(context.Context, State) error { return nil } // adoptionRaceStore loads a queued round with an adoptable command, but every // Update simulates another worker already holding the fire slot. @@ -458,7 +510,7 @@ func (s *adoptionRaceStore) Update(_ context.Context, mutate func(*State) error) return state, nil } -func (s *adoptionRaceStore) SyncDashboard(context.Context, State, StoreConfig) error { return nil } +func (s *adoptionRaceStore) SyncDashboard(context.Context, State) error { return nil } // --- test helpers --- @@ -627,11 +679,7 @@ func TestAutoReviewScanSkipsConfiguredAuthors(t *testing.T) { } } -// Who autoreview skips is fleet policy for the same reason the body marker is: -// leadership moves between hosts, and a Dependabot PR skipped on the machine -// that held the lease yesterday must not spend the shared allowance on the one -// that holds it today. -func TestAutoReviewScanSkipsTheFleetsAuthors(t *testing.T) { +func TestAutoReviewScanSkipsMarkedPRs(t *testing.T) { ctx := context.Background() cfg := Config{ GateRepo: "o/gate", @@ -641,27 +689,20 @@ func TestAutoReviewScanSkipsTheFleetsAuthors(t *testing.T) { ReviewCommand: "@coderabbitai review", LeaderTTL: time.Minute, AutoReviewMaxScan: 10, - SkipAuthors: authorSet("renovate[bot]"), + SkipMarker: "", } gh := newFakeGitHub() gh.searchPRs = []ghapi.SearchPR{ - {Repo: "o/app", Number: 1, Author: "Dependabot"}, - {Repo: "o/app", Number: 2, Author: "renovate[bot]"}, - {Repo: "o/app", Number: 3, Author: "alice"}, + {Repo: "o/app", Number: 1, Author: "alice", Body: "Tiny maintenance change.\n\n"}, + {Repo: "o/app", Number: 2, Author: "alice", Body: "Review this change."}, } - for pr := 1; pr <= 3; pr++ { + for pr := 1; pr <= 2; pr++ { var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("o/app", pr)] = pull } store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("skip-authors", "dependabot[bot]") - return nil - }); err != nil { - t.Fatal(err) - } svc := NewService(cfg, gh, store, nil) if err := svc.AutoReview(ctx, AutoOptions{Once: true, Incremental: true}); err != nil { @@ -671,87 +712,34 @@ func TestAutoReviewScanSkipsTheFleetsAuthors(t *testing.T) { if err != nil { t.Fatal(err) } - if st.Round("o/app", 1) != nil { - t.Fatalf("the fleet's skipped author must never be queued, got %#v", st.Round("o/app", 1)) - } - if st.Round("o/app", 2) == nil { - t.Fatal("the fleet's list replaces this host's, so its author is reviewed again") - } - if st.Round("o/app", 3) == nil { - t.Fatalf("a human-authored PR must still be enqueued, got rounds=%#v", st.Rounds) - } -} - -// Without a fleet `scope` the pass's configuration is this host's own, so its -// target list shares the backing array of CRQ_SCOPE. Sorting it in place -// rewrote the operator's configured scan order under every later reader of it, -// including a concurrent one. -func TestAutoReviewScanLeavesTheConfiguredScopeAlone(t *testing.T) { - ctx := context.Background() - cfg := Config{ - GateRepo: "o/gate", - Scope: []string{"zeta-org", "alpha-org"}, - Host: "h", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - LeaderTTL: time.Minute, - AutoReviewMaxScan: 10, - } - store := NewMemoryStore(cfg) - svc := NewService(cfg, newFakeGitHub(), store, nil) - - if err := svc.AutoReview(ctx, AutoOptions{Once: true, Incremental: true}); err != nil { - t.Fatal(err) + if st.FiredMarker("o/app", 2) == "" { + t.Fatalf("only the unmarked PR should be reviewed, got rounds=%#v", st.Rounds) } - if got := strings.Join(svc.cfg.Scope, ","); got != "zeta-org,alpha-org" { - t.Fatalf("Scope = %q, want the configured order untouched", got) + if st.Round("o/app", 1) != nil { + t.Fatalf("marked PR must never fire, got %#v", st.Round("o/app", 1)) } } -func TestAutoReviewScanSkipsMarkedPRs(t *testing.T) { +func TestNoteTitlesDoesNotWriteDuringDryRun(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "o/gate", - Scope: []string{"o"}, - Host: "h", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - LeaderTTL: time.Minute, - AutoReviewMaxScan: 10, - SkipMarker: "", - } - gh := newFakeGitHub() - gh.searchPRs = []ghapi.SearchPR{ - {Repo: "o/app", Number: 1, Author: "alice", Body: "Tiny maintenance change.\n\n"}, - {Repo: "o/app", Number: 2, Author: "alice", Body: "Review this change."}, - } - for pr := 1; pr <= 2; pr++ { - var pull ghapi.Pull - pull.State = "open" - pull.Head.SHA = "abcdef1234567890" - gh.pulls[fakeKey("o/app", pr)] = pull - } + cfg := Config{DryRun: true} store := NewMemoryStore(cfg) if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("skip-marker", "") + st.PutRound(Round{Repo: "o/r", PR: 1, Head: "aaaaaaaaa", Title: "old title"}) return nil }); err != nil { t.Fatal(err) } - svc := NewService(cfg, gh, store, nil) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + svc.noteTitles(ctx, []queueCandidate{{Repo: "o/r", PR: 1, Title: "new title"}}) - if err := svc.AutoReview(ctx, AutoOptions{Once: true, Incremental: true}); err != nil { - t.Fatal(err) - } st, _, err := store.Load(ctx) if err != nil { t.Fatal(err) } - if st.FiredMarker("o/app", 2) == "" { - t.Fatalf("only the unmarked PR should be reviewed, got rounds=%#v", st.Rounds) - } - if st.Round("o/app", 1) != nil { - t.Fatalf("marked PR must never fire, got %#v", st.Round("o/app", 1)) + if got := st.Round("o/r", 1).Title; got != "old title" { + t.Fatalf("title = %q, want dry-run state unchanged", got) } } @@ -764,7 +752,7 @@ func TestEnqueueBatchAppendsOncePerPR(t *testing.T) { {Repo: "o/b", PR: 2, Head: "bbbbbbbb2"}, {Repo: "o/a", PR: 1, Head: "aaaaaaaa1"}, } - if err := svc.enqueueBatch(ctx, items, svc.fleetCfg(DefaultState(cfg)).FleetRevision); err != nil { + if err := svc.enqueueBatch(ctx, items); err != nil { t.Fatal(err) } st, _, _ := svc.store.Load(ctx) @@ -775,7 +763,7 @@ func TestEnqueueBatchAppendsOncePerPR(t *testing.T) { if queued[0].Seq == queued[1].Seq || queued[0].Seq == 0 { t.Fatalf("expected distinct non-zero seqs, got %d and %d", queued[0].Seq, queued[1].Seq) } - if err := svc.enqueueBatch(ctx, items, svc.fleetCfg(DefaultState(cfg)).FleetRevision); err != nil { + if err := svc.enqueueBatch(ctx, items); err != nil { t.Fatal(err) } st2, _, _ := svc.store.Load(ctx) @@ -784,6 +772,24 @@ func TestEnqueueBatchAppendsOncePerPR(t *testing.T) { } } +func TestEnqueueBatchDryRunDoesNotWrite(t *testing.T) { + cfg := Config{GateRepo: "o/gate", Scope: []string{"o"}, Host: "h", DryRun: true} + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + + if err := svc.enqueueBatch(context.Background(), []queueCandidate{{ + Repo: "o/a", PR: 1, Head: "aaaaaaaa1", + }}); err != nil { + t.Fatal(err) + } + st, _, err := svc.store.Load(context.Background()) + if err != nil { + t.Fatal(err) + } + if round := st.Round("o/a", 1); round != nil { + t.Fatalf("dry-run enqueue persisted a round: %+v", round) + } +} + func TestEnqueueBatchSkipsHeldPRsUnderCAS(t *testing.T) { cfg := Config{GateRepo: "o/gate", Scope: []string{"o"}, Host: "h"} store := NewMemoryStore(cfg) @@ -801,7 +807,7 @@ func TestEnqueueBatchSkipsHeldPRsUnderCAS(t *testing.T) { {Repo: "o/held", PR: 1, Head: "aaaaaaaa1"}, {Repo: "o/ready", PR: 2, Head: "bbbbbbbb2"}, } - if err := svc.enqueueBatch(ctx, items, svc.fleetCfg(DefaultState(cfg)).FleetRevision); err != nil { + if err := svc.enqueueBatch(ctx, items); err != nil { t.Fatal(err) } st, _, err := store.Load(ctx) @@ -816,60 +822,6 @@ func TestEnqueueBatchSkipsHeldPRsUnderCAS(t *testing.T) { } } -func TestEnqueueBatchRejectsCandidatesSelectedUnderStaleFleetPolicy(t *testing.T) { - cfg := Config{GateRepo: "o/gate", Scope: []string{"o"}, Host: "h"} - store := NewMemoryStore(cfg) - svc := NewService(cfg, newFakeGitHub(), store, nil) - ctx := context.Background() - st, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - selectedRevision := svc.fleetCfg(st).FleetRevision - if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("repos", "o/elsewhere") - return nil - }); err != nil { - t.Fatal(err) - } - - if err := svc.enqueueBatch(ctx, []queueCandidate{ - {Repo: "o/obsolete", PR: 1, Head: "aaaaaaaa1"}, - }, selectedRevision); err != nil { - t.Fatal(err) - } - st, _, err = store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if round := st.Round("o/obsolete", 1); round != nil { - t.Fatalf("stale fleet discovery enqueued a round: %+v", round) - } -} - -func TestEnqueueBatchDoesNotWriteInDryRun(t *testing.T) { - cfg := Config{GateRepo: "o/gate", Scope: []string{"o"}, Host: "h", DryRun: true} - store := NewMemoryStore(cfg) - svc := NewService(cfg, newFakeGitHub(), store, nil) - ctx := context.Background() - st, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if err := svc.enqueueBatch(ctx, []queueCandidate{ - {Repo: "o/repo", PR: 1, Head: "aaaaaaaa1"}, - }, svc.fleetCfg(st).FleetRevision); err != nil { - t.Fatal(err) - } - st, _, err = store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if round := st.Round("o/repo", 1); round != nil { - t.Fatalf("dry-run batch wrote a round: %+v", round) - } -} - func TestLatestCalibrationReplyToleratesBotSuffix(t *testing.T) { cfg := Config{Bot: "coderabbitai", GateRepo: "o/gate", CalibrationPR: 1, CalibrationMarker: "auto-generated reply by CodeRabbit"} gh := newFakeGitHub() @@ -934,6 +886,74 @@ func firingConfig() Config { } } +func TestApplyTransitionDropsRetryWhenFleetPolicyChanged(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + repo, pr := "owner/repo", 88 + now := time.Now().UTC() + seedRound(t, store, cfg, repo, pr, "abcdef123", PhaseFired, now.Add(-time.Minute), 9) + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + decidedCfg := svc.cfgFor(st, repo) + changedAt := now.Add(time.Second) + st.Fleet.UpdatedAt = &changedAt + round := st.Round(repo, pr) + + err = svc.applyTransition(&st, round, engine.Transition{ + Outcome: engine.OutRetry, + Reason: "old in-flight timeout elapsed", + RetryAt: now.Add(time.Minute), + }, now, decidedCfg) + if !errors.Is(err, ErrNoChange) { + t.Fatalf("applyTransition error = %v, want stale retry discarded", err) + } + if round.Phase != PhaseFired || st.FireSlot == nil { + t.Fatalf("stale retry changed the live round or released its slot: round=%+v slot=%+v", round, st.FireSlot) + } + + until := now.Add(30 * time.Minute) + noticeAt := now.Add(-time.Minute) + err = svc.applyTransition(&st, round, engine.Transition{ + Outcome: engine.OutRetry, + Reason: dialect.ReasonRateLimited, + RetryAt: until, + Blocked: &engine.AccountBlock{ + Until: until, CommentID: 5, CommentUpdated: noticeAt, + }, + }, now, decidedCfg) + if err != nil { + t.Fatalf("applyTransition error = %v, want independent account evidence retained", err) + } + if st.Account.BlockedUntil == nil || !st.Account.BlockedUntil.Equal(until) { + t.Fatalf("blocked until = %v, want %s", st.Account.BlockedUntil, until) + } + if round.Phase != PhaseFired || st.FireSlot == nil { + t.Fatalf("account evidence applied the stale retry: round=%+v slot=%+v", round, st.FireSlot) + } +} + +func TestReleaseSlotRequiresTheOwningToken(t *testing.T) { + until := time.Now().UTC().Add(time.Minute) + st := State{ + FireSlot: &FireSlot{Key: "owner/repo#1", Token: "old-token", HoldUntil: &until}, + FireSlotHoldUntil: &until, + } + + releaseSlot(&st, "owner/repo#1", "replacement-token") + if st.FireSlot == nil || st.FireSlot.HoldUntil == nil || st.FireSlotHoldUntil == nil { + t.Fatalf("replacement round cleared the original command's slot hold: %+v", st.FireSlot) + } + + releaseSlot(&st, "owner/repo#1", "old-token") + if st.FireSlot != nil || st.FireSlotHoldUntil != nil { + t.Fatalf("owning token did not clear its slot and compatibility hold: %+v", st.FireSlot) + } +} + func TestFallbackTokenIsSafeToShorten(t *testing.T) { for _, now := range []time.Time{time.Unix(0, 0), time.Unix(0, 1)} { if got := fallbackToken(now); len(got) < 8 { @@ -1038,7 +1058,8 @@ func TestPumpAdoptsExistingReviewCommandWithoutRefiring(t *testing.T) { ctx := context.Background() cfg := firingConfig() gh := newFakeGitHub() - headTime := time.Now().UTC().Add(-time.Minute) + observedAt := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + headTime := observedAt.Add(-time.Minute) var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" @@ -1052,6 +1073,7 @@ func TestPumpAdoptsExistingReviewCommandWithoutRefiring(t *testing.T) { gh.graphQL = noForcePush store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) + service.now = func() time.Time { return observedAt } if _, err := service.Enqueue(ctx, "owner/repo", 12); err != nil { t.Fatal(err) @@ -1083,6 +1105,10 @@ func TestPumpAdoptsExistingReviewCommandWithoutRefiring(t *testing.T) { if r.WaitDeadline == nil || !r.WaitDeadline.Equal(comment.CreatedAt.Add(cfg.FeedbackWaitTimeout)) { t.Fatalf("adopted review command should set the feedback wait deadline from the comment timestamp, got %#v", r) } + if state.Account.FiresFrom == nil || !state.Account.FiresFrom.Equal(observedAt) { + t.Fatalf("fire-log coverage = %v, want the command observation time %s", + state.Account.FiresFrom, observedAt) + } } func TestAdoptableCommandsRequiresExpectedHead(t *testing.T) { @@ -1724,7 +1750,7 @@ func TestRecordFireResetsRecordedAcrossRetry(t *testing.T) { cfg := firingConfig() svc := NewService(cfg, newFakeGitHub(), retryNoChangeStore{cfg: cfg}, nil) round := Round{Repo: "owner/repo", PR: 12, Head: "abcdef123"} - _, err := svc.recordFire(context.Background(), round, "token", 1, nil, time.Now().UTC(), time.Now().UTC()) + _, err := svc.recordFire(context.Background(), cfg, round, "token", 1, nil, time.Now().UTC(), time.Now().UTC()) if !errors.Is(err, ErrNoChange) { t.Fatalf("expected no-change after retry lost the fire slot, got %v", err) } @@ -2501,144 +2527,6 @@ func TestRefreshQuotaPreservesBlockOnInconclusiveProbe(t *testing.T) { } } -func TestRefreshQuotaRejectsAReadingFromAnObsoleteFleetScope(t *testing.T) { - ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", StateRef: "crq-state-v3", CalibrationPR: 1, - CalibrationMarker: "auto-generated reply by CodeRabbit", - RateLimitCommand: "@coderabbitai rate limit", - CalibrationTTL: 2 * time.Minute, Scope: []string{"old-owner"}, - } - gh := newFakeGitHub() - inner := NewMemoryStore(cfg) - store := &hookedStore{StateStore: inner} - svc := NewService(cfg, gh, store, nil) - store.hook = func() { - if _, err := inner.Update(ctx, func(st *State) error { - st.SetFleetValue("scope", "new-owner") - st.Account = AccountQuota{Scope: "new-owner", Source: "fleet scope changed"} - return nil - }); err != nil { - t.Fatal(err) - } - } - - updated, err := svc.RefreshQuota(ctx) - if err != nil { - t.Fatal(err) - } - if updated.Account.Scope != "new-owner" || updated.Account.CalibAskedAt != nil || updated.Account.CheckedAt != nil { - t.Fatalf("obsolete calibration reading overwrote the new scope reset: %+v", updated.Account) - } -} - -// Only the scope says which account a probe answered for. Refusing the write -// because some unrelated fleet setting moved under the read dropped the block -// the probe had just found — and Update reports a refused write as success, so -// the same pump went on to post a metered review inside that window. -func TestRefreshQuotaRecordsABlockAcrossAnUnrelatedFleetChange(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - cfg.CalibrationPR = 77 - cfg.GateRepo = "o/state" - cfg.CalibrationTTL = time.Minute - gh := newFakeGitHub() - inner := NewMemoryStore(cfg) - store := &hookedStore{StateStore: inner} - svc := NewService(cfg, gh, store, nil) - now := time.Now().UTC() - svc.now = func() time.Time { return now } - - reply := ghapi.IssueComment{ - ID: 5, - Body: "auto-generated reply by CodeRabbit\n> **Next review available in:** **45 minutes**", - CreatedAt: now.Add(-10 * time.Second), UpdatedAt: now.Add(-10 * time.Second), - } - reply.User.Login = cfg.Bot - gh.comments[fakeKey(cfg.GateRepo, 77)] = []ghapi.IssueComment{reply} - store.hook = func() { - if _, err := inner.Update(ctx, func(st *State) error { - st.SetFleetValue("min-interval", "45m") - return nil - }); err != nil { - t.Error(err) - } - } - - updated, err := svc.RefreshQuota(ctx) - if err != nil { - t.Fatal(err) - } - if updated.Account.BlockedUntil == nil || !updated.Account.BlockedUntil.After(now.Add(40*time.Minute)) { - t.Fatalf("account = %+v, want the probe's block recorded despite the unrelated policy change", updated.Account) - } -} - -// The other half of that rule: a reading adopted under the TTL the read started -// with is exactly what a SHORTENED calibrate-ttl invalidates. Committing it -// would stamp CheckedAt=now over a reply the new window already calls too old, -// making it fresh for another full TTL and letting a metered review fire on a -// calibration the fleet has just asked to be redone. -func TestRefreshQuotaRejectsAReadingAShortenedTTLMakesStale(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - cfg.CalibrationPR = 77 - cfg.GateRepo = "o/state" - cfg.CalibrationTTL = 10 * time.Minute - gh := newFakeGitHub() - inner := NewMemoryStore(cfg) - store := &hookedStore{StateStore: inner} - svc := NewService(cfg, gh, store, nil) - now := time.Now().UTC() - svc.now = func() time.Time { return now } - - reply := ghapi.IssueComment{ - ID: 5, - Body: "auto-generated reply by CodeRabbit\n> **6 reviews** remaining", - CreatedAt: now.Add(-5 * time.Minute), UpdatedAt: now.Add(-5 * time.Minute), - } - reply.User.Login = cfg.Bot - gh.comments[fakeKey(cfg.GateRepo, 77)] = []ghapi.IssueComment{reply} - store.hook = func() { - if _, err := inner.Update(ctx, func(st *State) error { - st.SetFleetValue("calibrate-ttl", "1m") - return nil - }); err != nil { - t.Error(err) - } - } - - updated, err := svc.RefreshQuota(ctx) - if err != nil { - t.Fatal(err) - } - if updated.Account.CheckedAt != nil || updated.Account.Remaining != nil { - t.Fatalf("account = %+v, want the stale reading refused rather than stamped fresh", updated.Account) - } -} - -func TestRefreshQuotaDoesNotProbeOrWriteInDryRun(t *testing.T) { - ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", CalibrationPR: 1, DryRun: true, - CalibrationTTL: 2 * time.Minute, Scope: []string{"owner"}, - } - gh := newFakeGitHub() - store := NewMemoryStore(cfg) - svc := NewService(cfg, gh, store, nil) - - updated, err := svc.RefreshQuota(ctx) - if err != nil { - t.Fatal(err) - } - if len(gh.posted) != 0 { - t.Fatalf("dry-run calibration posted comments: %v", gh.posted) - } - if updated.Account.CalibAskedAt != nil || updated.Account.CheckedAt != nil { - t.Fatalf("dry-run calibration changed quota state: %+v", updated.Account) - } -} - // TestLoopDegradesToCodexOnlyOnRateLimit: a rate-limited round with Codex // activity must return Codex findings promptly — marked deferred — instead of // waiting out the CodeRabbit window. @@ -3389,58 +3277,6 @@ func TestFeedbackRecordsARateLimitNoticeItObserves(t *testing.T) { } } -// A fleet setting that has nothing to do with the account must not throw away -// an observed block. Update reports ErrNoChange as success, so refusing the -// write on a whole-revision mismatch left the caller believing the window had -// been recorded while the account stayed open — and the next fire went out -// inside it. -func TestObservedAccountBlockSurvivesAnUnrelatedFleetChange(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - gh := newFakeGitHub() - pull := ghapi.Pull{State: "open"} - pull.Head.SHA = "a0646f010abcdef0" - gh.pulls[fakeKey("o/carrier", 82)] = pull - - notice := time.Now().UTC().Add(-time.Minute) - rl := ghapi.IssueComment{ID: 501, - Body: "\n> ## Review limit reached\n> **Next review available in:** **40 minutes**", - CreatedAt: notice, UpdatedAt: notice} - rl.User.Login = "coderabbitai[bot]" - gh.comments[fakeKey("o/carrier", 82)] = []ghapi.IssueComment{rl} - - store := NewMemoryStore(cfg) - svc := NewService(cfg, gh, store, nil) - now := time.Now().UTC() - - st, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - obs, err := svc.observe(ctx, svc.fleetCfg(st), "o/carrier", 82, nil, nil, now) - if err != nil { - t.Fatal(err) - } - // Somebody changes an unrelated policy while this notice is being read. - if _, err := store.Update(ctx, func(w *State) error { - w.SetFleetValue("settle", "30s") - return nil - }); err != nil { - t.Fatal(err) - } - - if _, err := svc.recordObservedBlock(ctx, obs, st, now); err != nil { - t.Fatal(err) - } - after, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if after.Account.BlockedUntil == nil { - t.Fatal("the observed block was discarded because an unrelated fleet setting moved") - } -} - func TestObservedAccountBlockPersistsANewerNoticeAtTheStandingWindow(t *testing.T) { now := time.Date(2026, 7, 27, 8, 0, 0, 0, time.UTC) standing := now.Add(time.Hour) @@ -3497,3 +3333,52 @@ func TestObservedAccountBlockDoesNotRollTheNoticeWatermarkBackward(t *testing.T) st.Account.RLCommentID, st.Account.RLCommentUpdated) } } + +// The skip rules are per-repository settings like everything else, and the +// enrollment preview answers with the resolved ones. Reading this host's +// startup configuration instead made the daemon enqueue — and spend the shared +// allowance on — pull requests the dialog had just promised would be skipped. +func TestAutoReviewScanAppliesTheRepositorysOwnSkipAuthors(t *testing.T) { + ctx := context.Background() + cfg := Config{ + GateRepo: "o/gate", + Scope: []string{"o"}, + Host: "h", + Bot: "coderabbitai[bot]", + ReviewCommand: "@coderabbitai review", + LeaderTTL: time.Minute, + AutoReviewMaxScan: 10, + } + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{ + {Repo: "o/app", Number: 1, Author: "renovate[bot]"}, + {Repo: "o/app", Number: 2, Author: "alice"}, + } + for pr := 1; pr <= 2; pr++ { + var pull ghapi.Pull + pull.State = "open" + pull.Head.SHA = "abcdef1234567890" + gh.pulls[fakeKey("o/app", pr)] = pull + } + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + + // Nothing in this host's env skips renovate; the repository's own record + // does, and that is the answer every path has to reach. + if _, err := svc.SetSolver(ctx, "o/app", SolverChange{SkipAuthors: []string{"renovate[bot]"}}); err != nil { + t.Fatal(err) + } + if err := svc.AutoReview(ctx, AutoOptions{Once: true, Incremental: true}); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if st.Round("o/app", 1) != nil { + t.Errorf("a repository-skipped author was enqueued anyway: %#v", st.Rounds) + } + if st.FiredMarker("o/app", 2) == "" { + t.Errorf("the unaffected pull request must still be reviewed, got rounds=%#v", st.Rounds) + } +} diff --git a/internal/crq/sessionlog.go b/internal/crq/sessionlog.go new file mode 100644 index 00000000..3e65fe2e --- /dev/null +++ b/internal/crq/sessionlog.go @@ -0,0 +1,83 @@ +package crq + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// SessionLogTail is the newest part of one autofix session's output. +type SessionLogTail struct { + Text string `json:"text"` + Size int64 `json:"size"` + Truncated bool `json:"truncated"` +} + +// TailSessionLog reads a bounded tail of a log that belongs to repo's crq +// workspace. The caller supplies the path recorded in shared state, so this +// boundary must reject a forged state value that points elsewhere. +func (s *Service) TailSessionLog(ctx context.Context, repo, path string, maxBytes int64) (SessionLogTail, error) { + if err := ctx.Err(); err != nil { + return SessionLogTail{}, err + } + dir, err := s.workspace(ctx).LogDir(repo) + if err != nil { + return SessionLogTail{}, err + } + dir, err = filepath.Abs(dir) + if err != nil { + return SessionLogTail{}, err + } + dir, err = filepath.EvalSymlinks(dir) + if err != nil { + return SessionLogTail{}, fmt.Errorf("resolving session log directory: %w", err) + } + path, err = filepath.Abs(strings.TrimSpace(path)) + if err != nil { + return SessionLogTail{}, err + } + path, err = filepath.EvalSymlinks(path) + if err != nil { + return SessionLogTail{}, fmt.Errorf("resolving session log path: %w", err) + } + rel, err := filepath.Rel(dir, path) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return SessionLogTail{}, errors.New("session log is outside this repository's workspace") + } + if maxBytes <= 0 || maxBytes > 1<<20 { + maxBytes = 128 << 10 + } + root, err := os.OpenRoot(dir) + if err != nil { + return SessionLogTail{}, fmt.Errorf("opening session log directory: %w", err) + } + defer root.Close() + // os.Root resolves each component relative to the opened directory and + // refuses symlinks that escape it. Unlike EvalSymlinks followed by os.Open, + // this keeps the containment check and open in one race-safe operation. + file, err := root.Open(rel) + if err != nil { + return SessionLogTail{}, fmt.Errorf("opening session log: %w", err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return SessionLogTail{}, err + } + start := info.Size() - maxBytes + if start < 0 { + start = 0 + } + if _, err := file.Seek(start, io.SeekStart); err != nil { + return SessionLogTail{}, err + } + body, err := io.ReadAll(io.LimitReader(file, maxBytes)) + if err != nil { + return SessionLogTail{}, err + } + return SessionLogTail{Text: string(body), Size: info.Size(), Truncated: start > 0}, nil +} diff --git a/internal/crq/sessionlog_test.go b/internal/crq/sessionlog_test.go new file mode 100644 index 00000000..e56ee297 --- /dev/null +++ b/internal/crq/sessionlog_test.go @@ -0,0 +1,48 @@ +package crq + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTailSessionLogIsBoundedToTheRepoWorkspace(t *testing.T) { + cfg := firingConfig() + cfg.WorkspaceRoot = t.TempDir() + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + dir, err := svc.workspace(context.Background()).LogDir("o/r") + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "1-head-now.log") + if err := os.WriteFile(path, []byte("old\nnew\n"), 0o600); err != nil { + t.Fatal(err) + } + tail, err := svc.TailSessionLog(context.Background(), "o/r", path, 4) + if err != nil { + t.Fatal(err) + } + if tail.Text != "new\n" || !tail.Truncated || tail.Size != 8 { + t.Fatalf("tail = %+v", tail) + } + outside := filepath.Join(cfg.WorkspaceRoot, "secret") + if err := os.WriteFile(outside, []byte("credential"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := svc.TailSessionLog(context.Background(), "o/r", outside, 100); err == nil || + !strings.Contains(err.Error(), "outside") { + t.Fatalf("outside path error = %v", err) + } + link := filepath.Join(dir, "linked.log") + if err := os.Symlink(outside, link); err != nil { + t.Fatal(err) + } + if _, err := svc.TailSessionLog(context.Background(), "o/r", link, 100); err == nil { + t.Fatal("a session log symlink escaping the repository workspace was opened") + } +} diff --git a/internal/crq/solver.go b/internal/crq/solver.go new file mode 100644 index 00000000..d20fc11e --- /dev/null +++ b/internal/crq/solver.go @@ -0,0 +1,420 @@ +package crq + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" +) + +// SolverView is how one repository's fix sessions will actually be run. +type SolverView struct { + Repo string `json:"repo"` + // Overridden says this repository has its own record rather than following + // the fleet default. + Overridden bool `json:"overridden"` + // Agent is the fleet's, always: it is chosen at install time and baked into + // the session script, because switching agents is a different command line + // rather than a different flag. Reported so a reader knows what the model + // and effort below are being handed to. + Agent string `json:"agent,omitempty"` + + Models []string `json:"models"` + ModelChoices []string `json:"model_choices"` + Model string `json:"model,omitempty"` + Effort string `json:"effort,omitempty"` + Prompt string `json:"prompt,omitempty"` + MaxAttempts int `json:"max_attempts"` + Severities []string `json:"severities"` + AskMode string `json:"ask_mode"` + Forks bool `json:"forks"` + SkipAuthors []string `json:"skip_authors"` + + // Sources says, per setting, whether the value came from this repository's + // record, the fleet default, or this host's env. + Sources map[string]string `json:"sources"` + By string `json:"by,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Lagging []string `json:"lagging_hosts,omitempty"` +} + +// Solver reports how repo's fix sessions will run. +func (s *Service) Solver(ctx context.Context, repo string) (SolverView, error) { + repo = NormalizeRepo(repo) + if err := checkRepoShape(repo); err != nil { + return SolverView{}, err + } + st, _, err := s.store.Load(ctx) + if err != nil { + return SolverView{}, err + } + return s.solverViewOf(st, repo), nil +} + +func (s *Service) solverViewOf(st State, repo string) SolverView { + cfg := s.cfgFor(st, repo) + own, has := st.Solver(repo) + fleet := st.Fleet.Solver + + view := SolverView{ + Repo: repo, Overridden: has && !own.Empty(), + Models: append([]string{}, cfg.FixModels...), + Model: cfg.FixModel, Effort: cfg.FixEffort, Prompt: cfg.FixPrompt, + MaxAttempts: cfg.DispatchMaxAttempts, Forks: cfg.DispatchForks, + Severities: sortedKeys(cfg.FixSeverities), AskMode: cfg.FixAskMode, + SkipAuthors: sortedKeys(cfg.SkipAuthors), + Sources: map[string]string{}, + } + // Three layers, and the view names which one answered — the same + // distinction the fleet settings make, for the same reason: a value showing + // "env" is this host's file, and changing it here starts a record. + source := func(key string, inRepo, inFleet bool) { + switch { + case inRepo: + view.Sources[key] = "repo" + case inFleet: + view.Sources[key] = "fleet" + default: + view.Sources[key] = "env" + } + } + source("models", own.SetModels || len(own.Models) > 0 || own.Model != "", + fleet.SetModels || len(fleet.Models) > 0 || fleet.Model != "") + view.Sources["model"] = view.Sources["models"] + source("effort", own.SetEffort || own.Effort != "", + fleet.SetEffort || fleet.Effort != "") + source("prompt", own.SetPrompt || own.Prompt != "", fleet.SetPrompt || fleet.Prompt != "") + source("max_attempts", own.MaxAttempts != nil, fleet.MaxAttempts != nil) + source("severities", own.SetSeverities || len(own.Severities) > 0, + fleet.SetSeverities || len(fleet.Severities) > 0) + source("ask_mode", own.SetAskMode || own.AskMode != "", + fleet.SetAskMode || fleet.AskMode != "") + source("forks", own.Forks != nil, fleet.Forks != nil) + source("skip_authors", own.SetSkipAuthors, fleet.SetSkipAuthors) + + // This host's own answer when it runs fix sessions, and the fleet's + // self-reports otherwise. The dashboard is normally the second case: the + // agent is exported to the autofix unit alone, so a process serving the page + // has none of its own and reported nothing — which the page then had to guess + // at, and it guessed claude on a fleet fixing with codex. + view.Agent = s.cfg.fixAgent() + if view.Agent == "" { + view.Agent = st.FixAgent(s.clock().UTC()) + } + view.ModelChoices = modelChoicesFor(view.Agent, view.Models) + if has && own.UpdatedAt != nil { + view.By = own.By + view.UpdatedAt = own.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z") + } + // The warning belongs to the SETTING, not to the layer that happens to hold + // it. Asking only about this repository's own record meant a fleet-wide + // model, effort, fork policy or attempt limit — which every repository + // inheriting it is run with — warned about nobody, while an old autofix + // service went on dispatching its install-time values. + // + // The autofix role too, not just the queue's drivers: these settings are + // consumed when a fix session STARTS, and the watcher that starts one holds + // neither the leader lease nor the fire slot. + if (has && own.UpdatedAt != nil) || fleet.UpdatedAt != nil { + view.Lagging = st.LaggingRoleWriters(CapsSolver, s.clock().UTC(), "autofix") + } + return view +} + +// modelChoicesFor keeps agent-specific model vocabulary on the server. The +// browser receives both the supported choices and any already-recorded custom +// value, so a newer/older model is never made uneditable by a binary upgrade. +func modelChoicesFor(agent string, selected []string) []string { + name := strings.ToLower(filepath.Base(agent)) + var known []string + switch name { + case "claude": + known = []string{"opus", "sonnet", "haiku", "fable"} + case "codex": + known = []string{"gpt-5.6-sol", "gpt-5.6-terra", "codex-auto-review"} + default: + known = []string{"gpt-5.6-sol", "gpt-5.6-terra", "codex-auto-review", "opus", "sonnet", "haiku", "fable"} + } + out := make([]string, 0, len(selected)+len(known)) + seen := map[string]bool{} + for _, model := range append(append([]string{}, selected...), known...) { + model = strings.TrimSpace(model) + if model != "" && !seen[model] { + seen[model] = true + out = append(out, model) + } + } + return out +} + +// SolverChange is a proposed edit. Absent fields are left alone, so a form +// posting its whole state cannot clobber a setting changed a second earlier. +type SolverChange struct { + Models []string `json:"models"` + Model *string `json:"model"` + Effort *string `json:"effort"` + Prompt *string `json:"prompt"` + MaxAttempts *int `json:"max_attempts"` + Severities []string `json:"severities"` + AskMode *string `json:"ask_mode"` + Forks *bool `json:"forks"` + SkipAuthors []string `json:"skip_authors"` + // Unset* hands ONE setting back to the layer beneath, the same instruction + // FleetChange's Unset* fields carry. An empty model ranking and an empty + // effort both mean "use the agent default", false is a real fork policy, and + // an empty author list is "skip nobody", so none can also mean inheritance. + UnsetModels bool `json:"unset_models,omitempty"` + UnsetEffort bool `json:"unset_effort,omitempty"` + UnsetPrompt bool `json:"unset_prompt,omitempty"` + UnsetSeverities bool `json:"unset_severities,omitempty"` + UnsetAskMode bool `json:"unset_ask_mode,omitempty"` + UnsetForks bool `json:"unset_forks,omitempty"` + UnsetSkipAuthors bool `json:"unset_skip_authors,omitempty"` + // Clear drops the whole record, returning every setting to the fleet default. + Clear bool `json:"clear"` +} + +// knownEfforts are the reasoning levels every supported agent understands. An +// unknown one is refused rather than passed through: it would reach the agent +// as a flag value, and a session that dies on its first argument is a fix that +// silently never happens. +var knownEfforts = []string{"low", "medium", "high", "xhigh", "max"} +var knownAskModes = []string{"blocked", "uncertain", "ambiguous"} + +// SetSolver records how repo's fix sessions should run. +func (s *Service) SetSolver(ctx context.Context, repo string, change SolverChange) (SolverView, error) { + repo = NormalizeRepo(repo) + if err := checkRepoShape(repo); err != nil { + return SolverView{}, err + } + now := s.clock().UTC() + st, err := s.store.Update(ctx, func(st *State) error { + if change.Clear { + if !st.ClearSolver(repo) { + return ErrNoChange + } + return nil + } + sv, _ := st.Solver(repo) + next, err := applySolverChange(sv, change) + if err != nil { + return err + } + if next.Empty() { + // Setting every field back to nothing IS clearing it: leaving an + // empty record behind would report the repository as overridden + // while overriding nothing. + if !st.ClearSolver(repo) { + return ErrNoChange + } + return nil + } + st.SetSolver(repo, next, s.cfg.Host, now) + return nil + }) + if err != nil && !errors.Is(err, ErrNoChange) { + return SolverView{}, err + } + if err == nil { + s.sync(ctx, st) + } else { + st, _, err = s.store.Load(ctx) + if err != nil { + return SolverView{}, err + } + } + return s.solverViewOf(st, repo), nil +} + +// SetFleetSolver records the fleet-wide default every repository inherits. +func (s *Service) SetFleetSolver(ctx context.Context, change SolverChange) (SolverSettings, error) { + now := s.clock().UTC() + st, err := s.store.Update(ctx, func(st *State) error { + if change.Clear { + if st.Fleet.Solver.Empty() { + return ErrNoChange + } + fd := st.Fleet + fd.Solver = SolverSettings{} + st.SetFleetDefaults(fd, s.cfg.Host, now) + return nil + } + next, err := applySolverChange(st.Fleet.Solver, change) + if err != nil { + return err + } + if next.Empty() { + if st.Fleet.Solver.Empty() { + return ErrNoChange + } + fd := st.Fleet + fd.Solver = SolverSettings{} + st.SetFleetDefaults(fd, s.cfg.Host, now) + return nil + } + at := now + next.By, next.UpdatedAt = s.cfg.Host, &at + fd := st.Fleet + fd.Solver = next + st.SetFleetDefaults(fd, s.cfg.Host, now) + return nil + }) + if err != nil && !errors.Is(err, ErrNoChange) { + return SolverSettings{}, err + } + if err == nil { + s.sync(ctx, st) + } else if st, _, err = s.store.Load(ctx); err != nil { + return SolverSettings{}, err + } + return st.Fleet.Solver, nil +} + +// FleetSolver reports the recorded fleet-wide solver default. +func (s *Service) FleetSolver(ctx context.Context) (SolverSettings, error) { + st, _, err := s.store.Load(ctx) + if err != nil { + return SolverSettings{}, err + } + return st.Fleet.Solver, nil +} + +// applySolverChange folds a change onto a record, validating it. +func applySolverChange(sv SolverSettings, change SolverChange) (SolverSettings, error) { + if change.UnsetModels { + sv.Models, sv.SetModels, sv.Model = nil, false, "" + } else if change.Models != nil { + models := make([]string, 0, len(change.Models)) + seen := map[string]bool{} + for _, model := range change.Models { + model = strings.TrimSpace(model) + if model != "" && !seen[model] { + seen[model] = true + models = append(models, model) + } + } + if len(models) > 8 { + return sv, errors.New("model ranking is limited to 8 entries") + } + sv.Models, sv.SetModels = models, true + sv.Model = "" + if len(models) > 0 { + sv.Model = models[0] + } + } else if change.Model != nil { + // Legacy single-model clients still produce a valid one-entry ranking. + model := strings.TrimSpace(*change.Model) + if model == "" { + sv.Models, sv.SetModels, sv.Model = nil, false, "" + } else { + sv.Models, sv.SetModels, sv.Model = []string{model}, true, model + } + } + if change.UnsetEffort { + sv.Effort, sv.SetEffort = "", false + } else if change.Effort != nil { + effort := strings.ToLower(strings.TrimSpace(*change.Effort)) + if effort != "" && !containsString(knownEfforts, effort) { + return sv, fmt.Errorf("effort %q is not one of %s", effort, strings.Join(knownEfforts, ", ")) + } + sv.Effort, sv.SetEffort = effort, true + } + if change.UnsetPrompt { + sv.Prompt, sv.SetPrompt = "", false + } else if change.Prompt != nil { + prompt := strings.TrimSpace(*change.Prompt) + // The prompt is appended to every fix session's instructions, so a + // runaway one is a runaway cost on every pull request in the repository. + if len(prompt) > 4000 { + return sv, errors.New("extra instructions are limited to 4000 characters — they are appended to every fix session") + } + sv.Prompt, sv.SetPrompt = prompt, true + } + if change.MaxAttempts != nil { + n := *change.MaxAttempts + if n < 0 || n > 20 { + return sv, errors.New("max attempts must be between 0 and 20") + } + if n == 0 { + sv.MaxAttempts = nil // back to inherited + } else { + sv.MaxAttempts = &n + } + } + if change.UnsetSeverities { + sv.Severities, sv.SetSeverities = nil, false + } else if change.Severities != nil { + if len(change.Severities) == 0 { + return sv, errors.New("choose at least one severity, or turn autofix off for the repository") + } + seen := map[string]bool{} + severities := make([]string, 0, len(change.Severities)) + for _, severity := range change.Severities { + severity = strings.ToLower(strings.TrimSpace(severity)) + if !dialect.IsSeverity(severity) { + return sv, fmt.Errorf("severity %q is not one of %s", severity, strings.Join(dialect.KnownSeverities(), ", ")) + } + if !seen[severity] { + seen[severity] = true + severities = append(severities, severity) + } + } + sv.Severities, sv.SetSeverities = severities, true + } + if change.UnsetAskMode { + sv.AskMode, sv.SetAskMode = "", false + } else if change.AskMode != nil { + mode := strings.ToLower(strings.TrimSpace(*change.AskMode)) + if !containsString(knownAskModes, mode) { + return sv, fmt.Errorf("ask mode %q is not one of %s", mode, strings.Join(knownAskModes, ", ")) + } + sv.AskMode, sv.SetAskMode = mode, true + } + if change.UnsetForks { + sv.Forks = nil + } else if change.Forks != nil { + forks := *change.Forks + sv.Forks = &forks + } + if change.UnsetSkipAuthors { + sv.SkipAuthors, sv.SetSkipAuthors = nil, false + } else if change.SkipAuthors != nil { + authors := make([]string, 0, len(change.SkipAuthors)) + for _, a := range change.SkipAuthors { + if a = strings.TrimSpace(a); a != "" { + authors = append(authors, a) + } + } + sort.Strings(authors) + sv.SkipAuthors, sv.SetSkipAuthors = authors, true + } + return sv, nil +} + +func containsString(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} + +func sortedKeys(set map[string]bool) []string { + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// SolverIn answers for an already-loaded state, so a caller rendering many +// repositories does not re-read the ref once per row. +func (s *Service) SolverIn(st State, repo string) SolverView { + return s.solverViewOf(st, NormalizeRepo(repo)) +} diff --git a/internal/crq/solver_test.go b/internal/crq/solver_test.go new file mode 100644 index 00000000..d18e5698 --- /dev/null +++ b/internal/crq/solver_test.go @@ -0,0 +1,333 @@ +package crq + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +// Solver settings exist so two repositories the same watcher handles can be +// fixed differently, so what this pins is the layering and — because these +// values are handed to an agent's command line — the validation that stops a +// session dying on its first argument. +func TestSolverLayering(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.DispatchMaxAttempts = 3 + cfg.DispatchCommand = []string{"/usr/bin/claude"} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + // Nothing recorded: every value is this host's env. + view, err := svc.Solver(ctx, "o/plain") + if err != nil { + t.Fatal(err) + } + if view.Overridden || view.MaxAttempts != 3 || view.Model != "" { + t.Fatalf("view = %+v, want the env values with no record", view) + } + if view.Agent != "/usr/bin/claude" { + t.Errorf("agent = %q, want the fleet's — it is baked into the session script", view.Agent) + } + raw, err := json.Marshal(view) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"models":[]`) { + t.Fatalf("empty model ranking must be a JSON array: %s", raw) + } + if got := strings.Join(view.ModelChoices, ","); got != "opus,sonnet,haiku,fable" { + t.Errorf("model choices = %q, want the server-owned Claude vocabulary", got) + } + + // A fleet default reaches every repository. + if _, err := svc.SetFleetSolver(ctx, SolverChange{Effort: strptr("medium")}); err != nil { + t.Fatal(err) + } + if v, _ := svc.Solver(ctx, "o/plain"); v.Effort != "medium" || v.Sources["effort"] != "fleet" { + t.Errorf("view = %+v, want the fleet default applied and named", v) + } + if fleet, err := svc.FleetSolver(ctx); err != nil || fleet.Effort != "medium" { + t.Fatalf("fleet solver = %+v, err %v, want the recorded solver default", fleet, err) + } + + // Empty is a stated effort too: it asks the agent to choose, rather than + // inheriting a nonempty fleet answer. + if _, err := svc.SetSolver(ctx, "o/default-effort", SolverChange{Effort: strptr("")}); err != nil { + t.Fatal(err) + } + if v, _ := svc.Solver(ctx, "o/default-effort"); v.Effort != "" || v.Sources["effort"] != "repo" { + t.Errorf("view = %+v, want the repository's explicit agent default", v) + } + + // A repository's own record wins over it, field by field: setting the model + // here must not discard the fleet's effort. + if _, err := svc.SetSolver(ctx, "o/special", SolverChange{ + Model: strptr("opus"), MaxAttempts: intptr(5), + }); err != nil { + t.Fatal(err) + } + v, _ := svc.Solver(ctx, "o/special") + if v.Model != "opus" || v.Sources["model"] != "repo" { + t.Errorf("model = %q from %q, want the repository's own", v.Model, v.Sources["model"]) + } + if v.Effort != "medium" || v.Sources["effort"] != "fleet" { + t.Errorf("effort = %q from %q, want the fleet default still showing through", v.Effort, v.Sources["effort"]) + } + if v.MaxAttempts != 5 { + t.Errorf("attempts = %d, want the repository's 5 over the env's 3", v.MaxAttempts) + } + + // And the resolved config is what a dispatch would actually run with. + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if got := svc.cfgFor(st, "o/special"); got.FixModel != "opus" || got.DispatchMaxAttempts != 5 { + t.Errorf("cfg = model %q attempts %d, want the record applied", got.FixModel, got.DispatchMaxAttempts) + } + if _, err := svc.SetSolver(ctx, "o/special", SolverChange{ + Models: []string{"opus", "sonnet", "opus"}, + }); err != nil { + t.Fatal(err) + } + v, _ = svc.Solver(ctx, "o/special") + if got := strings.Join(v.Models, ","); got != "opus,sonnet" { + t.Errorf("ranked models = %q, want ordered and deduplicated", got) + } + st, _, err = store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if got := svc.cfgFor(st, "o/special"); strings.Join(got.FixModels, ",") != "opus,sonnet" { + t.Errorf("dispatch config models = %v, want the ranking", got.FixModels) + } + if got := svc.cfgFor(st, "o/plain"); got.DispatchMaxAttempts != 3 { + t.Errorf("attempts = %d, want another repository unaffected", got.DispatchMaxAttempts) + } + + // A value the agent would reject is refused here instead. + if _, err := svc.SetSolver(ctx, "o/special", SolverChange{Effort: strptr("turbo")}); err == nil { + t.Error("an unknown effort must be refused before it reaches a command line") + } + if _, err := svc.SetSolver(ctx, "o/special", SolverChange{Prompt: strptr(strings.Repeat("x", 4001))}); err == nil { + t.Error("an unbounded standing prompt must be refused — it is appended to every session") + } + if _, err := svc.SetSolver(ctx, "o/special", SolverChange{MaxAttempts: intptr(99)}); err == nil { + t.Error("an absurd attempt budget must be refused") + } + ask := "uncertain" + if _, err := svc.SetSolver(ctx, "o/special", SolverChange{ + Severities: []string{"minor", "minor"}, AskMode: &ask, + }); err != nil { + t.Fatal(err) + } + if v, _ := svc.Solver(ctx, "o/special"); strings.Join(v.Severities, ",") != "minor" || + v.AskMode != "uncertain" || v.Sources["severities"] != "repo" || v.Sources["ask_mode"] != "repo" { + t.Errorf("policy view = %+v, want the repository's minor-only, ask-when-uncertain policy", v) + } + badAsk := "whenever" + if _, err := svc.SetSolver(ctx, "o/special", SolverChange{AskMode: &badAsk}); err == nil { + t.Error("an unknown clarification threshold must be refused") + } + if _, err := svc.SetSolver(ctx, "o/special", SolverChange{Severities: []string{}}); err == nil { + t.Error("an empty severity policy must direct the operator to the autofix switch") + } + + // Emptying every field clears the record rather than leaving one that + // overrides nothing. + if _, err := svc.SetSolver(ctx, "o/special", SolverChange{ + Model: strptr(""), MaxAttempts: intptr(0), UnsetSeverities: true, UnsetAskMode: true, + }); err != nil { + t.Fatal(err) + } + if v, _ := svc.Solver(ctx, "o/special"); v.Overridden { + t.Error("a record with nothing in it must not report the repository as overridden") + } +} + +func TestAbsentSolverPromptKeepsHostPrompt(t *testing.T) { + cfg := firingConfig() + cfg.FixPrompt = "standing host instructions" + + if got := cfg.withSolver(SolverSettings{}).FixPrompt; got != cfg.FixPrompt { + t.Fatalf("prompt = %q, want host prompt %q when shared state says nothing", got, cfg.FixPrompt) + } + if got := cfg.withSolver(SolverSettings{Prompt: "fleet instructions"}).FixPrompt; got != "fleet instructions" { + t.Fatalf("prompt = %q, want the shared solver prompt", got) + } +} + +func TestExplicitEmptyRepoPromptOverridesFleetPrompt(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if _, err := svc.SetFleetSolver(ctx, SolverChange{Prompt: strptr("fleet instructions")}); err != nil { + t.Fatal(err) + } + view, err := svc.SetSolver(ctx, "o/repo", SolverChange{Prompt: strptr("")}) + if err != nil { + t.Fatal(err) + } + if view.Prompt != "" || view.Sources["prompt"] != "repo" { + t.Fatalf("prompt = %q from %q, want an explicit empty repository prompt", + view.Prompt, view.Sources["prompt"]) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if got := svc.cfgFor(st, "o/repo").FixPrompt; got != "" { + t.Fatalf("fix session prompt = %q, want repository to clear the fleet prompt", got) + } +} + +func TestRepositoryPromptCanReturnToInheritance(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + + if _, err := svc.SetFleetSolver(ctx, SolverChange{Prompt: strptr("fleet instructions")}); err != nil { + t.Fatal(err) + } + if _, err := svc.SetSolver(ctx, "o/repo", SolverChange{Prompt: strptr("")}); err != nil { + t.Fatal(err) + } + if _, err := svc.SetSolver(ctx, "o/repo", SolverChange{UnsetPrompt: true}); err != nil { + t.Fatal(err) + } + if view, _ := svc.Solver(ctx, "o/repo"); view.Prompt != "fleet instructions" || view.Sources["prompt"] != "fleet" { + t.Fatalf("view = %+v, want the inherited fleet prompt", view) + } +} + +// The hosts that will ignore a solver setting have to be named wherever it was +// recorded. Asking only about a repository's own record meant a FLEET model or +// attempt limit — the one every repository inherits — warned about nobody, +// while an old autofix service went on dispatching its install-time values. +func TestSolverNamesLaggingHostsForAFleetRecordToo(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + now := svc.clock().UTC() + if _, err := store.Update(ctx, func(st *State) error { + st.SetHostReport(HostReport{ + Host: "atlas", Caps: CapsSolver - 1, Roles: []string{"autofix"}, + }, now) + return nil + }); err != nil { + t.Fatal(err) + } + + // Nothing recorded anywhere: there is no setting to be ignored. + if v, _ := svc.Solver(ctx, "o/plain"); len(v.Lagging) != 0 { + t.Errorf("lagging = %v, want nobody named when no record exists", v.Lagging) + } + + if _, err := svc.SetFleetSolver(ctx, SolverChange{Model: strptr("opus")}); err != nil { + t.Fatal(err) + } + v, err := svc.Solver(ctx, "o/plain") + if err != nil { + t.Fatal(err) + } + if v.Sources["model"] != "fleet" { + t.Fatalf("model source = %q, want the fleet default answering", v.Sources["model"]) + } + if len(v.Lagging) != 1 || v.Lagging[0] != "atlas" { + t.Errorf("lagging = %v, want the autofix host that predates solver settings named", v.Lagging) + } +} + +// Which agent the fleet fixes with is a per-machine install answer, exported to +// the autofix unit alone. A dashboard process has none of its own, and guessing +// one made a codex fleet check every host for claude and report the setup that +// works as the one that is missing. +func TestSolverAgentComesFromTheHostsThatRunSessions(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() // no fix agent: this is the serve process + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if v, _ := svc.Solver(ctx, "o/plain"); v.Agent != "" { + t.Errorf("agent = %q, want silence from a process that has not been told", v.Agent) + } + + now := svc.clock().UTC() + if _, err := store.Update(ctx, func(st *State) error { + st.SetHostReport(HostReport{ + Host: "atlas", Caps: WriterCaps, Roles: []string{"autofix"}, Agent: "codex", + }, now) + return nil + }); err != nil { + t.Fatal(err) + } + if v, _ := svc.Solver(ctx, "o/plain"); v.Agent != "codex" { + t.Errorf("agent = %q, want the one the autofix host reports", v.Agent) + } + + // The serve heartbeat on that same machine says nothing about the agent, + // and silence must not erase what the autofix service reported. + if _, err := store.Update(ctx, func(st *State) error { + st.SetHostReport(HostReport{Host: "atlas", Caps: WriterCaps, Roles: []string{"serve"}}, now) + return nil + }); err != nil { + t.Fatal(err) + } + if v, _ := svc.Solver(ctx, "o/plain"); v.Agent != "codex" { + t.Errorf("agent = %q, want the autofix service's answer kept", v.Agent) + } +} + +// The fork policy and the skip list are the two solver settings whose own +// values cannot express "follow the fleet again": false is a real fork policy +// and an empty author list means "skip nobody". Without an unset instruction a +// repository that had set either could only rejoin the fleet by clearing every +// solver setting it had, prompt included. +func TestSolverFieldsCanReturnToInheritance(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if _, err := svc.SetFleetSolver(ctx, SolverChange{ + Effort: strptr("high"), Forks: boolptr(true), SkipAuthors: []string{"dependabot[bot]"}, + }); err != nil { + t.Fatal(err) + } + if _, err := svc.SetSolver(ctx, "o/repo", SolverChange{ + Effort: strptr(""), Prompt: strptr("this project uses bun"), + Forks: boolptr(false), SkipAuthors: []string{}, + }); err != nil { + t.Fatal(err) + } + v, _ := svc.Solver(ctx, "o/repo") + if v.Effort != "" || v.Sources["effort"] != "repo" || + v.Forks || v.Sources["forks"] != "repo" || v.Sources["skip_authors"] != "repo" { + t.Fatalf("view = %+v, want all three settings held by the repository", v) + } + + if _, err := svc.SetSolver(ctx, "o/repo", SolverChange{ + UnsetEffort: true, UnsetForks: true, UnsetSkipAuthors: true, + }); err != nil { + t.Fatal(err) + } + v, _ = svc.Solver(ctx, "o/repo") + if v.Effort != "high" || v.Sources["effort"] != "fleet" { + t.Errorf("effort = %q from %q, want the fleet's answer back", v.Effort, v.Sources["effort"]) + } + if !v.Forks || v.Sources["forks"] != "fleet" { + t.Errorf("forks = %v from %q, want the fleet's answer back", v.Forks, v.Sources["forks"]) + } + if len(v.SkipAuthors) != 1 || v.Sources["skip_authors"] != "fleet" { + t.Errorf("skip authors = %v from %q, want the fleet's list back", v.SkipAuthors, v.Sources["skip_authors"]) + } + if v.Prompt != "this project uses bun" { + t.Errorf("prompt = %q, want the settings it was not asked about left alone", v.Prompt) + } +} diff --git a/internal/crq/state.go b/internal/crq/state.go index 210344c9..4649da59 100644 --- a/internal/crq/state.go +++ b/internal/crq/state.go @@ -14,24 +14,28 @@ import ( // the package qualifier, and without colliding with the many `state`/`st` // variable names in this package. type ( - State = crqstate.State - Round = crqstate.Round - Phase = crqstate.Phase - FireSlot = crqstate.FireSlot - AccountQuota = crqstate.AccountQuota - LeaderLease = crqstate.LeaderLease - PostedCommand = crqstate.PostedCommand - RepoReviewers = crqstate.RepoReviewers - Revision = crqstate.Revision - StateStore = crqstate.StateStore - StoreConfig = crqstate.StoreConfig + State = crqstate.State + Round = crqstate.Round + Phase = crqstate.Phase + FireSlot = crqstate.FireSlot + AccountQuota = crqstate.AccountQuota + LeaderLease = crqstate.LeaderLease + PostedCommand = crqstate.PostedCommand + RepoReviewers = crqstate.RepoReviewers + RepoEnrollment = crqstate.RepoEnrollment + FleetDefaults = crqstate.FleetDefaults + SolverSettings = crqstate.SolverSettings + CoBotRound = crqstate.CoBotRound + HostReport = crqstate.HostReport + ToolReport = crqstate.ToolReport + Revision = crqstate.Revision + StateStore = crqstate.StateStore + StoreConfig = crqstate.StoreConfig LeaderCapabilityLease = crqstate.LeaderCapabilityLease // RepoAutofixSwitch is one repository's answer to whether crq may fix it. RepoAutofixSwitch = crqstate.RepoAutofixSwitch - - // RepoReviewers is the per-repository reviewer override (see Config.ForRepo). ) const ( @@ -50,10 +54,20 @@ const ( AutofixUnhealthyAfter = crqstate.AutofixUnhealthyAfter // CapsRepoOverrides is the binary capability per-repo reviewer overrides need. CapsRepoOverrides = crqstate.CapsRepoOverrides - // CapsFleetPolicy is the binary capability state-backed fleet policy needs. - CapsFleetPolicy = crqstate.CapsFleetPolicy - // WriterCaps is what THIS binary understands of the shared state. + // CapsPrimaryOff is the capability a host needs to honour a repository that + // turned the metered primary off. + CapsPrimaryOff = crqstate.CapsPrimaryOff + // CapsEnrollment is the capability a host needs to honour enrollment records. + CapsEnrollment = crqstate.CapsEnrollment + // CapsFleetDefaults is the capability a host needs to honour fleet defaults. + CapsFleetDefaults = crqstate.CapsFleetDefaults + // CapsSolver is the capability a host needs to honour solver settings. + CapsSolver = crqstate.CapsSolver + // WriterCaps is what THIS binary understands, recorded in each host's + // self-report so a fleet can see why a host ignores a setting. WriterCaps = crqstate.WriterCaps + // HostReportTTL is how long a host's self-report counts as current. + HostReportTTL = crqstate.HostReportTTL ) var ( @@ -70,8 +84,11 @@ func (c Config) storeConfig() StoreConfig { Timezone: c.Timezone, Scope: c.Scope, CoReviewers: c.coReviewerSummary(), - Host: c.WriterID(), - MinInterval: c.MinInterval, + ResolveCoReviewers: func(fleet FleetDefaults) string { + return c.WithFleet(fleet).coReviewerSummary() + }, + Host: c.WriterID(), + MinInterval: c.MinInterval, } } @@ -97,18 +114,13 @@ func (c Config) coReviewerSummary() string { return strings.Join(parts, " · ") } -// NewGitStateStore builds the git-ref-backed store. The logger surfaces the -// loud auto-reinit line when a stale-schema payload is loaded. -// -// The store renders dashboard.md on every state commit, and it renders it with -// the fleet's policy rather than this host's startup settings — otherwise the -// file in the state ref would carry whichever co-reviewer set the writing -// machine happened to be configured with, while the gate issue shows the -// fleet's. +// NewGitStateStore builds the git-ref-backed store. Dashboard rendering resolves +// the fleet record from the state being written rather than using this host's +// frozen startup configuration. func NewGitStateStore(cfg Config, gh *ghapi.GitHub, log Logger) *crqstate.GitStateStore { store := crqstate.NewGitStateStore(cfg.storeConfig(), gh, log) store.SetRenderConfig(func(st State) StoreConfig { - return applyFleet(cfg, st.FleetConfig, nil).storeConfig() + return cfg.WithFleet(st.Fleet).storeConfig() }) return store } @@ -127,19 +139,19 @@ func DefaultState(cfg Config) State { } func renderDashboard(st State, cfg Config) string { - return crqstate.RenderDashboard(st, cfg.storeConfig()) + return crqstate.RenderDashboard(st, cfg.WithFleet(st.Fleet).storeConfig()) } func renderTitle(st State, cfg Config) string { - return crqstate.RenderTitle(st, cfg.storeConfig()) + return crqstate.RenderTitle(st, cfg.WithFleet(st.Fleet).storeConfig()) } // StatusLine renders the queue as a single line for a harness status bar. func StatusLine(st State, cfg Config) string { - return crqstate.StatusLine(st, cfg.storeConfig()) + return crqstate.StatusLine(st, cfg.WithFleet(st.Fleet).storeConfig()) } func issueBody(st State, cfg Config) (string, error) { - return crqstate.IssueBody(st, cfg.storeConfig()) + return crqstate.IssueBody(st, cfg.WithFleet(st.Fleet).storeConfig()) } // policy assembles the engine Policy from config. @@ -147,6 +159,7 @@ func (c Config) policy() engine.Policy { p := engine.Policy{ Bot: c.Bot, RequiredBots: c.RequiredBots, + PrimaryOff: c.PrimaryOff, MinInterval: c.MinInterval, InflightTimeout: c.InflightTimeout, RateLimitFallback: c.RateLimitFallback, diff --git a/internal/crq/threads.go b/internal/crq/threads.go index e2d8581c..b56dd066 100644 --- a/internal/crq/threads.go +++ b/internal/crq/threads.go @@ -36,12 +36,6 @@ type OpenThread struct { // // This is the read that closes that loop: list, decide, `crq resolve`. func (s *Service) OpenThreads(ctx context.Context, repo string, pr int) ([]OpenThread, error) { - repo = NormalizeRepo(repo) - // The same reviewer set `crq feedback` reads findings with. A login the - // fleet or this repository added is a bot there, and reading threads from - // this host's own configuration instead reported its unresolved threads as - // human comments — the one output an agent uses to decide whether a thread - // needs a person. st, _, err := s.store.Load(ctx) if err != nil { return nil, err @@ -68,7 +62,11 @@ func (s *Service) OpenThreads(ctx context.Context, repo string, pr int) ([]OpenT } else { open.Author = first.Author.Login } - open.Title = dialect.ThreadTitle(isBot, first.Body) + if isBot { + open.Title = dialect.ReviewTitleFor(first.Author.Login, first.Body) + } else { + open.Title = dialect.ThreadTitle(false, first.Body) + } open.URL = first.URL if open.Path == "" { open.Path = first.Path diff --git a/internal/crq/threads_test.go b/internal/crq/threads_test.go index caacf87b..d1d2b1fb 100644 --- a/internal/crq/threads_test.go +++ b/internal/crq/threads_test.go @@ -89,10 +89,7 @@ func TestOpenThreadsNamesAConfiguredFeedbackBot(t *testing.T) { } } -// The same, for a reviewer only the FLEET names. `crq feedback` already reads -// its findings through the effective configuration, so listing its thread as a -// human's would have the two commands disagree about the same thread. -func TestOpenThreadsNamesAFleetFeedbackBot(t *testing.T) { +func TestOpenThreadsUsesFleetConfiguredFeedbackBots(t *testing.T) { gh := newFakeGitHub() gh.graphQL = func(query string, _ map[string]any, out any) error { if !strings.Contains(query, "reviewThreads") { @@ -101,30 +98,28 @@ func TestOpenThreadsNamesAFleetFeedbackBot(t *testing.T) { return json.Unmarshal([]byte(`{"repository":{"pullRequest":{"reviewThreads":{ "pageInfo":{"hasNextPage":false,"endCursor":""}, "nodes":[{"id":"T_fleet","isResolved":false,"isOutdated":false,"path":"a.go","line":1, - "comments":{"totalCount":1,"nodes":[{"body":"### Nil deref\n\n**High Severity**\n\nDetail.", - "author":{"login":"reviewdog[bot]"}}]}}] + "comments":{"totalCount":1,"nodes":[{"body":"### Fleet finding","author":{"login":"reviewdog[bot]"}}]}}] }}}}`), out) } - ctx := context.Background() - cfg := firingConfig() + cfg, err := BuildConfig(map[string]string{"CRQ_REPO": "o/gate", "CRQ_ISSUE": "1"}) + if err != nil { + t.Fatal(err) + } store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("feedback-bots", "coderabbitai[bot],reviewdog[bot]") + if _, err := store.Update(context.Background(), func(st *State) error { + st.Fleet.Env = map[string]string{"CRQ_FEEDBACK_BOTS": "reviewdog[bot]"} return nil }); err != nil { t.Fatal(err) } svc := NewService(cfg, gh, store, nil) - threads, err := svc.OpenThreads(ctx, "o/r", 1) + threads, err := svc.OpenThreads(context.Background(), "o/r", 1) if err != nil { t.Fatal(err) } - if len(threads) != 1 { - t.Fatalf("got %d threads, want the fleet reviewer's", len(threads)) - } - if threads[0].Bot != "reviewdog" || threads[0].Author != "" { - t.Errorf("thread = %+v, want the fleet's feedback bot named as a bot", threads[0]) + if len(threads) != 1 || threads[0].Bot != "reviewdog" || threads[0].Author != "" { + t.Fatalf("thread = %+v, want the fleet-configured reviewer named as a bot", threads) } } diff --git a/internal/crq/tidy.go b/internal/crq/tidy.go index 96cecd02..47cad227 100644 --- a/internal/crq/tidy.go +++ b/internal/crq/tidy.go @@ -67,11 +67,9 @@ func (s *Service) Tidy(ctx context.Context, repo string, pr int, dryRun bool) (T return result, err } // Which comments count as a trigger depends on who reviews, so the whole - // path takes a configuration value rather than reading the Service's — the - // effective one, fleet policy and this repository's override included. - // Reading this host's own would compare the comments against a command crq - // never posted, read every spent trigger as edited by hand, and keep them on - // the PR for ever. + // path takes a configuration value rather than reading the Service's. That + // is what lets per-repo reviewers substitute one here later without + // threading anything new through. cfg := s.cfgFor(st, repo) observedPosted := collectPosted(st, repo, pr) if len(observedPosted.commands) == 0 { @@ -98,6 +96,7 @@ func (s *Service) Tidy(ctx context.Context, repo string, pr int, dryRun bool) (T if err != nil { return result, err } + cfg = s.cfgFor(st, repo) posted := collectPosted(st, repo, pr) // A deleted comment stays on its round for ever, so without this every later @@ -645,7 +644,7 @@ func eventAt(e dialect.BotEvent) time.Time { // // It is best-effort by design. Deleting a comment is housekeeping, and a // housekeeping failure must never break the pass that did the real work. -func (s *Service) tidyAfterPump(ctx context.Context, res PumpResult) error { +func (s *Service) tidyAfterPump(ctx context.Context, st State, res PumpResult) error { if res.Repo == "" || res.PR == 0 { return nil } @@ -668,7 +667,7 @@ func (s *Service) tidyAfterPump(ctx context.Context, res PumpResult) error { default: return nil } - return s.tidyProgressed(ctx, res.Repo, res.PR) + return s.tidyProgressed(ctx, st, res.Repo, res.PR) } // tidyProgressed runs a tidy pass for one PR whose round just moved, and @@ -676,8 +675,13 @@ func (s *Service) tidyAfterPump(ctx context.Context, res PumpResult) error { // housekeeping failure must never break the pass that did the real work. // GitHub throttles are returned so autoreview can sleep through the reset // window instead of continuing to spend requests that are expected to fail. -func (s *Service) tidyProgressed(ctx context.Context, repo string, pr int) error { - if !s.cfg.Tidy { +// +// The switch is resolved from the snapshot the pump decided on, not from the env +// this process started with: CRQ_TIDY is offered as a fleet setting, and reading +// the startup value made saving it in the dashboard change nothing on any daemon +// while the page reported fleet-wide tidying as on. +func (s *Service) tidyProgressed(ctx context.Context, st State, repo string, pr int) error { + if !s.cfgFor(st, repo).Tidy { return nil } if _, err := s.Tidy(ctx, repo, pr, false); err != nil { diff --git a/internal/crq/tidy_test.go b/internal/crq/tidy_test.go index 9c958b3d..8918b586 100644 --- a/internal/crq/tidy_test.go +++ b/internal/crq/tidy_test.go @@ -3,7 +3,6 @@ package crq import ( "context" "encoding/json" - "strings" "testing" "time" @@ -366,59 +365,6 @@ func TestTidyKeepsATriggerEditedDuringThePass(t *testing.T) { } } -// The command crq POSTED is the fleet's, so that is the one a trigger comment -// has to be compared against. Reading this host's own startup configuration -// instead found no such command, read every spent co-reviewer trigger as edited -// by hand, and kept them on the PR for ever. -func TestTidyRecognisesATriggerTheFleetConfigured(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - gh := newFakeGitHub() - gh.graphQL = noForcePush - now := time.Now().UTC() - repo, pr := "o/r", 27 - const command = "bugbot run please" - - var pull ghapi.Pull - pull.State = "open" - pull.Head.SHA = "bbbbbbbb2" - gh.pulls[fakeKey(repo, pr)] = pull - gh.commits["bbbbbbbb2"] = commitAt(now.Add(-10 * time.Minute)) - - c := ghapi.IssueComment{ID: 100, Body: command, - CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now.Add(-2 * time.Hour)} - c.User.Login = "kristofferR" - gh.comments[fakeKey(repo, pr)] = []ghapi.IssueComment{c} - - store := NewMemoryStore(cfg) - svc := NewService(cfg, gh, store, nil) - if _, err := store.Update(ctx, func(st *State) error { - // The fleet enables a co-reviewer this host does not run, with a command - // only the fleet knows. - st.SetFleetValue("cobots", "bugbot") - st.SetFleetValue("cobot-bugbot-cmd", command) - return completedRound(st, repo, pr, now, func(r *Round) error { - if err := r.Fire(0, now.Add(-2*time.Hour)); err != nil { - return err - } - r.RecordPosted("cursor[bot]", c.ID, c.CreatedAt) - return nil - }) - }); err != nil { - t.Fatal(err) - } - - result, err := svc.Tidy(ctx, repo, pr, true) - if err != nil { - t.Fatal(err) - } - for _, kept := range result.Kept { - if strings.Contains(kept, "someone edited it") { - t.Fatalf("the fleet's own trigger read as an edited comment: %v", result.Kept) - } - } -} - // A delete GitHub refuses must reach the caller. An empty Deleted otherwise // reads as "nothing was spent" when it means "the token may not delete". func TestTidyReportsDeletionFailures(t *testing.T) { @@ -483,7 +429,7 @@ func TestTidyReportsDeletionFailures(t *testing.T) { gh.getComment = func(string, int64) (ghapi.IssueComment, error) { return ghapi.IssueComment{}, throttle } - if err := svc.tidyProgressed(ctx, repo, pr); !ghapi.IsThrottled(err) { + if err := svc.tidyProgressed(ctx, st, repo, pr); !ghapi.IsThrottled(err) { t.Fatalf("tidyProgressed error = %v, want the pre-delete read throttle", err) } st, _, err = store.Load(ctx) @@ -890,14 +836,18 @@ func TestTidyAfterPumpSkipsAPumpThatChangedNothing(t *testing.T) { t.Fatal(err) } - if err := svc.tidyAfterPump(ctx, PumpResult{Action: "waiting", Repo: repo, PR: pr, Reason: "review in progress"}); err != nil { + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if err := svc.tidyAfterPump(ctx, st, PumpResult{Action: "waiting", Repo: repo, PR: pr, Reason: "review in progress"}); err != nil { t.Fatal(err) } if reads := gh.reviewPolls(); reads != 0 { t.Fatalf("a no-progress pump observed the pr %d time(s); the pump had just read it", reads) } // The same PR, once something actually moved. - if err := svc.tidyAfterPump(ctx, PumpResult{Action: "cleared", Repo: repo, PR: pr}); err != nil { + if err := svc.tidyAfterPump(ctx, st, PumpResult{Action: "cleared", Repo: repo, PR: pr}); err != nil { t.Fatal(err) } if gh.reviewPolls() == 0 { @@ -1041,6 +991,59 @@ func TestFeedbackSkipsReactionsForTidiedCommand(t *testing.T) { } } +func TestTidyUsesFleetResolvedConfiguration(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", "CRQ_COBOTS": "", + "CRQ_REVIEW_CMD": "@host review", "CRQ_TIDY": "0", + }) + gh := newFakeGitHub() + gh.graphQL = noForcePush + now := time.Now().UTC() + repo, pr, head := "o/r", 24, "cccccccc33333333" + const commandBody = "@fleet review" + + pull := ghapi.Pull{State: "open"} + pull.Head.SHA = head + gh.pulls[fakeKey(repo, pr)] = pull + gh.commits[head] = commitAt(now.Add(-10 * time.Minute)) + command := ghapi.IssueComment{ + ID: 200, Body: commandBody, + CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now.Add(-2 * time.Hour), + } + command.User.Login = "kristofferR" + gh.comments[fakeKey(repo, pr)] = []ghapi.IssueComment{command} + review := ghapi.Review{ + ID: 201, CommitID: head, State: "COMMENTED", + SubmittedAt: now.Add(-time.Minute), Body: "review complete", + } + review.User.Login = cfg.Bot + gh.reviews[fakeKey(repo, pr)] = []ghapi.Review{review} + + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + st, err := store.Update(ctx, func(st *State) error { + st.Fleet = fleetEnvSet(st.Fleet, "CRQ_TIDY", "1", false) + st.Fleet = fleetEnvSet(st.Fleet, "CRQ_REVIEW_CMD", commandBody, false) + return completedRound(st, repo, pr, now, func(r *Round) error { + if err := r.Fire(command.ID, command.CreatedAt); err != nil { + return err + } + r.RecordPosted(cfg.Bot, command.ID, command.CreatedAt) + return nil + }) + }) + if err != nil { + t.Fatal(err) + } + if err := svc.tidyProgressed(ctx, st, repo, pr); err != nil { + t.Fatal(err) + } + if len(gh.deleted) != 1 || gh.deleted[0] != command.ID { + t.Fatalf("deleted = %v, want the command recognized under fleet settings", gh.deleted) + } +} + // completedRound builds a round that fired (via fire) and then completed, which // is the "has progressed" precondition every tidy candidate needs. func completedRound(st *State, repo string, pr int, now time.Time, fire func(*Round) error) error { diff --git a/internal/crq/wait.go b/internal/crq/wait.go index a7e838e6..0ffc7a1d 100644 --- a/internal/crq/wait.go +++ b/internal/crq/wait.go @@ -128,7 +128,7 @@ func (s *Service) WaitForAction(ctx context.Context, repo string, pr int) (NextR continue } - deadline := now.Add(maxStaleFactor * s.cfg.LeaderTTL) + deadline := now.Add(maxStaleFactor * s.leaderTTL(st)) if report.RecheckAfter != nil && report.RecheckAfter.Before(deadline) { deadline = *report.RecheckAfter } diff --git a/internal/crq/watch.go b/internal/crq/watch.go index 150dfdb1..1e9d4b4a 100644 --- a/internal/crq/watch.go +++ b/internal/crq/watch.go @@ -1,6 +1,7 @@ package crq import ( + "bufio" "context" "encoding/json" "errors" @@ -10,11 +11,13 @@ import ( "os/exec" "path/filepath" "sort" + "strconv" "strings" "sync" "sync/atomic" "time" + "github.com/kristofferR/coderabbit-queue/internal/dialect" "github.com/kristofferR/coderabbit-queue/internal/engine" ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" "github.com/kristofferR/coderabbit-queue/internal/workspace" @@ -81,12 +84,9 @@ type WatchEvent struct { // does, and why it is off unless asked for. // // Every dispatch is claimed under CAS, so two watchers cannot both spawn a -// session for one PR, and bounded per head, so a fix that keeps not working -// stops instead of spending a review round each time. +// session for one PR, and bounded in cooldown-backed cycles, so a fix that keeps +// not working neither spins nor permanently dead-letters the head. func (s *Service) Watch(ctx context.Context, opts WatchOptions, emit func(WatchEvent) error) error { - if opts.Interval <= 0 { - opts.Interval = s.cfg.WatchInterval - } if opts.MaxAttempts <= 0 { opts.MaxAttempts = s.cfg.DispatchMaxAttempts } @@ -98,6 +98,9 @@ func (s *Service) Watch(ctx context.Context, opts WatchOptions, emit func(WatchE on := true opts.Dispatch = &on } + // The watcher's PATH is the one a fix session inherits, so its report is the + // one that answers "can this host actually run the agent". + s.ReportHost(ctx, "autofix") if *opts.Dispatch && len(opts.Command) == 0 { opts.Command = s.cfg.DispatchCommand } @@ -144,7 +147,14 @@ func (s *Service) Watch(ctx context.Context, opts WatchOptions, emit func(WatchE defer pool.wait() defer endSessions() for { + // Resolved per pass, not once at startup: the interval is fleet-settable + // from the dashboard, and a watcher that read it when the service booted + // reported the new cadence while still running on the old one until + // somebody restarted it. This run's own --interval still wins. wait := opts.Interval + if wait <= 0 { + wait = s.watchInterval(ctx) + } if err := s.watchPass(sessionCtx, opts, pool, emit); err != nil { reset, throttled := ghapi.ThrottleWait(err) if !throttled { @@ -177,53 +187,62 @@ func (s *Service) Watch(ctx context.Context, opts WatchOptions, emit func(WatchE } } -// watchesRepo reports whether the policy in hand still covers this repository, -// the same two questions the pass asked when it gathered its candidates: -// `exclude` means "crq does not go here" everywhere, and the fleet's repository -// list decides the set whenever the pass had to build one. Repositories named on -// the command line are the operator's own request and outrank the list — but not -// the exclusion, exactly as at the top of the pass. -// -// An emptied list is not "watch everything" here. Unlike autoReviewPass, which -// falls back to scanning by scope, a pass with no repositories of its own has -// nothing to watch at all and refuses to run — so a candidate gathered under the -// old list is one the live policy no longer covers, and treating the empty list -// as permissive would dispatch a fix for a repository the fleet just dropped. -func watchesRepo(cfg Config, explicit []string, repo string) bool { - key := NormalizeRepo(repo) - if cfg.ExcludeRepos[key] { - return false - } - if len(explicit) > 0 { - return true +// watchInterval is the pass cadence with the fleet's recorded default applied, +// falling back to this host's env when the ref cannot be read — a watcher must +// not stop pacing itself because one load failed. +func (s *Service) watchInterval(ctx context.Context) time.Duration { + if st, _, err := s.store.Load(ctx); err == nil { + if d := s.cfg.WithFleet(st.Fleet).WatchInterval; d > 0 { + return d + } } - return cfg.AllowRepos[key] + return s.cfg.WatchInterval } func (s *Service) watchPass(ctx context.Context, opts WatchOptions, pool *dispatchPool, emit func(WatchEvent) error) error { var failures []string - state, _, err := s.store.Load(ctx) - if err != nil { - return err - } - cfg := s.fleetCfg(state) type pendingEvent struct { event WatchEvent result <-chan dispatchResult } var pending []pendingEvent + s.ReportHost(ctx, "autofix") + // One snapshot for the pass. It decides both which repositories are watched + // at all and which of them may be fixed, so it is read BEFORE the target + // list is built. + st, _, stateErr := s.store.Load(ctx) + if stateErr != nil { + return fmt.Errorf("loading shared state for watch pass: %w", stateErr) + } + fleetCfg := s.cfg.WithFleet(st.Fleet) // Copied, never appended to in place. `crq watch -- ` splits argv at // "--", so the flag half keeps CAPACITY reaching into the command half, and // fs.Args() is a sub-slice of it: filling an empty list with append wrote the // repository names straight over the fix command, and every dispatch in the // fleet died with "fork/exec kristofferr/coderabbit-queue: no such file or // directory". A caller's slice is the caller's. - repos := make([]string, 0, len(opts.Repos)+len(cfg.AllowRepos)) + repos := make([]string, 0, len(opts.Repos)+len(fleetCfg.AllowRepos)) repos = append(repos, opts.Repos...) if len(repos) == 0 { - for repo := range cfg.AllowRepos { + // Enrollment is a fleet-wide record, and autoreview already builds its + // scan list from it. Reading only CRQ_REPOS here meant a repository + // enrolled from the dashboard was reviewed but never watched — its + // findings arrived and no fix session ever started — until somebody + // edited an env file on this host and reinstalled the service. + seen := map[string]bool{} + add := func(repo string) { + if repo = NormalizeRepo(repo); repo == "" || seen[repo] { + return + } + seen[repo] = true repos = append(repos, repo) } + for repo := range fleetCfg.AllowRepos { + add(repo) + } + for _, repo := range st.EnrolledRepos() { + add(repo) + } sort.Strings(repos) // stable order: a pass must not depend on map iteration } if len(repos) == 0 { @@ -237,19 +256,26 @@ func (s *Service) watchPass(ctx context.Context, opts WatchOptions, pool *dispat // its findings grew from 15 to 25. This is the same fix the quota-free // rescue scan already needed, for the same reason. type candidate struct { - repo string - pull ghapi.Pull + repo string + pull ghapi.Pull + priority int64 } var candidates []candidate - gate := NormalizeRepo(cfg.GateRepo) + gate := NormalizeRepo(s.cfg.GateRepo) // Which repositories may be FIXED is per repository, read once for the pass. // Watching is unaffected: a repository with autofix off is still observed // and still reviewed, so its feedback arrives for a person to act on. autofixOff := map[string]bool{} + // A repository turned off from the dashboard must stop being watched on the + // next pass, not on restart. + notEnrolled := map[string]bool{} for _, repo := range repos { - if !state.AutofixEnabled(repo) { + if !st.AutofixEnabled(repo) { autofixOff[NormalizeRepo(repo)] = true } + if !s.reviewsRepo(st, repo) { + notEnrolled[NormalizeRepo(repo)] = true + } } for _, repo := range repos { // The calibration PR is deliberately kept open to probe account quota; it @@ -264,7 +290,12 @@ func (s *Service) watchPass(ctx context.Context, opts WatchOptions, pool *dispat // it; this one did not, so the single setting that reads like a fleet-wide // opt-out silently covered half of what crq does — reviews stopped and the // watcher carried on, which is not a setting anyone can reason about. - if cfg.ExcludeRepos[NormalizeRepo(repo)] { + if s.cfg.ExcludeRepos[NormalizeRepo(repo)] { + continue + } + // "crq does not go here" has to mean that to every path, including the + // one that spends an agent on a fix session. + if notEnrolled[NormalizeRepo(repo)] { continue } pulls, err := s.gh.ListPulls(ctx, repo, openPullQuery()) @@ -288,7 +319,11 @@ func (s *Service) watchPass(ctx context.Context, opts WatchOptions, pool *dispat } open := make(map[int]bool, len(pulls)) for _, pull := range pulls { - candidates = append(candidates, candidate{repo, pull}) + var priority int64 + if round := st.Round(repo, pull.Number); round != nil && round.Seq < 0 { + priority = round.Seq + } + candidates = append(candidates, candidate{repo: repo, pull: pull, priority: priority}) open[pull.Number] = true } // This list is the authoritative set of open pull requests for the @@ -301,54 +336,58 @@ func (s *Service) watchPass(ctx context.Context, opts WatchOptions, pool *dispat s.log.Printf("watch: %s: retiring closed rounds: %v", repo, err) } } - if len(candidates) > 0 { - s.watchOffset = (s.watchOffset + 1) % len(candidates) + // Operator-prioritized PRs always lead the pass, ordered by their queue + // sequence. Everyone else keeps the rotating start that prevents a fixed + // repository/PR order from starving the tail when dispatch capacity is full. + var prioritized, regular []candidate + for _, c := range candidates { + if c.priority < 0 { + prioritized = append(prioritized, c) + } else { + regular = append(regular, c) + } + } + sort.SliceStable(prioritized, func(i, j int) bool { + return prioritized[i].priority < prioritized[j].priority + }) + if len(regular) > 0 { + s.watchOffset = (s.watchOffset + 1) % len(regular) + rotated := append([]candidate(nil), regular[s.watchOffset:]...) + rotated = append(rotated, regular[:s.watchOffset]...) + candidates = append(prioritized, rotated...) + } else { + candidates = prioritized } for i := range candidates { - c := candidates[(i+s.watchOffset)%len(candidates)] + c := candidates[i] repo, pull := c.repo, c.pull { if err := ctx.Err(); err != nil { return err } - // The fleet's skip marker suppresses review deliberately, to protect - // the shared quota, and its repository lists say where crq goes at - // all. Next is a MUTATING oracle — it enqueues and can fire — and it - // enforces neither, because it is the manual path: its target is a - // request a person made. So both have to be honoured before calling + // The skip rules suppress review deliberately — the marker to protect + // the shared quota, the author list to keep crq off pull requests a + // repository has said not to touch. Next is a MUTATING oracle: it + // enqueues, it can fire, and it can start a fix session that runs an + // agent with approvals bypassed. So both are honoured BEFORE calling // it, not after. // - // The policy is re-read here rather than reused from the top of the - // pass. A fleet-wide scan is long, so a marker the fleet adopted — or - // a repository it dropped — mid-pass would otherwise spend a metered - // review on every candidate still to come in it, gathered under the - // policy the pass started with. Rereading costs a conditional GET on - // the ETag'd state ref, against a call that already re-observes the - // PR. - live, _, lerr := s.store.Load(ctx) - if lerr != nil { - if _, throttled := ghapi.ThrottleWait(lerr); throttled { - return lerr - } - // Next reads the same ref and would fail the same way; treat it - // like any other unreadable candidate rather than firing under a - // policy crq could not confirm. - if s.log != nil { - s.log.Printf("watch: %s#%d: %v", repo, pull.Number, lerr) - } - if opts.Once { - failures = append(failures, fmt.Sprintf("%s#%d: %v", repo, pull.Number, lerr)) - } - continue - } - liveCfg := s.fleetCfg(live) - if !watchesRepo(liveCfg, opts.Repos, repo) { - continue + // Read through the pass's state, the way autoReviewPass reads them: + // both are fleet- and repository-settable, and a watcher answering + // from its startup env reviewed pull requests the settings it was + // displaying had just excluded. + skipCfg := s.cfgFor(st, repo) + skipped := "" + switch { + case skipCfg.SkipsReview(pull.Body): + skipped = "fleet auto-review skip marker present" + case skipCfg.SkipAuthors[dialect.NormalizeBotName(strings.ToLower(pull.User.Login))]: + skipped = "the author is on this repository's skip list" } - if liveCfg.SkipsReview(pull.Body) { + if skipped != "" { event := WatchEvent{ Repo: repo, PR: pull.Number, - Action: "skipped", Reason: "fleet auto-review skip marker present", + Action: "skipped", Reason: skipped, At: s.clock().UTC(), } if emit != nil { @@ -392,10 +431,11 @@ func (s *Service) watchPass(ctx context.Context, opts WatchOptions, pool *dispat Action: report.Action, Reason: report.Reason, Findings: len(report.Findings), At: s.clock().UTC(), } + report.Fork = forkPull(repo, pull) var result <-chan dispatchResult if opts.dispatching() && report.Action == string(engine.ActionFix) && autofixOff[NormalizeRepo(repo)] { event.Skipped = "autofix is off for this repository (crq autofix on " + NormalizeRepo(repo) + ")" - } else if opts.dispatching() && report.Action == string(engine.ActionFix) && !s.mayDispatch(repo, pull) { + } else if opts.dispatching() && report.Action == string(engine.ActionFix) && !s.mayDispatch(skipCfg, repo, pull) { event.Skipped = "the head branch is a fork; set CRQ_DISPATCH_FORKS=1 to fix contributor pull requests" } else if opts.dispatching() && report.Action == string(engine.ActionFix) { // Claim here and hand preparation to the pool. Checkout and @@ -478,15 +518,19 @@ func (s *Service) watchPass(ctx context.Context, opts WatchOptions, pool *dispat // sandbox and does not claim to be one — it is the line between code the // operator wrote and code somebody else did. Reviewing a fork is unaffected: // reading a pull request runs nothing. -func (s *Service) mayDispatch(repo string, pull ghapi.Pull) bool { - if s.cfg.DispatchForks { +func (s *Service) mayDispatch(cfg Config, repo string, pull ghapi.Pull) bool { + if cfg.DispatchForks { return true } + return !forkPull(repo, pull) +} + +func forkPull(repo string, pull ghapi.Pull) bool { head := NormalizeRepo(pull.Head.Repo.FullName) // An unreadable head repository is not evidence that it is ours. A deleted // fork answers with an empty name, and defaulting to "same repository" would // hand exactly the untrusted case the permission it is missing. - return head != "" && head == NormalizeRepo(repo) + return head == "" || head != NormalizeRepo(repo) } // noteDispatchHealth records whether fix sessions are starting, and says so @@ -570,6 +614,13 @@ func (s *Service) queueDispatch( if s.cfg.DryRun { return false, "dry run: would dispatch a fix session", nil, nil } + solver := s.repoCfg(ctx, report.Repo) + if len(report.Findings) > 0 { + report.Findings = autofixFindings(report.Findings, solver.FixSeverities) + if len(report.Findings) == 0 { + return false, "autofix policy excludes every open finding", nil, nil + } + } var ok bool var why string if opts.Once { @@ -581,7 +632,7 @@ func (s *Service) queueDispatch( return false, why, nil, nil } token := randomToken() - claimed, why, byDesign := s.claimDispatch(ctx, report, token, opts.MaxAttempts) + claimed, why, byDesign, model := s.claimDispatchModels(ctx, &report, token, opts.MaxAttempts) if !claimed { pool.release() // A round another watcher already holds, or one that has spent its @@ -600,12 +651,49 @@ func (s *Service) queueDispatch( result := make(chan dispatchResult, 1) started := make(chan dispatchResult, 1) pool.run(func() { - result <- s.runDispatch(ctx, opts, report, token, started) + result <- s.runDispatch(ctx, opts, report, token, model, started) close(result) }) return true, "", result, started } +// recordedMaxAttempts is the fix-session budget for one repository: whatever +// shared state RECORDS, falling back to this run's own value. +// +// Per repository, not per watcher — the budget is a property of the project +// being fixed, and one that keeps needing a fourth try should not have to raise +// the limit for every other repository the watcher handles. +// +// RECORDED, not resolved. The merged configuration always carries a positive +// default, so reading that here outranked this run's own `--max-attempts` on +// every ordinary setup: the flag was accepted and then silently replaced by 3. +// +// Both records are asked. The typed solver record is the per-repository answer; +// the generic fleet map is where the settings editor saves +// CRQ_DISPATCH_MAX_ATTEMPTS, and a claim that consulted only the first went on +// enforcing this host's number while the dashboard reported the fleet's — with +// no later state read able to change it. +func (s *Service) recordedMaxAttempts(ctx context.Context, repo string, fallback int) int { + st, _, err := s.store.Load(ctx) + if err != nil { + return fallback + } + return recordedMaxAttemptsIn(st, repo, fallback) +} + +func recordedMaxAttemptsIn(st State, repo string, fallback int) int { + if sv := st.EffectiveSolver(repo); sv.MaxAttempts != nil { + return *sv.MaxAttempts + } + // Positive only, the way every consumer of a fleet setting reads one: a + // recorded 0 or a value that will not parse is not a budget, and honouring + // it would stop the whole fleet fixing anything. + if n, err := strconv.Atoi(strings.TrimSpace(st.Fleet.Env["CRQ_DISPATCH_MAX_ATTEMPTS"])); err == nil && n > 0 { + return n + } + return fallback +} + type dispatchResult struct { ok bool reason string @@ -618,9 +706,10 @@ func (s *Service) runDispatch( opts WatchOptions, report NextReport, token string, + model string, started chan<- dispatchResult, ) dispatchResult { - ok, why := s.dispatchWithStart(ctx, opts, report, token, started) + ok, why := s.dispatchWithStart(ctx, opts, report, token, model, started) if !ok && s.log != nil { s.log.Printf("watch: %s#%d not fixed: %s", report.Repo, report.PR, why) } @@ -629,7 +718,8 @@ func (s *Service) runDispatch( // dispatch checks the claimed round's head out and runs the fix session. func (s *Service) dispatch(ctx context.Context, opts WatchOptions, report NextReport, token string) (bool, string) { - return s.dispatchWithStart(ctx, opts, report, token, nil) + model, _ := s.claimedDispatchModel(ctx, report, token) + return s.dispatchWithStart(ctx, opts, report, token, model, nil) } func (s *Service) dispatchWithStart( @@ -637,6 +727,7 @@ func (s *Service) dispatchWithStart( opts WatchOptions, report NextReport, token string, + model string, started chan<- dispatchResult, ) (ok bool, reason string) { // Health means "can this watcher START a session", not whether the agent later @@ -689,6 +780,11 @@ func (s *Service) dispatchWithStart( // read but the fact that nothing happened. Every session gets a file, and // the path is logged before it starts so it is findable while it runs. logPath, logFile, err := s.sessionLog(ctx, report) + if err == nil { + // Record where the output is going and what it is working on, so the + // dashboard can say more about a running session than "attempt 2". + s.noteSessionDetail(ctx, report, token, logPath, len(report.Findings)) + } if err != nil { s.releaseDispatch(context.WithoutCancel(ctx), report, token, false) _ = co.Remove(context.WithoutCancel(ctx)) @@ -701,11 +797,22 @@ func (s *Service) dispatchWithStart( cmd.Dir = co.Dir cmd.Stdout = logFile cmd.Stderr = logFile + // The repository's solver settings travel in the ENVIRONMENT, because argv + // is fixed when the watcher starts and these differ per repository. The + // session script reads them; a script from an older install ignores them + // and runs exactly as it did. + solver := s.repoCfg(runCtx, report.Repo) + sessionPrompt := appendAutofixPolicy(solver.FixPrompt, solver.FixSeverities, solver.FixAskMode) cmd.Env = append(os.Environ(), "CRQ_DISPATCH_REPO="+report.Repo, fmt.Sprintf("CRQ_DISPATCH_PR=%d", report.PR), "CRQ_DISPATCH_HEAD="+report.Head, "CRQ_DISPATCH_FINDINGS="+findingsPath, + // The agent and prompt come from the unit's environment and are already + // in os.Environ(); only the per-repository half is added here. + "CRQ_FIX_MODEL="+model, + "CRQ_FIX_EFFORT="+solver.FixEffort, + "CRQ_FIX_PROMPT="+sessionPrompt, ) // The session's push is a plain `git push`, and git reads no GITHUB_TOKEN of // its own. The mirror carries a credential helper that reads this variable @@ -731,6 +838,27 @@ func (s *Service) dispatchWithStart( } s.noteDispatchHealth(context.WithoutCancel(ctx), true, "") runErr := cmd.Wait() + _ = logFile.Sync() + if question := clarificationFromLog(string(readLogTail(logPath, 256<<10))); question != "" { + // Asking is not a failed solution attempt. Hold the PR with the exact + // question so it appears in the dashboard's existing attention surface, + // then release this claim without consuming the per-head budget. + reason := "Autofix needs clarification: " + question + if _, err := s.holdDispatch(context.WithoutCancel(ctx), report, token, reason); err == nil { + s.releaseDispatch(context.WithoutCancel(ctx), report, token, false) + return false, fmt.Sprintf("%s (log: %s)", reason, logPath) + } + // Standalone watch/autofix has no autoreview leader, so it cannot create + // an administrative hold. Keep a head-scoped terminal dispatch marker + // instead; otherwise the released claim launches another paid session + // for the same unanswered question on the next pass. + if err := s.stopDispatchForClarification( + context.WithoutCancel(ctx), report, token, question, + ); err != nil { + return false, fmt.Sprintf("%s (could not record the clarification stop: %v; log: %s)", reason, err, logPath) + } + return false, fmt.Sprintf("%s (autofix stopped for this head; log: %s)", reason, logPath) + } // A session the WATCHER stopped did not use up the head's attempt budget. // // That budget exists to stop a fix which keeps not working from looping for @@ -740,6 +868,16 @@ func (s *Service) dispatchWithStart( // would have left the pull request unfixable at that commit while `crq next` // went on asking for a fix. attempted := ctx.Err() == nil + if runErr != nil && attempted { + if failure := dialect.ClassifyAgentFailure(readLogTail(logPath, 256<<10), s.clock()); failure.Unavailable { + s.releaseDispatchUnavailable(context.WithoutCancel(ctx), report, token, failure) + if lost() { + return false, "another watcher took this round; the session was stopped" + } + return false, fmt.Sprintf("%s; fallback will retry after %s (log: %s)", + failure.Reason, failure.RetryAt.Format(time.RFC3339), logPath) + } + } s.releaseDispatch(context.WithoutCancel(ctx), report, token, attempted) if lost() { return false, "another watcher took this round; the session was stopped" @@ -768,6 +906,93 @@ func (s *Service) dispatchWithStart( return true, "" } +func autofixFindings(findings []dialect.Finding, allowed map[string]bool) []dialect.Finding { + if len(allowed) == 0 { + return findings + } + out := make([]dialect.Finding, 0, len(findings)) + for _, finding := range findings { + severity := strings.ToLower(strings.TrimSpace(finding.Severity)) + if severity == "" { + severity = "unknown" + } + if allowed[severity] { + out = append(out, finding) + } + } + return out +} + +const clarificationMarker = "CRQ_NEEDS_CLARIFICATION:" + +func appendAutofixPolicy(prompt string, severities map[string]bool, askMode string) string { + allowed := sortedKeys(severities) + if len(allowed) == 0 { + allowed = dialect.KnownSeverities() + } + instruction := "Autofix policy:\n- Work only on findings passed to this session (configured severities: " + + strings.Join(allowed, ", ") + ").\n" + switch askMode { + case "ambiguous": + instruction += "- Stop at the first meaningful ambiguity: if multiple reasonable solutions would change behavior differently, do not guess.\n" + case "uncertain": + instruction += "- Stop when confidence is low that the chosen solution is the intended one; do not guess through unclear requirements.\n" + default: + instruction += "- Use best judgment and stop for clarification only when missing information makes a safe fix impossible.\n" + } + instruction += "- To ask, make no further edits and end your response with exactly `" + + clarificationMarker + " `." + if strings.TrimSpace(prompt) == "" { + return instruction + } + return strings.TrimSpace(prompt) + "\n\n" + instruction +} + +func clarificationFromLog(body string) string { + var response string + scanner := bufio.NewScanner(strings.NewReader(body)) + scanner.Buffer(make([]byte, 64<<10), 256<<10) + for scanner.Scan() { + var event struct { + Type string `json:"type"` + Result string `json:"result"` + Item struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"item"` + } + if json.Unmarshal(scanner.Bytes(), &event) != nil { + continue + } + switch { + case event.Type == "result": + // Claude's stream-json terminal event. + response = event.Result + case event.Type == "item.completed" && event.Item.Type == "agent_message": + // Codex's --json terminal assistant message. + response = event.Item.Text + } + } + + response = strings.TrimSpace(response) + if response == "" { + return "" + } + line := response + if start := strings.LastIndexByte(response, '\n'); start >= 0 { + line = response[start+1:] + } + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, clarificationMarker) { + return "" + } + question := strings.TrimSpace(strings.TrimPrefix(line, clarificationMarker)) + if len(question) > 500 { + question = question[:500] + } + return strings.TrimSpace(question) +} + // sessionWork reports whether this checkout holds work that exists nowhere else, // and says what it is. // @@ -832,8 +1057,20 @@ func (s *Service) writeFindings(report NextReport) (string, error) { // already running for this PR, or a head that has spent its attempt budget — // rather than evidence about whether fix sessions can start at all. func (s *Service) claimDispatch(ctx context.Context, report NextReport, token string, maxAttempts int) (bool, string, bool) { + ok, why, byDesign, _ := s.claimDispatchModels(ctx, &report, token, maxAttempts) + return ok, why, byDesign +} + +func (s *Service) claimDispatchModels( + ctx context.Context, + report *NextReport, + token string, + fallbackMaxAttempts int, +) (bool, string, bool, string) { reason, byDesign := "", false var seen string + var selectedModel string + var selectedFindings []dialect.Finding _, err := s.store.Update(ctx, func(st *State) error { // The pass-level snapshot is only an optimization. The switch is an // operator safety gate, so enforce it in the same CAS that grants the @@ -842,8 +1079,17 @@ func (s *Service) claimDispatch(ctx context.Context, report NextReport, token st reason, byDesign = "fix sessions are disabled for this repository", true return ErrNoChange } - if s.fleetCfg(*st).ExcludeRepos[NormalizeRepo(report.Repo)] { - reason, byDesign = "repository is excluded by fleet policy", true + // Enrollment is the same kind of gate and gets the same treatment. The + // pass reads it once and can be minutes old, and its load is best-effort — + // so "Stop reviewing" could be clicked, and a coding-agent session still + // start against the repository, on the strength of a snapshot taken before + // the click or of no snapshot at all. + if !s.reviewsRepo(*st, report.Repo) { + reason, byDesign = "crq is not reviewing this repository", true + return ErrNoChange + } + if report.Fork && !s.cfgFor(*st, report.Repo).DispatchForks { + reason, byDesign = "the head branch is a fork and current solver policy forbids fixing it", true return ErrNoChange } round := st.Round(report.Repo, report.PR) @@ -852,7 +1098,7 @@ func (s *Service) claimDispatch(ctx context.Context, report NextReport, token st // claim at all, and no test reproduces it: the tests assert the refusal // and get it. So the next occurrence has to explain itself from the log — // which round each grant saw, and what its claim said at the time. - seen = describeDispatchClaim(round, st, report, s.clock()) + seen = describeDispatchClaim(round, st, *report, s.clock()) // Somebody is fixing an earlier head of this PR right now, and is entitled // to finish: a session's own push moves the head, so this is what a // successful session looks like from the outside. Superseding its round @@ -898,7 +1144,13 @@ func (s *Service) claimDispatch(ctx context.Context, report NextReport, token st // CARRIED from an older commit proves nothing about this head either. // Both cases leave the round adoptable instead, so the queue can // still buy the review that is actually missing. - if report.ReviewedBy[s.cfg.Bot] && len(engine.FindingsOnHead(report.Findings, report.Head)) > 0 { + // + // Who the primary IS comes from the state this write lands on, not + // from this watcher's startup env: a fleet that changed CRQ_BOT would + // otherwise have its new primary's review read as a co-reviewer's, and + // the head marked unreviewed for a review it already has. + if reviewedByConfiguredBot(report.ReviewedBy, s.cfgFor(*st, report.Repo).Bot) && + len(engine.FindingsOnHead(report.Findings, report.Head)) > 0 { if err := round.Dedupe(s.clock()); err != nil { return err } @@ -909,17 +1161,37 @@ func (s *Service) claimDispatch(ctx context.Context, report NextReport, token st round.Note = "adopted to fix feedback carried from an earlier head" } } - ok, why := round.ClaimDispatch(s.cfg.Host, token, s.clock(), maxAttempts) + // The operator can replace the severity policy, model ranking, or attempt + // limit after this pass's initial read. Resolve all three from the state + // revision this CAS will write. + claimCfg := s.cfgFor(*st, report.Repo) + selectedFindings = autofixFindings(report.Findings, claimCfg.FixSeverities) + if len(report.Findings) > 0 && len(selectedFindings) == 0 { + reason, byDesign = "current autofix policy excludes every open finding", true + return ErrNoChange + } + models := append([]string(nil), claimCfg.FixModels...) + if len(models) == 0 && claimCfg.FixModel != "" { + models = []string{claimCfg.FixModel} + } + maxAttempts := recordedMaxAttemptsIn(*st, report.Repo, fallbackMaxAttempts) + ok, why := round.ClaimDispatchModels(s.cfg.Host, token, s.clock(), maxAttempts, models) if !ok { reason, byDesign = why, true return ErrNoChange } + selectedModel = round.Dispatch.Model st.RememberDispatch(report.Repo, report.PR, *round.Dispatch) st.PutRound(*round) return nil }) if err != nil { - return false, err.Error(), false + return false, err.Error(), false, "" + } + if reason == "" { + // The session receives exactly the finding set selected by the same + // state revision that granted its claim. + report.Findings = selectedFindings } if s.log != nil { outcome := "granted" @@ -929,7 +1201,7 @@ func (s *Service) claimDispatch(ctx context.Context, report NextReport, token st s.log.Printf("dispatch claim %s for %s#%d@%s token=%s: %s", outcome, report.Repo, report.PR, report.Head, token, seen) } - return reason == "", reason, byDesign + return reason == "", reason, byDesign, selectedModel } // describeDispatchClaim renders what a claim attempt read, for the log line @@ -967,10 +1239,102 @@ func (s *Service) releaseDispatch(ctx context.Context, report NextReport, token } if !attempted && round.Dispatch != nil && round.Dispatch.Attempts > 0 { round.Dispatch.Attempts-- + round.Dispatch.AttemptResetAt = nil + } + st.PutRound(*round) + return nil + }) +} + +func (s *Service) stopDispatchForClarification( + ctx context.Context, + report NextReport, + token string, + question string, +) error { + _, err := s.store.Update(ctx, func(st *State) error { + round := st.Round(report.Repo, report.PR) + if round == nil || !round.MarkDispatchClarification(token, question) { + return ErrNoChange + } + if claim, ok := st.Dispatches[QueueKey(report.Repo, report.PR)]; ok && claim.Token == token { + delete(st.Dispatches, QueueKey(report.Repo, report.PR)) } st.PutRound(*round) return nil }) + return err +} + +// releaseDispatchUnavailable refunds an attempt that never reached the code +// problem: the provider/model could not serve it. The selected model is parked +// until its retry time while the already-advanced cursor makes the next claim +// choose a fallback. +func (s *Service) releaseDispatchUnavailable( + ctx context.Context, + report NextReport, + token string, + failure dialect.AgentFailure, +) { + _, _ = s.store.Update(ctx, func(st *State) error { + round := st.Round(report.Repo, report.PR) + current := round != nil && round.MarkDispatchUnavailable(token, failure.RetryAt, failure.Reason) + archived := false + if !current { + archived = st.MarkArchivedDispatchUnavailable( + report.Repo, report.PR, token, failure.RetryAt, failure.Reason, + ) + } + if !current && !archived { + return ErrNoChange + } + if current { + if !round.ReleaseDispatch(token) { + return ErrNoChange + } + st.PutRound(*round) + } + st.ReleaseArchivedDispatch(report.Repo, report.PR, token) + return nil + }) +} + +func (s *Service) claimedDispatchModel( + ctx context.Context, + report NextReport, + token string, +) (string, bool) { + st, _, err := s.store.Load(ctx) + if err != nil { + return "", false + } + if round := st.Round(report.Repo, report.PR); round != nil && + round.Dispatch != nil && round.Dispatch.Token == token { + return round.Dispatch.Model, true + } + if claim, ok := st.Dispatches[QueueKey(report.Repo, report.PR)]; ok && claim.Token == token { + return claim.Model, true + } + return "", false +} + +func readLogTail(path string, limit int64) []byte { + file, err := os.Open(path) + if err != nil { + return nil + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil + } + start := info.Size() - limit + if start < 0 { + start = 0 + } + body := make([]byte, info.Size()-start) + n, _ := file.ReadAt(body, start) + return body[:n] } // beatDispatch refreshes the claim while the session runs, so a session that @@ -1242,3 +1606,31 @@ func (s *Service) retireClosedRounds(ctx context.Context, repo string, open map[ } return nil } + +// noteSessionDetail records what a running session is working on, for the +// reader. Best-effort: this is display bookkeeping, and failing a fix session +// over it would trade something that matters for something that does not. +func (s *Service) noteSessionDetail(ctx context.Context, report NextReport, token, logPath string, findings int) { + _, err := s.store.Update(ctx, func(st *State) error { + r := st.Round(report.Repo, report.PR) + if r == nil || r.Dispatch == nil || r.Dispatch.Token != token { + return ErrNoChange + } + mirror := st.Dispatches[QueueKey(report.Repo, report.PR)] + if r.Dispatch.Log == logPath && r.Dispatch.Findings == findings && + mirror.Token == token && mirror.Log == logPath && mirror.Findings == findings { + return ErrNoChange + } + r.Dispatch.Log, r.Dispatch.Findings = logPath, findings + st.PutRound(*r) + // The session list reads the MIRRORED claim, not the round, so the same + // write has to reach both. Left to the heartbeat, these fields appeared + // a third of a TTL late — and never at all for a session that finished + // sooner, which is most of them. + st.RememberDispatch(report.Repo, report.PR, *r.Dispatch) + return nil + }) + if err != nil && !errors.Is(err, ErrNoChange) && s.log != nil { + s.log.Printf("warning: recording session detail for %s#%d: %v", report.Repo, report.PR, err) + } +} diff --git a/internal/crq/watch_test.go b/internal/crq/watch_test.go index 14613731..f6cc3386 100644 --- a/internal/crq/watch_test.go +++ b/internal/crq/watch_test.go @@ -110,6 +110,186 @@ func TestWatchDispatchesAFixSessionWithItsContext(t *testing.T) { }) } +func TestStandaloneDispatchKeepsClarificationTerminal(t *testing.T) { + base := t.TempDir() + repo := "owner/thing" + sha := originRepo(t, filepath.Join(base, repo)) + t.Setenv("CRQ_REMOTE_BASE", base) + + cfg := firingConfig() + cfg.WorkspaceRoot = t.TempDir() + cfg.AllowRepos = map[string]bool{repo: true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + report := NextReport{Repo: repo, PR: 5, Head: sha, Action: "fix"} + seedRound(t, store, cfg, repo, 5, sha, PhaseQueued, time.Now().UTC(), 0) + + script := filepath.Join(t.TempDir(), "session.sh") + body := "#!/bin/sh\nprintf '%s\\n' '{\"type\":\"result\",\"result\":\"" + + clarificationMarker + " Which behavior should remain?\"}'\n" + if err := os.WriteFile(script, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + + pool := newDispatchPool(0) + if ok, why := svc.startDispatch(context.Background(), WatchOptions{ + Dispatch: dispatchOn(), Command: []string{script}, MaxAttempts: 3, + }, pool, report); !ok { + t.Fatalf("dispatch did not run: %s", why) + } + pool.wait() + + st, _, err := store.Load(context.Background()) + if err != nil { + t.Fatal(err) + } + round := st.Round(repo, 5) + if round == nil || round.Dispatch == nil || + round.Dispatch.Clarification != "Which behavior should remain?" { + t.Fatalf("clarification marker = %#v, want the question retained on this head", round) + } + if ok, why, byDesign := svc.claimDispatch(context.Background(), report, "again", 3); ok || + !byDesign || !strings.Contains(why, "Which behavior should remain?") { + t.Fatalf("repeat dispatch = ok %v byDesign %v reason %q", ok, byDesign, why) + } +} + +func TestProviderOutageUsesFallbackWithoutSpendingAnAttempt(t *testing.T) { + base := t.TempDir() + repo := "owner/thing" + sha := originRepo(t, filepath.Join(base, repo)) + t.Setenv("CRQ_REMOTE_BASE", base) + + cfg := firingConfig() + cfg.WorkspaceRoot = t.TempDir() + cfg.AllowRepos = map[string]bool{repo: true} + gh := newFakeGitHub() + gh.graphQL = noForcePush + store := NewMemoryStore(cfg) + switchable := &loadSwitchStore{StateStore: store} + svc := NewService(cfg, gh, switchable, nil) + if _, err := svc.SetSolver(context.Background(), repo, SolverChange{ + Models: []string{"opus", "sonnet"}, + }); err != nil { + t.Fatal(err) + } + seedRound(t, store, cfg, repo, 18, sha, PhaseQueued, time.Now().UTC(), 0) + + record := filepath.Join(t.TempDir(), "models") + script := filepath.Join(t.TempDir(), "agent.sh") + body := "#!/bin/sh\n" + + "printf '%s\\n' \"$CRQ_FIX_MODEL\" >> " + record + "\n" + + "if test \"$CRQ_FIX_MODEL\" = opus; then\n" + + " printf '%s\\n' '{\"type\":\"result\",\"error\":\"rate_limit\",\"resetsAt\":4102444800}'\n" + + " exit 1\n" + + "fi\n" + if err := os.WriteFile(script, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + report := NextReport{Repo: repo, PR: 18, Head: sha, Action: "fix"} + opts := WatchOptions{Dispatch: dispatchOn(), Command: []string{script}, MaxAttempts: 5} + + var claimedModel string + if ok, why, _, model := svc.claimDispatchModels(context.Background(), &report, "first", 5); !ok { + t.Fatalf("first claim: %s", why) + } else if model != "opus" { + t.Fatalf("first claim returned model %q, want opus", model) + } else { + claimedModel = model + } + // The selected model belongs to the successful claim. A later state-ref + // outage must not replace it with this host's startup default. + switchable.unreadable = true + if ok, why := svc.dispatchWithStart(context.Background(), opts, report, "first", claimedModel, nil); ok || !strings.Contains(why, "temporarily unavailable") { + t.Fatalf("provider outage = ok %v reason %q", ok, why) + } + st, _, err := store.Load(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := st.Round(repo, 18).Dispatch.Attempts; got != 0 { + t.Fatalf("provider outage spent %d attempts, want 0", got) + } + + if ok, why, _, model := svc.claimDispatchModels(context.Background(), &report, "second", 5); !ok { + t.Fatalf("fallback claim: %s", why) + } else if model != "sonnet" { + t.Fatalf("fallback claim returned model %q, want sonnet", model) + } else { + claimedModel = model + } + if ok, why := svc.dispatchWithStart(context.Background(), opts, report, "second", claimedModel, nil); !ok { + t.Fatalf("fallback did not run: %s", why) + } + models, err := os.ReadFile(record) + if err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(string(models)); got != "opus\nsonnet" { + t.Fatalf("models tried = %q, want ranked fallback", got) + } + st, _, err = store.Load(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := st.Round(repo, 18).Dispatch.Attempts; got != 1 { + t.Fatalf("successful fallback left %d attempts, want only its real attempt", got) + } +} + +func TestProviderOutageReleasesAnArchivedDispatchClaim(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + now := time.Now().UTC() + const ( + repo = "o/repo" + pr = 18 + token = "session" + ) + if _, err := store.Update(ctx, func(st *State) error { + round, err := st.NewRound(repo, pr, "aaaaaaaa1", now) + if err != nil { + return err + } + if ok, why := round.ClaimDispatchModels("host", token, now, 5, []string{"opus"}); !ok { + return errors.New(why) + } + st.RememberDispatch(repo, pr, *round.Dispatch) + st.PutRound(*round) + _, err = st.Supersede(repo, pr, "bbbbbbbb2", now.Add(time.Minute)) + return err + }); err != nil { + t.Fatal(err) + } + + retryAt := now.Add(time.Hour) + svc.releaseDispatchUnavailable(ctx, NextReport{Repo: repo, PR: pr}, token, dialect.AgentFailure{ + RetryAt: retryAt, Reason: "provider unavailable", + }) + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if st.ArchivedDispatchHeld(repo, pr, now.Add(2*time.Minute)) { + t.Fatal("provider outage left the archived claim live") + } + for _, round := range st.Archive { + if round.Repo != repo || round.PR != pr || round.Dispatch == nil { + continue + } + if got := round.Dispatch.UnavailableModels["opus"]; !got.Equal(retryAt) { + t.Fatalf("archived outage = %s, want %s", got, retryAt) + } + if round.Dispatch.Attempts != 0 { + t.Fatalf("archived outage spent %d attempts, want 0", round.Dispatch.Attempts) + } + return + } + t.Fatal("no archived dispatch claim found") +} + // Dispatching is the default, so a machine with no fix agent configured must // still be able to watch: refusing to start would make the default setting // break the plain command. It observes instead — and says so, because an autofix watcher @@ -520,6 +700,47 @@ func TestWatchPassRotatesSoNoPRIsStarved(t *testing.T) { } } +func TestWatchPassVisitsPrioritizedPRBeforeFairnessRotation(t *testing.T) { + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/r": true} + gh := newFakeGitHub() + gh.graphQL = noForcePush + for _, pr := range []int{1, 2, 3, 4} { + var p ghapi.Pull + p.State = "open" + p.Number = pr + p.Head.SHA = "aaaaaaaa1" + gh.pulls[fakeKey("o/r", pr)] = p + } + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + seedRound(t, store, cfg, "o/r", 4, "aaaaaaaa1", PhaseQueued, time.Now().UTC(), 0) + if err := svc.Prioritize(context.Background(), "o/r", 4); err != nil { + t.Fatal(err) + } + + var order []int + if err := svc.watchPass(context.Background(), WatchOptions{}, newDispatchPool(4), func(e WatchEvent) error { + order = append(order, e.PR) + return nil + }); err != nil { + t.Fatal(err) + } + if len(order) == 0 || order[0] != 4 { + t.Fatalf("visit order = %v, want prioritized PR 4 first", order) + } +} + +func TestPrioritizeDryRunDoesNotReportAMutation(t *testing.T) { + cfg := firingConfig() + cfg.DryRun = true + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + err := svc.Prioritize(context.Background(), "o/r", 4) + if err == nil || !strings.Contains(err.Error(), "dry run") { + t.Fatalf("dry-run prioritize error = %v, want an explicit non-success outcome", err) + } +} + // A session that commits its fixes but does not push them leaves a clean working // tree, and deleting that worktree destroys the only copy of the fix. func TestDispatchKeepsACommittedButUnpushedFix(t *testing.T) { @@ -689,7 +910,7 @@ type dashboardCountingStore struct { syncs []State } -func (s *dashboardCountingStore) SyncDashboard(_ context.Context, state State, _ StoreConfig) error { +func (s *dashboardCountingStore) SyncDashboard(_ context.Context, state State) error { s.syncs = append(s.syncs, cloneState(state)) return nil } @@ -808,35 +1029,6 @@ func TestClaimDispatchRechecksRepositoryAutofixSwitch(t *testing.T) { } } -func TestClaimDispatchRechecksFleetExclusion(t *testing.T) { - ctx := context.Background() - cfg := firingConfig() - store := NewMemoryStore(cfg) - svc := NewService(cfg, newFakeGitHub(), store, nil) - report := NextReport{ - Repo: "owner/thing", PR: 12, Head: "aaaaaaaa1", Action: "fix", - Findings: []dialect.Finding{{ID: "f1", Commit: "aaaaaaaa1"}}, - } - if _, err := store.Update(ctx, func(st *State) error { - st.SetFleetValue("exclude", report.Repo) - return nil - }); err != nil { - t.Fatal(err) - } - - ok, why, byDesign := svc.claimDispatch(ctx, report, "tok", 3) - if ok || !byDesign || !strings.Contains(why, "excluded") { - t.Fatalf("claim = %v, %q, %v; want a fleet-exclusion refusal", ok, why, byDesign) - } - st, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if round := st.Round(report.Repo, report.PR); round != nil && round.Dispatch != nil { - t.Errorf("refused claim mutated the round: %+v", round) - } -} - // A head that moved on while its previous round still stood: `Next` reports fix // without enqueueing, so nothing else supersedes the stale round. Refusing the // claim over the mismatch left the new head's findings undispatchable on every @@ -974,6 +1166,46 @@ func TestSessionLogPruneLeavesRoomForTheNewLog(t *testing.T) { } } +func TestAutofixPolicyFiltersAndExtractsClarification(t *testing.T) { + findings := []dialect.Finding{ + {ID: "major", Severity: "major"}, + {ID: "minor", Severity: "minor"}, + {ID: "unknown"}, + } + got := autofixFindings(findings, map[string]bool{"minor": true}) + if len(got) != 1 || got[0].ID != "minor" { + t.Fatalf("filtered findings = %+v, want only minor", got) + } + if got := autofixFindings(findings, nil); len(got) != len(findings) { + t.Fatalf("unset policy filtered %d findings to %d", len(findings), len(got)) + } + prompt := appendAutofixPolicy("use bun", map[string]bool{"minor": true}, "uncertain") + if !strings.Contains(prompt, "configured severities: minor") || + !strings.Contains(prompt, clarificationMarker) || + !strings.Contains(prompt, "confidence is low") { + t.Fatalf("policy prompt did not carry its enforcement contract:\n%s", prompt) + } + log := `{"type":"result","result":"` + clarificationMarker + ` Which API behavior should remain compatible?"}` + if got := clarificationFromLog(log); got != "Which API behavior should remain compatible?" { + t.Fatalf("clarification = %q", got) + } + log = `{"type":"result","result":"` + clarificationMarker + ` Should this return"}` + if got := clarificationFromLog(log); got != "Should this return" { + t.Fatalf("clarification ending in severity-like letters = %q", got) + } + log = `{"type":"assistant","message":{"content":[{"type":"tool_result","content":"` + + clarificationMarker + ` Should repository output place a hold?"}]}}` + "\n" + + `{"type":"result","result":"Fix completed."}` + if got := clarificationFromLog(log); got != "" { + t.Fatalf("tool output was mistaken for a clarification: %q", got) + } + log = `{"type":"item.completed","item":{"type":"agent_message","text":"I need one decision.\n` + + clarificationMarker + ` Which behavior should remain?"}}` + if got := clarificationFromLog(log); got != "Which behavior should remain?" { + t.Fatalf("Codex clarification = %q", got) + } +} + // A fork PR's branch lives in the contributor's repository, so the session // pushes there — and no branch of `origin` (the base repository this worktree // came from) will ever contain that commit. Read as unpushed work, every @@ -1031,25 +1263,48 @@ func TestDispatchSkipsAForkUnlessAllowed(t *testing.T) { own.Head.Repo.FullName = "Owner/Thing" // case differs; the repository does not fork.Head.Repo.FullName = "contributor/thing" - if !svc.mayDispatch("owner/thing", own) { + if !svc.mayDispatch(svc.cfg, "owner/thing", own) { t.Error("a branch in the repository itself must be dispatchable") } - if svc.mayDispatch("owner/thing", fork) { + if svc.mayDispatch(svc.cfg, "owner/thing", fork) { t.Error("a fork was dispatched without CRQ_DISPATCH_FORKS") } // A deleted fork answers with no repository at all. Reading that as "ours" // would grant exactly the untrusted case the permission it lacks. - if svc.mayDispatch("owner/thing", ghapi.Pull{}) { + if svc.mayDispatch(svc.cfg, "owner/thing", ghapi.Pull{}) { t.Error("an unreadable head repository was treated as our own") } allowed := cfg allowed.DispatchForks = true - if !NewService(allowed, newFakeGitHub(), NewMemoryStore(allowed), nil).mayDispatch("owner/thing", fork) { + if !NewService(allowed, newFakeGitHub(), NewMemoryStore(allowed), nil).mayDispatch(allowed, "owner/thing", fork) { t.Error("CRQ_DISPATCH_FORKS did not allow a fork") } } +func TestForkPolicyIsRecheckedInsideTheClaim(t *testing.T) { + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"owner/thing": true} + cfg.DispatchForks = true // the pass-level snapshot was permissive + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + ctx := context.Background() + report := NextReport{ + Repo: "owner/thing", PR: 9, Head: "aaaaaaaa1", Action: "fix", Fork: true, + } + seedRound(t, store, cfg, report.Repo, report.PR, report.Head, PhaseQueued, time.Now().UTC(), 0) + + off := false + if _, err := svc.SetSolver(ctx, report.Repo, SolverChange{Forks: &off}); err != nil { + t.Fatal(err) + } + if ok, why, byDesign := svc.claimDispatch(ctx, report, "token", 5); ok { + t.Fatal("a stale permissive fork decision started a session after policy was turned off") + } else if !byDesign || !strings.Contains(why, "current solver policy") { + t.Fatalf("claim refusal = %q byDesign=%v", why, byDesign) + } +} + // The watcher must not write into the caller's slices. // // `crq watch -- ` splits argv at "--": the flag half is args[:i] and the @@ -1095,20 +1350,14 @@ func dispatchOn() *bool { func TestWatchHonoursTheExcludedRepositories(t *testing.T) { cfg := firingConfig() cfg.AllowRepos = map[string]bool{"owner/kept": true, "owner/gone": true} + cfg.ExcludeRepos = map[string]bool{"owner/gone": true} gh := newFakeGitHub() for _, repo := range []string{"owner/kept", "owner/gone"} { var pull ghapi.Pull pull.State, pull.Number, pull.Head.SHA = "open", 1, "aaaaaaaa1" gh.pulls[fakeKey(repo, 1)] = pull } - store := NewMemoryStore(cfg) - if _, err := store.Update(context.Background(), func(st *State) error { - st.SetFleetValue("exclude", "owner/gone") - return nil - }); err != nil { - t.Fatal(err) - } - svc := NewService(cfg, gh, store, nil) + svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) var seen []string if err := svc.watchPass(context.Background(), WatchOptions{}, newDispatchPool(0), @@ -1125,151 +1374,401 @@ func TestWatchHonoursTheExcludedRepositories(t *testing.T) { } } -// loadHookedStore runs a hook once, immediately AFTER the first Load returns — -// the window between a pass reading the fleet policy and acting on the -// candidates it collected under it. -type loadHookedStore struct { - StateStore - hook func() +// `crq watch --max-attempts N` has to mean N. The per-repository budget is +// resolved from the RECORD, not from the merged configuration: that always +// carries a positive default, so reading it there accepted the flag and then +// silently replaced it with 3 on every ordinary setup. +func TestDispatchAttemptBudgetPrefersTheRecordedValue(t *testing.T) { + ctx := context.Background() + base := t.TempDir() + repo := "owner/thing" + sha := originRepo(t, filepath.Join(base, repo)) + t.Setenv("CRQ_REMOTE_BASE", base) + + cfg := firingConfig() + cfg.DispatchMaxAttempts = 3 + cfg.WorkspaceRoot = t.TempDir() + gh := newFakeGitHub() + gh.graphQL = noForcePush + var pull ghapi.Pull + pull.State = "open" + pull.Head.SHA = sha + gh.pulls[fakeKey(repo, 9)] = pull + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + seedRound(t, store, cfg, repo, 9, sha, PhaseQueued, time.Now().UTC(), 0) + spendAttempts(t, store, repo, 9, 3) + + report := NextReport{Repo: repo, PR: 9, Head: sha, Action: "fix"} + script := filepath.Join(t.TempDir(), "fix.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + + // Nothing recorded: this run's own limit is the one in force. + pool := newDispatchPool(0) + ok, why := svc.startDispatch(ctx, WatchOptions{Dispatch: dispatchOn(), Command: []string{script}, MaxAttempts: 5}, pool, report) + pool.wait() + if !ok { + t.Fatalf("dispatch refused with --max-attempts 5 after 3 attempts: %s", why) + } + + // A recorded value still outranks it: the budget belongs to the project. + spendAttempts(t, store, repo, 9, 1) + if _, err := svc.SetSolver(ctx, repo, SolverChange{MaxAttempts: intptr(2)}); err != nil { + t.Fatal(err) + } + pool = newDispatchPool(0) + ok, why = svc.startDispatch(ctx, WatchOptions{Dispatch: dispatchOn(), Command: []string{script}, MaxAttempts: 5}, pool, report) + pool.wait() + if ok { + t.Fatal("dispatch ran past the repository's recorded budget of 2") + } + if !strings.Contains(why, "dispatch attempts already made") { + t.Errorf("refusal = %q, want the attempt bound to be the reason", why) + } +} + +// spendAttempts records n finished dispatch attempts on a round, the way a +// session that ran and released its claim leaves it. +func spendAttempts(t *testing.T, store StateStore, repo string, pr, n int) { + t.Helper() + _, err := store.Update(context.Background(), func(st *State) error { + r := st.Round(repo, pr) + if r == nil { + t.Fatalf("no round for %s#%d", repo, pr) + } + for i := 0; i < n; i++ { + if ok, why := r.ClaimDispatch("host", "tok", time.Now().UTC(), 0); !ok { + t.Fatalf("seeding attempt %d: %s", i, why) + } + r.ReleaseDispatch("tok") + } + st.PutRound(*r) + return nil + }) + if err != nil { + t.Fatal(err) + } } -func (l *loadHookedStore) Load(ctx context.Context) (State, Revision, error) { - st, rev, err := l.StateStore.Load(ctx) - if l.hook != nil { - hook := l.hook - l.hook = nil - hook() +// "Stop reviewing" has to stop a fix session too, and it has to stop one that +// is being granted right now. The pass reads enrollment once and best-effort, +// so a click landing after that read — or a read that failed — would otherwise +// let a coding agent start on a repository somebody just turned off. +func TestDispatchRefusesADisabledRepositoryUnderTheClaim(t *testing.T) { + ctx := context.Background() + repo := "owner/thing" + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{repo: true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + seedRound(t, store, cfg, repo, 9, "abcdef123", PhaseQueued, time.Now().UTC(), 0) + + // Disabled AFTER the pass would have read enrollment, which is the window. + if _, err := svc.SetEnrollment(ctx, repo, false, "stopped from the dashboard"); err != nil { + t.Fatal(err) + } + claimed, why, byDesign := svc.claimDispatch(ctx, + NextReport{Repo: repo, PR: 9, Head: "abcdef123", Action: "fix"}, "tok", 3) + if claimed { + t.Fatal("a fix session was granted for a repository crq is not reviewing") + } + if !byDesign { + t.Errorf("refusal %q must count as the gate working, not as a dispatcher failure", why) + } + if !strings.Contains(why, "not reviewing") { + t.Errorf("refusal = %q, want enrollment named as the reason", why) } - return st, rev, err } -// A fleet-wide scan is long, and `Next` is a mutating oracle that does not -// enforce the body marker itself. Reusing the policy read at the top of the -// pass therefore spent a metered review on every candidate still to come after -// the fleet opted them out. -func TestWatchRereadsTheSkipMarkerBeforeEachCandidate(t *testing.T) { +// Enrollment is a fleet-wide record and autoreview already builds its scan list +// from it. The watcher read only CRQ_REPOS, so a repository enrolled from the +// dashboard was reviewed but never watched — its findings arrived and no fix +// session ever started — until somebody edited an env file on this host and +// reinstalled the service. +func TestWatchTargetsIncludeRepositoriesEnrolledFromTheDashboard(t *testing.T) { ctx := context.Background() cfg := firingConfig() - cfg.AllowRepos = map[string]bool{"owner/repo": true} + cfg.AllowRepos = map[string]bool{"owner/known": true} gh := newFakeGitHub() - var pull ghapi.Pull - pull.State, pull.Number, pull.Head.SHA = "open", 1, "aaaaaaaa1" - pull.Body = "Tiny change.\n\n" - gh.pulls[fakeKey("owner/repo", 1)] = pull - + for _, repo := range []string{"owner/known", "owner/enrolled"} { + var pull ghapi.Pull + pull.State, pull.Number, pull.Head.SHA = "open", 1, "aaaaaaaa1" + gh.pulls[fakeKey(repo, 1)] = pull + } store := NewMemoryStore(cfg) - hooked := &loadHookedStore{StateStore: store} - svc := NewService(cfg, gh, hooked, nil) - hooked.hook = func() { - if err := svc.SetFleetConfig(ctx, "skip-marker", ""); err != nil { - t.Error(err) - } + svc := NewService(cfg, gh, store, nil) + if _, err := svc.SetEnrollment(ctx, "owner/enrolled", true, ""); err != nil { + t.Fatal(err) } - var seen []WatchEvent + seen := map[string]bool{} if err := svc.watchPass(ctx, WatchOptions{}, newDispatchPool(0), - func(e WatchEvent) error { seen = append(seen, e); return nil }); err != nil { + func(e WatchEvent) error { seen[NormalizeRepo(e.Repo)] = true; return nil }); err != nil { t.Fatal(err) } - if len(seen) != 1 || seen[0].Action != "skipped" { - t.Fatalf("events = %+v, want the marker adopted mid-pass to skip the candidate", seen) + if !seen["owner/enrolled"] { + t.Errorf("watched %v, want the repository the record enrolled", seen) } - st, _, err := store.Load(ctx) + if !seen["owner/known"] { + t.Errorf("watched %v, want this host's own list still honoured", seen) + } +} + +func TestWatchTargetsIncludeMigratedFleetAllowList(t *testing.T) { + ctx := context.Background() + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_REPOS": "owner/startup", + }) if err != nil { t.Fatal(err) } - if round := st.Round("owner/repo", 1); round != nil { - t.Fatalf("round = %+v, want an opted-out PR never queued", round) + gh := newFakeGitHub() + for _, repo := range []string{"owner/startup", "owner/migrated"} { + var pull ghapi.Pull + pull.State, pull.Number, pull.Head.SHA = "open", 1, "aaaaaaaa1" + gh.pulls[fakeKey(repo, 1)] = pull + } + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.Fleet.Env = map[string]string{"CRQ_REPOS": "owner/migrated"} + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, gh, store, nil) + + seen := map[string]bool{} + if err := svc.watchPass(ctx, WatchOptions{}, newDispatchPool(0), + func(e WatchEvent) error { seen[NormalizeRepo(e.Repo)] = true; return nil }); err != nil { + t.Fatal(err) + } + if !seen["owner/migrated"] { + t.Errorf("watched %v, want the repository from the migrated fleet allow-list", seen) + } + if seen["owner/startup"] { + t.Errorf("watched %v, startup allow-list should be replaced by the migrated fleet policy", seen) } } -// The candidates are gathered under the policy the pass started with, and `Next` -// treats its target as a manual request — so a repository the fleet dropped -// mid-pass still had its PRs enqueued and reviewed by the pass that was already -// running, in a repository crq no longer watches. -func TestWatchRereadsTheRepositoryListBeforeEachCandidate(t *testing.T) { +// The fork switch is not a refinement of a setting; it is the line between +// running an agent over the operator's own code and over a stranger's. When the +// shared record cannot be read, falling back to a permissive host value would +// re-enable fork dispatches at precisely the moment the safety policy is +// unavailable, so the fallback denies them instead. +func TestForkDispatchFallsBackClosedWhenTheRecordCannotBeRead(t *testing.T) { ctx := context.Background() cfg := firingConfig() - cfg.AllowRepos = map[string]bool{"owner/repo": true} + cfg.DispatchForks = true // this host's env says yes + svc := NewService(cfg, newFakeGitHub(), unreadableStore{NewMemoryStore(cfg)}, nil) + + var fork ghapi.Pull + fork.Head.Repo.FullName = "contributor/thing" + if svc.mayDispatch(svc.repoCfg(ctx, "owner/thing"), "owner/thing", fork) { + t.Error("a fork was dispatched on a permissive env value while the shared policy was unreadable") + } + // Everything else still falls back to the host's configuration: an + // unreadable setting must not stop crq fixing its own branches. + var own ghapi.Pull + own.Head.Repo.FullName = "owner/thing" + if !svc.mayDispatch(svc.repoCfg(ctx, "owner/thing"), "owner/thing", own) { + t.Error("an own-repository pull request must still be dispatchable") + } +} + +// unreadableStore is a state ref that cannot be read — a transient outage, a +// revoked token, a ref that has not been created yet. +type unreadableStore struct{ StateStore } + +func (unreadableStore) Load(context.Context) (State, Revision, error) { + return State{}, Revision{}, errors.New("state ref unreadable") +} + +type loadSwitchStore struct { + StateStore + unreadable bool +} + +func (s *loadSwitchStore) Load(ctx context.Context) (State, Revision, error) { + if s.unreadable { + return State{}, Revision{}, errors.New("state ref unreadable") + } + return s.StateStore.Load(ctx) +} + +func TestWatchPassStopsWhenSharedStateCannotBeRead(t *testing.T) { + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/r": true} gh := newFakeGitHub() - var pull ghapi.Pull - pull.State, pull.Number, pull.Head.SHA = "open", 1, "aaaaaaaa1" - gh.pulls[fakeKey("owner/repo", 1)] = pull + gh.searchPRs = []ghapi.SearchPR{{Repo: "o/r", Number: 1}} + svc := NewService(cfg, gh, unreadableStore{NewMemoryStore(cfg)}, nil) + err := svc.watchPass(context.Background(), WatchOptions{}, newDispatchPool(0), nil) + if err == nil || !strings.Contains(err.Error(), "loading shared state") { + t.Fatalf("watchPass error = %v, want shared-state load failure", err) + } +} + +// A solver record that names an author to skip has to reach the watcher too. +// Next is a MUTATING oracle — it enqueues, it can fire, and it can start a fix +// session that runs an agent with approvals bypassed — so a watcher that +// checked only the skip marker reviewed and executed against pull requests the +// per-repository setting had just excluded. +func TestWatchHonoursTheSkipAuthorList(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/r": true} + gh := newFakeGitHub() + var bot ghapi.Pull + bot.State = "open" + bot.Number = 9 + bot.Head.SHA = "abcdef1234567890" + bot.User.Login = "dependabot[bot]" + gh.pulls[fakeKey("o/r", 9)] = bot + var human ghapi.Pull + human.State = "open" + human.Number = 10 + human.Head.SHA = "beefbeef12345678" + human.User.Login = "kristofferR" + gh.pulls[fakeKey("o/r", 10)] = human store := NewMemoryStore(cfg) - hooked := &loadHookedStore{StateStore: store} - svc := NewService(cfg, gh, hooked, nil) - hooked.hook = func() { - if err := svc.SetFleetConfig(ctx, "repos", "owner/other"); err != nil { - t.Error(err) - } + svc := NewService(cfg, gh, store, nil) + + if _, err := svc.SetSolver(ctx, "o/r", SolverChange{SkipAuthors: []string{"dependabot[bot]"}}); err != nil { + t.Fatal(err) } - var seen []WatchEvent - if err := svc.watchPass(ctx, WatchOptions{}, newDispatchPool(0), - func(e WatchEvent) error { seen = append(seen, e); return nil }); err != nil { + var events []WatchEvent + if err := svc.watchPass(ctx, WatchOptions{}, newDispatchPool(0), func(event WatchEvent) error { + events = append(events, event) + return nil + }); err != nil { t.Fatal(err) } - if len(seen) != 0 { - t.Fatalf("events = %+v, want no work in a repository the fleet dropped mid-pass", seen) + var skipped *WatchEvent + for i := range events { + if events[i].PR == 9 { + skipped = &events[i] + } + } + if skipped == nil || skipped.Action != "skipped" || skipped.Reason == "" { + t.Fatalf("events = %#v, want the skipped author explained", events) } st, _, err := store.Load(ctx) if err != nil { t.Fatal(err) } - if round := st.Round("owner/repo", 1); round != nil { - t.Fatalf("round = %+v, want an unwatched repository never queued", round) + if st.Round("o/r", 9) != nil { + t.Error("a skipped author's pull request was passed to the mutating Next oracle") + } + if st.Round("o/r", 10) == nil { + t.Error("the rest of the repository must still be watched") } } -// Emptying the list is the same removal. `crq watch` has no scope fallback — a -// pass with no repositories of its own refuses to run at all — so an emptied -// list left the candidates of the pass already running as the only repositories -// crq would ever act on, each one no longer covered by the live policy. -func TestWatchDropsCandidatesWhenTheRepositoryListIsEmptied(t *testing.T) { +// The attempt budget is fleet-settable, and the settings editor saves it into +// the generic fleet map. A claim that consulted only the typed solver record +// went on enforcing this host's number while the dashboard reported the +// fleet's — with no later state read able to change it. +func TestRecordedMaxAttemptsReadsBothRecords(t *testing.T) { ctx := context.Background() - cfg := firingConfig() - cfg.AllowRepos = map[string]bool{"owner/repo": true} - gh := newFakeGitHub() - var pull ghapi.Pull - pull.State, pull.Number, pull.Head.SHA = "open", 1, "aaaaaaaa1" - gh.pulls[fakeKey("owner/repo", 1)] = pull + cfg, err := BuildConfig(map[string]string{ + "CRQ_REPO": "owner/gate", "CRQ_HOST": "testhost", "CRQ_COBOTS": "", + }) + if err != nil { + t.Fatal(err) + } + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + + // Nothing recorded: this run's own flag stands, which is the whole reason a + // RECORDED value is asked for rather than a resolved one. + if got := svc.recordedMaxAttempts(ctx, "o/r", 7); got != 7 { + t.Errorf("attempts = %d, want this run's own 7", got) + } + if _, err := svc.SetEnv(ctx, "CRQ_DISPATCH_MAX_ATTEMPTS", "9", false); err != nil { + t.Fatal(err) + } + if got := svc.recordedMaxAttempts(ctx, "o/r", 7); got != 9 { + t.Errorf("attempts = %d, want the fleet's recorded 9", got) + } + // The per-repository record is more specific and wins outright. + if _, err := svc.SetSolver(ctx, "o/r", SolverChange{MaxAttempts: intptr(2)}); err != nil { + t.Fatal(err) + } + if got := svc.recordedMaxAttempts(ctx, "o/r", 7); got != 2 { + t.Errorf("attempts = %d, want the repository's 2", got) + } + if got := svc.recordedMaxAttempts(ctx, "o/other", 7); got != 9 { + t.Errorf("attempts = %d, want another repository still on the fleet's 9", got) + } +} +func TestDispatchClaimResolvesCurrentSolverSettingsInsideCAS(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.FixModels = []string{"old-model"} + cfg.FixModel = "old-model" store := NewMemoryStore(cfg) - hooked := &loadHookedStore{StateStore: store} - svc := NewService(cfg, gh, hooked, nil) + repo, pr, head := "o/r", 19, "abcdef123" + seedRound(t, store, cfg, repo, pr, head, PhaseQueued, time.Now().UTC(), 0) + + hooked := &hookedStore{StateStore: store} hooked.hook = func() { - if err := svc.SetFleetConfig(ctx, "repos", ""); err != nil { + if _, err := store.Update(ctx, func(st *State) error { + sv := st.EffectiveSolver(repo) + sv.Models, sv.SetModels, sv.Model = []string{"new-model"}, true, "new-model" + sv.MaxAttempts = intptr(1) + st.SetSolver(repo, sv, "operator", time.Now().UTC()) + return nil + }); err != nil { t.Error(err) } } + svc := NewService(cfg, newFakeGitHub(), hooked, nil) + report := NextReport{Repo: repo, PR: pr, Head: head, Action: "fix"} - var seen []WatchEvent - if err := svc.watchPass(ctx, WatchOptions{}, newDispatchPool(0), - func(e WatchEvent) error { seen = append(seen, e); return nil }); err != nil { - t.Fatal(err) + ok, why, _, model := svc.claimDispatchModels(ctx, &report, "first", 5) + if !ok { + t.Fatalf("first claim: %s", why) } - if len(seen) != 0 { - t.Fatalf("events = %+v, want no work once the fleet's list is emptied mid-pass", seen) + if model != "new-model" { + t.Fatalf("selected model = %q, want the ranking written immediately before the CAS", model) } - st, _, err := store.Load(ctx) - if err != nil { + svc.releaseDispatch(ctx, report, "first", true) + + if ok, why, byDesign, _ := svc.claimDispatchModels(ctx, &report, "second", 5); ok { + t.Fatal("a second claim exceeded the current one-attempt limit") + } else if !byDesign || !strings.Contains(why, "attempt") { + t.Fatalf("second claim = byDesign %v reason %q, want the current attempt limit", byDesign, why) + } +} + +func TestDispatchClaimFiltersFindingsWithCurrentSeverityPolicy(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"o/r": true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + repo, pr, head := "o/r", 29, "abcdef123" + if _, err := svc.SetSolver(ctx, repo, SolverChange{Severities: []string{"minor"}}); err != nil { t.Fatal(err) } - if round := st.Round("owner/repo", 1); round != nil { - t.Fatalf("round = %+v, want an unwatched repository never queued", round) + seedRound(t, store, cfg, repo, pr, head, PhaseQueued, time.Now().UTC(), 0) + report := NextReport{ + Repo: repo, PR: pr, Head: head, Action: "fix", + Findings: []dialect.Finding{ + {ID: "major", Severity: "major"}, + {ID: "minor", Severity: "minor"}, + }, } -} -// A repository named on the command line is the operator's own request and -// outranks the fleet's list, emptied or not — only the exclusion overrides it. -func TestWatchesRepoHonoursExplicitTargetsAndExclusion(t *testing.T) { - empty := Config{} - if !watchesRepo(empty, []string{"owner/repo"}, "owner/repo") { - t.Error("an explicitly requested repository must stay watched with no fleet list") + ok, why, _, _ := svc.claimDispatchModels(ctx, &report, "severity-token", 5) + if !ok { + t.Fatalf("claim refused: %s", why) } - excluded := Config{ExcludeRepos: map[string]bool{"owner/repo": true}} - if watchesRepo(excluded, []string{"owner/repo"}, "owner/repo") { - t.Error("exclusion outranks an explicit target") + if len(report.Findings) != 1 || report.Findings[0].Severity != "minor" { + t.Fatalf("claimed findings = %+v, want only the severity allowed by the claiming state revision", report.Findings) } } diff --git a/internal/dialect/agent.go b/internal/dialect/agent.go new file mode 100644 index 00000000..8c18e143 --- /dev/null +++ b/internal/dialect/agent.go @@ -0,0 +1,100 @@ +package dialect + +import ( + "encoding/json" + "strconv" + "strings" + "time" +) + +// AgentFailure describes a provider/model failure that should be retried on a +// fallback rather than counted as a failed attempt to change code. +type AgentFailure struct { + Unavailable bool + RetryAt time.Time + Reason string +} + +// ClassifyAgentFailure recognizes machine-readable outage responses emitted by +// supported coding agents. The transcript also contains repository-controlled +// command output, test failures, source code, and the review findings +// themselves, so free-text markers are not trustworthy evidence. Unknown +// failures stay ordinary fix failures: refunding a genuine bad fix forever +// would remove the loop's safety bound. +func ClassifyAgentFailure(log []byte, now time.Time) AgentFailure { + found := false + var resetAt int64 + for _, line := range strings.Split(string(log), "\n") { + var event agentEvent + if json.Unmarshal([]byte(line), &event) != nil || !event.providerUnavailable() { + continue + } + found = true + if unix, ok := event.resetUnix(); ok { + resetAt = unix + } + } + if !found { + return AgentFailure{} + } + + retryAt := now.UTC().Add(15 * time.Minute) + if resetAt != 0 { + retryAt = time.Unix(resetAt, 0).UTC() + } + if !retryAt.After(now) { + retryAt = now.UTC().Add(5 * time.Minute) + } + // Agent output is influenced by repository code. Never let a bogus reset + // timestamp park a model for this head indefinitely. + if latest := now.UTC().Add(7 * 24 * time.Hour); retryAt.After(latest) { + retryAt = latest + } + return AgentFailure{ + Unavailable: true, + RetryAt: retryAt, + Reason: "provider or model temporarily unavailable", + } +} + +// agentEvent is deliberately only the outer protocol envelope. Repository +// output is embedded inside assistant/user events and may itself contain +// outage-shaped JSON; recursively scanning it would let a test refund a failed +// fix and bypass the per-head attempt bound. +type agentEvent struct { + Type string `json:"type"` + Error json.RawMessage `json:"error"` + ResetsAt json.RawMessage `json:"resetsAt"` +} + +func (e agentEvent) providerUnavailable() bool { + if e.Type != "result" && e.Type != "error" { + return false + } + var direct string + if json.Unmarshal(e.Error, &direct) == nil { + return strings.EqualFold(direct, "rate_limit") + } + var nested struct { + Type string `json:"type"` + } + if json.Unmarshal(e.Error, &nested) != nil { + return false + } + return strings.EqualFold(nested.Type, "rate_limit_error") || + strings.EqualFold(nested.Type, "overloaded_error") +} + +func (e agentEvent) resetUnix() (int64, bool) { + var number json.Number + if json.Unmarshal(e.ResetsAt, &number) == nil { + unix, err := strconv.ParseInt(string(number), 10, 64) + return unix, err == nil + } + var text string + if json.Unmarshal(e.ResetsAt, &text) == nil { + unix, err := strconv.ParseInt(text, 10, 64) + return unix, err == nil + } + return 0, false +} diff --git a/internal/dialect/agent_test.go b/internal/dialect/agent_test.go new file mode 100644 index 00000000..2160d46a --- /dev/null +++ b/internal/dialect/agent_test.go @@ -0,0 +1,40 @@ +package dialect + +import ( + "fmt" + "testing" + "time" +) + +func TestClassifyAgentFailure(t *testing.T) { + now := time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC) + reset := now.Add(2 * time.Hour) + log := []byte(fmt.Sprintf(`{"type":"result","error": "rate_limit","resetsAt":%d}`, reset.Unix())) + got := ClassifyAgentFailure(log, now) + if !got.Unavailable { + t.Fatal("machine-readable provider outage was not classified") + } + if got.RetryAt.Unix() != reset.Unix() { + t.Fatalf("retry = %s, want timestamp from the response", got.RetryAt) + } + if ordinary := ClassifyAgentFailure([]byte("tests failed after editing"), reset); ordinary.Unavailable { + t.Fatal("an ordinary bad fix was refunded as a provider outage") + } + repositoryOutput := []byte(`The review says "service unavailable". +The test printed: rate limit exceeded. +source := ` + "`\"type\":\"overloaded_error\"`" + ` +`) + if ordinary := ClassifyAgentFailure(repositoryOutput, reset); ordinary.Unavailable { + t.Fatal("repository-controlled text was treated as a provider outage") + } + repositoryJSON := []byte( + `{"type":"assistant","message":{"content":[{"type":"tool_result","content":{"error":"rate_limit","resetsAt":4102444800}}]}}`, + ) + if ordinary := ClassifyAgentFailure(repositoryJSON, reset); ordinary.Unavailable { + t.Fatal("repository-controlled JSON was treated as a provider outage") + } + apiError := []byte(`{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`) + if outage := ClassifyAgentFailure(apiError, reset); !outage.Unavailable { + t.Fatal("the provider's top-level error envelope was not classified") + } +} diff --git a/internal/dialect/coderabbit.go b/internal/dialect/coderabbit.go index d3442ff2..8bcc8c5f 100644 --- a/internal/dialect/coderabbit.go +++ b/internal/dialect/coderabbit.go @@ -459,6 +459,29 @@ type CLIError struct { // OrgAttributed distinguishes an organisation-wide limit from this user's // own. Only the former says anything about the shared account. OrgAttributed bool + // ProUser is the CLI's own "isProUser". It says which plan's allowance the + // limit belongs to, which is what decides whether a review past it costs + // money or simply waits. + ProUser bool + // PolicyGuidance is the CLI's prose about what to do, which is the only + // place it states the overage price. Kept verbatim: it is a vendor string + // and paraphrasing it would put a price in crq's mouth. + PolicyGuidance string + // UsageBasedEnabled is read OFF that prose: when the CLI offers to enable + // usage-based reviews, they are not enabled, so exhausting the allowance + // stops work rather than billing for it. + UsageBasedEnabled bool +} + +// usageBasedAlreadyEnabled reports whether the guidance does not invite the +// reader to enable usage-based reviews. +func usageBasedAlreadyEnabled(guidance string) bool { + if guidance == "" { + return false + } + lower := strings.ToLower(guidance) + return !strings.Contains(lower, "enable **[usage-based reviews]") && + !strings.Contains(lower, "enable usage-based reviews") } // ParseCLIError reads an error event from the CLI's --agent JSON stream. @@ -473,6 +496,11 @@ func ParseCLIError(event map[string]any) CLIError { if v, ok := meta["orgAttributed"].(bool); ok { out.OrgAttributed = v } + if v, ok := meta["isProUser"].(bool); ok { + out.ProUser = v + } + out.PolicyGuidance = cliStringField(meta, "policyGuidance") + out.UsageBasedEnabled = usageBasedAlreadyEnabled(out.PolicyGuidance) } return out } diff --git a/internal/dialect/codex.go b/internal/dialect/codex.go index 0ca66e3a..a3aa3cbd 100644 --- a/internal/dialect/codex.go +++ b/internal/dialect/codex.go @@ -144,9 +144,15 @@ func ParseCodexReviewFindings(body string, review ReviewMeta, bot string) []Find if title == "" { title = TitleOf(block) } + labels := ReviewLabelsFor(bot, block) + severity := labels.Severity + if severity == "" { + severity = CodexPrioritySeverity(block) + } finding := Finding{ Bot: bot, - Severity: CodexPrioritySeverity(block), + Severity: severity, + Scale: labels.Scale, Path: path, Line: line, Title: title, diff --git a/internal/dialect/common.go b/internal/dialect/common.go index e78a76d3..0a50df33 100644 --- a/internal/dialect/common.go +++ b/internal/dialect/common.go @@ -5,6 +5,37 @@ import ( "strings" ) +const ( + SeverityCritical = "critical" + SeverityMajor = "major" + SeverityPotential = "potential" + SeverityMinor = "minor" + SeverityUnknown = "unknown" +) + +var severities = [...]string{ + SeverityCritical, + SeverityMajor, + SeverityPotential, + SeverityMinor, + SeverityUnknown, +} + +// KnownSeverities returns the normalized severity vocabulary emitted by review +// bots and accepted by autofix configuration. +func KnownSeverities() []string { + return append([]string(nil), severities[:]...) +} + +func IsSeverity(severity string) bool { + for _, known := range severities { + if severity == known { + return true + } + } + return false +} + var ( boldTitleRE = regexp.MustCompile(`(?m)^\*\*([^*\n]+)\*\*`) crCommentRE = regexp.MustCompile(``) @@ -16,16 +47,25 @@ func SeverityOf(text string) string { lower := strings.ToLower(text) switch { case strings.Contains(lower, "critical"), strings.Contains(lower, "🔴"): - return "critical" + return SeverityCritical case strings.Contains(lower, "major"), strings.Contains(lower, "high"), strings.Contains(lower, "🟠"): - return "major" + return SeverityMajor case strings.Contains(lower, "potential issue"), strings.Contains(lower, "medium"), strings.Contains(lower, "🟡"): - return "potential" + return SeverityPotential case strings.Contains(lower, "nitpick"), strings.Contains(lower, "minor"), strings.Contains(lower, "low"), strings.Contains(lower, "🔵"): - return "minor" + return SeverityMinor default: - return "unknown" + return SeverityUnknown + } +} + +// SeverityFor prefers the reviewer's structured scale, then falls back to the +// generic vocabulary for formats without one. +func SeverityFor(bot, text string) string { + if labels := ReviewLabelsFor(bot, text); labels.Severity != "" { + return labels.Severity } + return SeverityOf(text) } // FloorSeverity raises sev to at least floor by rank ("unknown" ranks lowest), @@ -39,13 +79,13 @@ func FloorSeverity(sev, floor string) string { func RankSeverity(sev string) int { switch sev { - case "critical": + case SeverityCritical: return 5 - case "major": + case SeverityMajor: return 4 - case "potential": + case SeverityPotential: return 3 - case "minor": + case SeverityMinor: return 2 default: return 1 diff --git a/internal/dialect/coreviewer.go b/internal/dialect/coreviewer.go index c8e4931d..e28d29b0 100644 --- a/internal/dialect/coreviewer.go +++ b/internal/dialect/coreviewer.go @@ -124,6 +124,10 @@ type CoReviewer struct { // FindingDedupeKey extracts a bot-stable finding identity (Bugbot BUG_ID) // so the same bug re-reported in a new thread dedupes to one finding. FindingDedupeKey func(body string) (string, bool) + // Price estimates what one review of this diff costs. Nil means the bot + // charges nothing per review — it is covered by a subscription — which is + // true of every co-reviewer here except Macroscope. + Price func(d DiffStat) CostEstimate } // Is reports whether login names this co-reviewer, tolerating the "[bot]" @@ -206,6 +210,8 @@ func KnownCoReviewers() []CoReviewer { ClassifyComment: ClassifyMacroscopeComment, ClassifyCheck: ClassifyMacroscopeCheck, ResolvedInSHA: MacroscopeResolvedInSHA, + // The one co-reviewer that bills per review rather than per seat. + Price: EstimateMacroscope, }, } } diff --git a/internal/dialect/event.go b/internal/dialect/event.go index 47eb266f..3fe6e17b 100644 --- a/internal/dialect/event.go +++ b/internal/dialect/event.go @@ -83,13 +83,15 @@ func (e BotEvent) ObservedTime() time.Time { } // Classifier classifies issue comments into BotEvents. Bot is the configured -// CodeRabbit login; ReviewCommand is the exact trigger comment body; +// primary login; ReviewCommand is the exact trigger comment body; Primary +// carries registry wording hooks when that login is a known co-reviewer; // CoReviewers are the enabled co-reviewer entries with their config-resolved // trigger commands (empty: no co-reviewer classification at all). type Classifier struct { CodeRabbit CodeRabbit Bot string ReviewCommand string + Primary *CoReviewer CoReviewers []CoReviewer } @@ -112,6 +114,25 @@ func (c Classifier) Classify(author, body string, id int64, createdAt, updatedAt return ev } } + if fromConfigured && c.Primary != nil && c.Primary.Is(author) { + if c.Primary.ClassifyComment == nil { + return ev + } + primary := c.Primary.ClassifyComment(body) + ev.SHA = primary.SHA + ev.Approved = primary.Approved + switch primary.Kind { + case EvCoClean: + ev.Kind = EvNoAction + case EvCoUnable: + // A registry primary that cannot produce the requested review is a + // failed attempt, not an optional co-reviewer disengaging its gate. + ev.Kind = EvFailed + default: + ev.Kind = EvOther + } + return ev + } for _, co := range c.CoReviewers { if !co.Is(author) { continue diff --git a/internal/dialect/finding.go b/internal/dialect/finding.go index 61db6210..b44f3f0c 100644 --- a/internal/dialect/finding.go +++ b/internal/dialect/finding.go @@ -17,6 +17,9 @@ type Finding struct { ID string `json:"id"` Bot string `json:"bot"` Severity string `json:"severity"` + Scale string `json:"scale,omitempty"` + Category string `json:"category,omitempty"` + Effort string `json:"effort,omitempty"` Path string `json:"path,omitempty"` Line int `json:"line,omitempty"` Title string `json:"title"` diff --git a/internal/dialect/golden_test.go b/internal/dialect/golden_test.go index fecb4787..56f82f9f 100644 --- a/internal/dialect/golden_test.go +++ b/internal/dialect/golden_test.go @@ -50,6 +50,9 @@ func TestGoldenClassification(t *testing.T) { // wantKind == EvOther (the zero value) skips the Classify assertion. author string wantKind EventKind + // registryPrimary classifies the fixture with Codex promoted from a + // co-reviewer to the configured primary. + registryPrimary bool }{ {file: "coderabbit/rate-limit-fair-usage.md", rateLimited: true, autoReply: true, availableIn: 48 * time.Minute}, // Contains the "does not re-review" boilerplate in its help section — @@ -88,6 +91,10 @@ func TestGoldenClassification(t *testing.T) { {file: "codex/clean-summary-legacy.md", codexClean: true, noAction: true, nonActionable: true, author: "chatgpt-codex-connector[bot]", wantKind: EvCoClean}, {file: "codex/clean-summary-tada.md", codexClean: true, noAction: true, nonActionable: true, reviewedSHA: "4d9e8bca82", author: "chatgpt-codex-connector[bot]", wantKind: EvCoClean}, {file: "codex/usage-limit.md", codexUsageLimit: true, nonActionable: true, author: "chatgpt-codex-connector[bot]", wantKind: EvCoUnable}, + {file: "codex/clean-summary-tada.md", codexClean: true, noAction: true, nonActionable: true, reviewedSHA: "4d9e8bca82", + author: "chatgpt-codex-connector[bot]", wantKind: EvNoAction, registryPrimary: true}, + {file: "codex/usage-limit.md", codexUsageLimit: true, nonActionable: true, + author: "chatgpt-codex-connector[bot]", wantKind: EvFailed, registryPrimary: true}, // Codex's "create an environment" platform ad, posted as a thread reply — // never a finding, never a rebuttal. {file: "codex/environment-notice.md", nonActionable: true, author: "chatgpt-codex-connector[bot]", wantKind: EvCoNotice}, @@ -96,8 +103,20 @@ func TestGoldenClassification(t *testing.T) { base := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) classifier := Classifier{CodeRabbit: goldenCR, Bot: "coderabbitai[bot]", ReviewCommand: "@coderabbitai review", CoReviewers: KnownCoReviewers()} for _, tc := range cases { - t.Run(tc.file, func(t *testing.T) { + name := tc.file + if tc.registryPrimary { + name += "/registry-primary" + } + t.Run(name, func(t *testing.T) { body := readGolden(t, tc.file) + activeClassifier := classifier + if tc.registryPrimary { + primary, ok := CoReviewerByName("codex") + if !ok { + t.Fatal("Codex registry entry is missing") + } + activeClassifier.Bot, activeClassifier.Primary = primary.Login, &primary + } checks := []struct { name string got bool @@ -134,7 +153,7 @@ func TestGoldenClassification(t *testing.T) { t.Errorf("CodexReviewedCommitSHA = %q, want %q", got, tc.reviewedSHA) } if tc.wantKind != EvOther { - if got := classifier.Classify(tc.author, body, 1, base, base).Kind; got != tc.wantKind { + if got := activeClassifier.Classify(tc.author, body, 1, base, base).Kind; got != tc.wantKind { t.Errorf("Classify kind = %v, want %v", got, tc.wantKind) } } @@ -162,6 +181,7 @@ func TestGoldenFindings(t *testing.T) { path string line int severity string // "" = don't check + scale string // "" = don't check title string // "" = don't check source string commit string // "" = don't check @@ -200,7 +220,7 @@ func TestGoldenFindings(t *testing.T) { { file: "codex/findings-outside-diff.md", bot: "chatgpt-codex-connector[bot]", - want: []want{{path: "convex/sections/aiCommands.ts", line: 2170, severity: "minor", title: "Query learning history by topic before taking", source: "review_body", commit: "347388ffd"}}, + want: []want{{path: "convex/sections/aiCommands.ts", line: 2170, severity: "potential", scale: "P2", title: "Query learning history by topic before taking", source: "review_body", commit: "347388ffd"}}, }, } for _, tc := range cases { @@ -218,6 +238,9 @@ func TestGoldenFindings(t *testing.T) { if w.severity != "" && f.Severity != w.severity { t.Errorf("finding %d severity = %q, want %q", i, f.Severity, w.severity) } + if w.scale != "" && f.Scale != w.scale { + t.Errorf("finding %d scale = %q, want %q", i, f.Scale, w.scale) + } if w.title != "" && f.Title != w.title { t.Errorf("finding %d title = %q, want %q", i, f.Title, w.title) } @@ -241,6 +264,7 @@ func TestGoldenReplyVerdict(t *testing.T) { retained bool }{ {file: "coderabbit/reply-withdrawn.md", withdrawn: true}, + {file: "coderabbit/reply-withdrawn-confirmed.md", withdrawn: true}, // A concession whose PROSE reads like agreement, not like the stock // "withdrawing this" phrasing. CodeRabbit ships a machine-readable // marker with it; matching that is what keeps a settled finding from @@ -632,3 +656,110 @@ func TestGoldenCLIRateLimit(t *testing.T) { } } } + +// TestGoldenPricing pins the money vocabulary against the same corpus every +// other classifier is pinned against. Both payloads were already captured and +// their billing fields discarded; these rows are the spec for reading them. +func TestGoldenPricing(t *testing.T) { + t.Run("macroscope agent credits", func(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "macroscope", "check-custom.json")) + if err != nil { + t.Fatal(err) + } + var run struct { + Output struct { + Text string `json:"text"` + } `json:"output"` + } + if err := json.Unmarshal(raw, &run); err != nil { + t.Fatal(err) + } + credits, ok := ParseAgentCredits(run.Output.Text) + if !ok || credits != 81 { + t.Fatalf("credits = %d, %v; want 81, true", credits, ok) + } + // A body without the line is not zero credits — it is no answer. + if _, ok := ParseAgentCredits("no credits line here"); ok { + t.Error("a body with no credit line must report absence, not 0") + } + }) + + t.Run("coderabbit cli billing metadata", func(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "coderabbit", "cli-rate-limit.json")) + if err != nil { + t.Fatal(err) + } + var event map[string]any + if err := json.Unmarshal(raw, &event); err != nil { + t.Fatal(err) + } + got := ParseCLIError(event) + if got.ProUser { + t.Error("isProUser is false in this capture") + } + if !strings.Contains(got.PolicyGuidance, "$0.25/file") { + t.Errorf("policy guidance = %q, want the vendor's own overage price kept verbatim", got.PolicyGuidance) + } + // The guidance INVITES enabling usage-based reviews, so they are off — + // which means this block costs nothing and simply waits. + if got.UsageBasedEnabled { + t.Error("guidance offering to enable usage-based reviews means they are not enabled") + } + }) + + t.Run("estimates", func(t *testing.T) { + // Under the 10 KB minimum the floor IS the price, so it is exact. + small := EstimateMacroscope(DiffStat{Additions: 40, Deletions: 20, ChangedFiles: 3}) + if !small.Exact || small.Low != 0.50 || small.High != 0.50 { + t.Errorf("small diff = %+v, want an exact $0.50 floor", small) + } + // A large one is a range, and never above the per-review cap. + large := EstimateMacroscope(DiffStat{Additions: 40000, Deletions: 20000, ChangedFiles: 300}) + if large.High > 10.0 || large.Low != macroscopeMinKB*macroscopePerKB || large.Low >= large.High || large.Exact { + t.Errorf("large diff = %+v, want the incremental floor through the capped whole-diff upper bound", large) + } + + // A co-reviewer on its own subscription is free and says why; an + // unknown login is Unknown, never a confident $0.00. + if e := EstimateCost(CodexBotLogin, "coderabbitai[bot]", DiffStat{}, Allowance{}); !e.Exact || e.High != 0 || e.Basis == "" || e.Metered { + t.Errorf("codex = %+v, want an explained zero", e) + } + if e := EstimateCost("sonar[bot]", "coderabbitai[bot]", DiffStat{}, Allowance{}); !e.Unknown { + t.Errorf("unknown bot = %+v, want Unknown rather than free", e) + } + + // The VENDOR decides the basis, not the role. CRQ_BOT may name a + // registry bot, and pricing whichever one is configured on CodeRabbit's + // allowance model billed a Macroscope primary on the wrong basis + // entirely and hid a Codex primary's subscription behind an allowance + // it does not use. + big := DiffStat{Additions: 40000, Deletions: 20000, ChangedFiles: 300} + spent := Allowance{RemainingKnown: true, UsageBasedKnown: true, UsageBasedEnabled: true} + if e := EstimateCost(MacroscopeLogin, MacroscopeLogin, big, spent); e != EstimateMacroscope(big) { + t.Errorf("macroscope primary = %+v, want its own per-kilobyte price", e) + } + if e := EstimateCost(CodexBotLogin, CodexBotLogin, big, spent); !e.Exact || e.High != 0 { + t.Errorf("codex primary = %+v, want the subscription it is actually covered by", e) + } + + // The primary is free inside the allowance, unknown without a count, + // and priced per file only once past it WITH usage-based billing on. + d := DiffStat{ChangedFiles: 8} + if e := EstimateCodeRabbit("coderabbitai[bot]", d, Allowance{Remaining: 3, RemainingKnown: true}); !e.Exact || e.High != 0 || !e.Metered { + t.Errorf("inside allowance = %+v, want free", e) + } + if e := EstimateCodeRabbit("coderabbitai[bot]", d, Allowance{}); !e.Unknown { + t.Errorf("no count = %+v, want Unknown — absent is not exhausted", e) + } + if e := EstimateCodeRabbit("coderabbitai[bot]", d, Allowance{RemainingKnown: true}); !e.Unknown { + t.Errorf("exhausted, billing mode unknown = %+v, want Unknown — unknown is not off", e) + } + if e := EstimateCodeRabbit("coderabbitai[bot]", d, Allowance{RemainingKnown: true, UsageBasedKnown: true}); !e.Exact || e.High != 0 { + t.Errorf("exhausted with billing off = %+v, want free (it waits instead)", e) + } + e := EstimateCodeRabbit("coderabbitai[bot]", d, Allowance{RemainingKnown: true, UsageBasedKnown: true, UsageBasedEnabled: true}) + if e.High != 2.0 { + t.Errorf("exhausted with billing on = %+v, want up to 8 files x $0.25", e) + } + }) +} diff --git a/internal/dialect/pricing.go b/internal/dialect/pricing.go new file mode 100644 index 00000000..86e15e89 --- /dev/null +++ b/internal/dialect/pricing.go @@ -0,0 +1,220 @@ +package dialect + +import ( + "fmt" + "math" + "regexp" + "strconv" +) + +// PricesCheckedAt is the day every figure in this file was read off the +// vendor's own pricing page. It travels with the estimate and is displayed +// beside it, because a price nobody has re-checked is a number that quietly +// stops being true — CodeRabbit's are already roughly double the figures +// widely cited a year ago. +const PricesCheckedAt = "2026-07-27" + +// PricingDisclosure is the shared user-facing explanation of vendor billing. +// It lives beside the prices so changing a vendor term cannot leave the CLI and +// dashboard describing a different model from the estimator. +const PricingDisclosure = "Macroscope bills by diff size with a per-review minimum and charges incrementally after its first review of a pull request, so later rounds can cost less than this whole-head estimate. CodeRabbit uses the plan allowance first and may bill by reviewed file after it; path filters can reduce that figure. Codex and Cursor Bugbot are covered by their own subscriptions." + +// Published prices, per vendor. Named constants rather than literals in the +// arithmetic so re-checking them is reading one block, not auditing a function. +const ( + // Macroscope bills the diff it reviews: $0.05 per KB with a 10 KB minimum, + // so every review costs at least $0.50, capped at $10 per review (and $50 + // per pull request, which crq does not track across rounds). + macroscopePerKB = 0.05 + macroscopeMinKB = 10.0 + macroscopeMaxSpend = 10.0 + + // CodeRabbit charges nothing until the plan's allowance is spent; past it, + // usage-based reviews bill $0.25 per reviewed file. Only reached when the + // account has usage-based reviews enabled at all. + coderabbitPerFile = 0.25 +) + +// DiffStat is what a cost estimate needs to know about a head. Lines rather +// than bytes because that is what GitHub reports on a pull request without +// fetching the diff itself. +type DiffStat struct { + Additions int + Deletions int + ChangedFiles int +} + +// Lines is the diff's total changed lines. +func (d DiffStat) Lines() int { return d.Additions + d.Deletions } + +// bytesPerLineHigh turns a cumulative line count into a conservative whole-diff +// upper bound for Macroscope. Measurements over this repository reached 118 +// bytes per line on long-line-heavy diffs; later rounds may instead bill only +// their increment, whose lower bound is the vendor's per-review floor. +const bytesPerLineHigh = 120.0 + +// Allowance is the account state a CodeRabbit estimate depends on: whether the +// plan's included reviews are exhausted, and whether usage-based billing is +// even switched on to take over when they are. +type Allowance struct { + // Remaining is included reviews left, as the bot last reported them. + Remaining int + // RemainingKnown is false when crq has never seen a count. Absent is not + // zero: treating "unknown" as "exhausted" would invent a charge. + RemainingKnown bool + // UsageBasedEnabled says pay-as-you-go is on. When it is off, an exhausted + // account does not spend money — it stops, which is a different answer. + UsageBasedEnabled bool + // UsageBasedKnown is false when crq has never learned which of those two it + // is. Absent is not "off", for the same reason absent is not "exhausted": + // asserting "off" prices an exhausted account at exactly $0.00, and an + // account that does have overages on would then be told its backlog is free + // and billed per reviewed file for it. + UsageBasedKnown bool +} + +// CostEstimate is what one reviewer will cost for one head, in US dollars. +// +// Low and High bound the answer rather than pretending to a single figure: the +// only honest output for a per-kilobyte price derived from a line count is a +// range. Exact marks the cases where there genuinely is one number — a bot that +// costs nothing, or a diff small enough that the whole band lands on the +// vendor's minimum charge. +type CostEstimate struct { + Bot string + Low float64 + High float64 + // Metered says this review spends CodeRabbit's account allowance. + Metered bool + // Exact says Low and High are the same number and that number is not a + // guess. + Exact bool + // Unknown says crq has no basis to estimate this reviewer at all. A UI must + // say so rather than showing $0.00, which reads as "free". + Unknown bool + // Basis is the one-sentence explanation shown under the figure. Every + // estimate carries one; a number without its reasoning is not checkable. + Basis string +} + +// Free is the estimate for a reviewer covered by its own subscription, which is +// every co-reviewer except Macroscope. +func freeEstimate(login, why string) CostEstimate { + return CostEstimate{Bot: login, Exact: true, Basis: why} +} + +// EstimateMacroscope prices one Macroscope review of this diff. +// +// The diff basis is deliberately the whole head: Macroscope bills incrementally +// after its first review of a pull request (head vs the previously reviewed +// head), so this is an upper bound on later rounds and exact on the first. +func EstimateMacroscope(d DiffStat) CostEstimate { + est := CostEstimate{Bot: MacroscopeLogin} + highKB := math.Max(macroscopeMinKB, float64(d.Lines())*bytesPerLineHigh/1024) + // The first review sees the whole pull-request diff, but later reviews are + // incremental. Without the previously reviewed head the only honest lower + // bound is the per-review floor; a tiny follow-up can cost that little even + // when the cumulative PR diff is large. + est.Low = macroscopeMinKB * macroscopePerKB + est.High = math.Min(macroscopeMaxSpend, highKB*macroscopePerKB) + switch { + case est.Low == est.High && est.High == macroscopeMinKB*macroscopePerKB: + // The entire band fits under the 10 KB minimum, so the minimum IS the + // price — no estimation left in it. + est.Exact = true + est.Basis = fmt.Sprintf("%d changed lines is under the 10 KB minimum, so it is the $%.2f floor", + d.Lines(), macroscopeMinKB*macroscopePerKB) + default: + est.Basis = fmt.Sprintf( + "$%.2f incremental-review floor to a $%.2f whole-diff upper bound (%d changed lines at %.0f bytes/line)", + est.Low, est.High, d.Lines(), bytesPerLineHigh, + ) + } + return est +} + +// EstimateCodeRabbit prices one CodeRabbit review of this diff against the +// account's remaining allowance. +func EstimateCodeRabbit(login string, d DiffStat, a Allowance) CostEstimate { + est := CostEstimate{Bot: login, Metered: true} + switch { + case !a.RemainingKnown: + est.Unknown = true + est.Basis = "crq has not seen a remaining-review count, so whether this one is included or billed is unknown" + case a.Remaining > 0: + est.Exact = true + est.Basis = fmt.Sprintf("included: %d review(s) left in the plan allowance", a.Remaining) + case !a.UsageBasedKnown: + est.Unknown = true + est.Basis = "the allowance is spent and crq has not learned whether usage-based reviews are on, so whether this waits or bills is unknown" + case !a.UsageBasedEnabled: + est.Exact = true + est.Basis = "the allowance is spent and usage-based reviews are off, so this waits for the window rather than costing anything" + case d.ChangedFiles <= 0: + est.Unknown = true + est.Basis = "past the allowance, billed per reviewed file — and the file count is not known here" + default: + // Every changed file is not necessarily a REVIEWED file: path filters + // and the plan's per-review file cap both cut it down. So this is an + // upper bound, and says so. + est.High = float64(d.ChangedFiles) * coderabbitPerFile + est.Basis = fmt.Sprintf("past the allowance: up to %d file(s) at $%.2f, less whatever path filters exclude", + d.ChangedFiles, coderabbitPerFile) + } + return est +} + +// EstimateCost prices one reviewer's review of one head. login may be the +// configured primary or any registry co-reviewer; an unrecognised one is +// Unknown rather than free, because crq cannot know what somebody else's bot +// charges. +// +// The VENDOR decides the basis, not the role. CRQ_BOT may name a registry bot — +// Macroscope, Codex — and asking about the primary first billed whichever one +// was configured on CodeRabbit's allowance-then-per-file model: a Macroscope +// primary was quoted the wrong basis outright, and a Codex primary read as +// billable or unknown instead of covered by its subscription. The registry is +// therefore consulted first; CodeRabbit has no entry there, so a CodeRabbit +// primary still lands on its own estimator. +func EstimateCost(login, primary string, d DiffStat, a Allowance) CostEstimate { + if co, ok := CoReviewerByName(login); ok { + var estimate CostEstimate + if co.Price != nil { + estimate = co.Price(d) + } else { + estimate = freeEstimate(co.Login, "covered by its own subscription — it spends no per-review money") + } + estimate.Metered = co.Budget == BudgetAccount + return estimate + } + if primary != "" && NormalizeBotName(login) == NormalizeBotName(primary) { + return EstimateCodeRabbit(login, d, a) + } + return CostEstimate{Bot: login, Unknown: true, Basis: "crq has no pricing for this reviewer"} +} + +// agentCredits matches the credit line Macroscope appends to a check run's +// body: "**Agent Credits:** 81 credits". Undocumented, observed in the wild — +// which is why it is one regexp with a corpus file behind it. +var agentCredits = regexp.MustCompile(`(?i)\*\*Agent Credits:\*\*\s*([0-9][0-9,]*)\s*credits?`) + +// ParseAgentCredits reads the credits a Macroscope check run reports spending. +// The second return is false when the body carries no such line, which is the +// normal case for its other check types. +func ParseAgentCredits(body string) (int, bool) { + m := agentCredits.FindStringSubmatch(body) + if m == nil { + return 0, false + } + digits := make([]byte, 0, len(m[1])) + for i := 0; i < len(m[1]); i++ { + if m[1][i] != ',' { + digits = append(digits, m[1][i]) + } + } + n, err := strconv.Atoi(string(digits)) + if err != nil { + return 0, false + } + return n, true +} diff --git a/internal/dialect/reply.go b/internal/dialect/reply.go index 2b933d91..26826741 100644 --- a/internal/dialect/reply.go +++ b/internal/dialect/reply.go @@ -64,6 +64,8 @@ func IsReviewFindingWithdrawn(text string) bool { } t := NormalizeReviewText(text) return strings.Contains(t, "should be withdrawn") || + strings.Contains(t, "finding remains withdrawn") || + strings.Contains(t, "finding is withdrawn") || strings.Contains(t, "withdrawing this") || strings.Contains(t, "withdrawing the finding") || strings.Contains(t, "withdrawing my") || diff --git a/internal/dialect/testdata/coderabbit/reply-withdrawn-confirmed.md b/internal/dialect/testdata/coderabbit/reply-withdrawn-confirmed.md new file mode 100644 index 00000000..46f941be --- /dev/null +++ b/internal/dialect/testdata/coderabbit/reply-withdrawn-confirmed.md @@ -0,0 +1,3 @@ +`@kristofferR`, confirmed. The finding remains withdrawn; the synthetic parser cases should stay inline, and the captured corpus/golden rows remain reserved for verbatim vendor payloads. + + diff --git a/internal/dialect/title.go b/internal/dialect/title.go index b33a6d36..4c69ab21 100644 --- a/internal/dialect/title.go +++ b/internal/dialect/title.go @@ -3,6 +3,7 @@ package dialect import ( "regexp" "strings" + "unicode" ) // markup matches the wrappers a bot puts AROUND its title: badge images, the @@ -25,10 +26,15 @@ var markup = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)` + // and displaces the part that differs. It matches the SHAPE rather than any // pipe, because a title may contain one: "Support A | B configuration" must not // become "B configuration". -var rubric = regexp.MustCompile(`^[^|]*\b(Correctness|Maintainability|Security|Performance|Reliability|Quality)\b[^|]*\|[^|]*\|[^|]*\|?\s*`) +const codeRabbitCategoryPattern = `Correctness|Maintainability|Security|Performance|Reliability|Quality|Data Integrity` + +var ( + rubric = regexp.MustCompile(`^[^|]*\b(` + codeRabbitCategoryPattern + `)\b[^|]*\|[^|]*\|[^|]*\|?\s*`) + codeRabbitCategory = regexp.MustCompile(`(?i)\b(` + codeRabbitCategoryPattern + `)\b`) +) // severityWord is the vocabulary Bugbot and Macroscope lead with. -const severityWord = `critical|high|medium|low|major|minor|blocker|trivial|info` +const severityWord = `potential issue|critical|high|medium|low|major|minor|blocker|trivial|info` // severityOnly matches a title that is nothing but a severity label — "High // Severity", "Critical" — which carries nothing a severity field does not. @@ -38,6 +44,132 @@ var severityOnly = regexp.MustCompile(`(?i)^\W*(` + severityWord + `)(\s+severit // front of the path on its first line. var severityPrefix = regexp.MustCompile(`(?i)^\W*(` + severityWord + `)(\s+severity)?\b[\s:—-]*`) +var ( + codexPriority = regexp.MustCompile(`(?i)(?:P([0-3])\s+Badge|badge/P([0-3])(?:-|$))`) + bugbotScale = regexp.MustCompile(`(?im)^\s*\*\*(Critical|High|Medium|Low)\s+Severity\*\*`) + detailsBlock = regexp.MustCompile(`(?is)]*)?>.*?`) + macroscopeScale = regexp.MustCompile( + `(?im)^\s*\W*\*\*(Critical|High|Medium|Low)\*\*`, + ) +) + +// ReviewLabels are the independent labels in a reviewer's rubric header. +// Requiredness and actionability live elsewhere; these are presentation facts +// the reviewer explicitly wrote, such as "Functional Correctness | Minor | +// Quick win". +type ReviewLabels struct { + Category string + Severity string + Scale string + Effort string +} + +// ReviewLabelsFor reads the labels in one reviewer's own dialect. Do not merge +// these formats: CodeRabbit's "Minor" and Codex's "P2" mean similar urgency but +// are different scales the UI should preserve. +func ReviewLabelsFor(bot, body string) ReviewLabels { + switch NormalizeBotName(bot) { + case "coderabbitai": + return codeRabbitLabels(body) + case "chatgpt-codex-connector": + return codexLabels(body) + case "cursor": + return singleScaleLabels(body, bugbotScale) + case "macroscopeapp": + return singleScaleLabels(body, macroscopeScale) + default: + return ReviewLabels{} + } +} + +// codeRabbitLabels reads the three-part rubric without letting words deep in +// the explanation override it. CodeRabbit routinely writes "Minor" in the +// header and later mentions a "major" code path; scanning the whole body made +// every such finding render as Major. +func codeRabbitLabels(body string) ReviewLabels { + for _, line := range strings.Split(body, "\n") { + parts := strings.Split(line, "|") + if len(parts) != 3 { + continue + } + category := labelText(parts[0]) + scale := labelText(parts[1]) + effort := labelText(parts[2]) + severity := severityFromLabel(scale) + if !isCodeRabbitCategory(category) || severity == "" { + continue + } + return ReviewLabels{Category: category, Severity: severity, Scale: scale, Effort: effort} + } + return ReviewLabels{} +} + +func isCodeRabbitCategory(category string) bool { + return codeRabbitCategory.MatchString(category) +} + +func codexLabels(body string) ReviewLabels { + match := codexPriority.FindStringSubmatch(body) + if match == nil { + return ReviewLabels{} + } + priority := match[1] + if priority == "" { + priority = match[2] + } + severity := map[string]string{"0": "critical", "1": "major", "2": "potential", "3": "minor"}[priority] + return ReviewLabels{Severity: severity, Scale: "P" + priority} +} + +func singleScaleLabels(body string, pattern *regexp.Regexp) ReviewLabels { + match := pattern.FindStringSubmatch(body) + if match == nil { + return ReviewLabels{} + } + scale := match[1] + return ReviewLabels{Severity: severityFromLabel(scale), Scale: scale} +} + +func labelText(label string) string { + label = strings.Trim(strings.TrimSpace(label), "_*` ") + runes := []rune(label) + for len(runes) > 0 && !unicode.IsLetter(runes[0]) && !unicode.IsNumber(runes[0]) { + runes = runes[1:] + } + return strings.TrimSpace(string(runes)) +} + +func severityFromLabel(label string) string { + lower := strings.ToLower(label) + switch { + case strings.Contains(lower, "critical"), strings.Contains(lower, "blocker"): + return "critical" + case strings.Contains(lower, "major"), strings.Contains(lower, "high"): + return "major" + case strings.Contains(lower, "potential"), strings.Contains(lower, "medium"): + return "potential" + case strings.Contains(lower, "minor"), strings.Contains(lower, "low"), strings.Contains(lower, "trivial"): + return "minor" + default: + return "" + } +} + +// ReviewTitleFor reads a title in one reviewer's dialect. In particular, +// CodeRabbit may put an Analysis chain before the actual finding; shell +// comments and bold labels inside that collapsed block are implementation +// detail, never title candidates. +func ReviewTitleFor(bot, body string) string { + switch NormalizeBotName(bot) { + case "coderabbitai": + return ThreadTitle(true, detailsBlock.ReplaceAllString(body, "")) + case "chatgpt-codex-connector", "cursor", "macroscopeapp": + return ThreadTitle(true, body) + default: + return ThreadTitle(true, body) + } +} + // ThreadTitle reduces a review comment to one readable line, per bot. // // Every bot wraps its summary differently: CodeRabbit prefixes a fixed severity @@ -156,6 +288,11 @@ func cleanTitle(title string) string { // renders it as a replacement character. title = strings.ToValidUTF8(title, "") title = strings.Join(strings.Fields(markup.ReplaceAllString(title, " ")), " ") + // A title is already rendered separately from its Markdown body. Backticks + // that survive beside punctuation show up literally (and an unmatched one + // can consume the rest of the line in a Markdown renderer), so retain the + // identifier but not its source-format delimiter. + title = strings.ReplaceAll(title, "`", "") if trimmed := strings.TrimSpace(rubric.ReplaceAllString(title, "")); trimmed != "" { title = trimmed } diff --git a/internal/dialect/title_test.go b/internal/dialect/title_test.go index 18e7f4f3..eb80ea8f 100644 --- a/internal/dialect/title_test.go +++ b/internal/dialect/title_test.go @@ -48,6 +48,11 @@ func TestThreadTitleReadsAsText(t *testing.T) { "**Preserve `__init__` ordering**\n\nDetail.", "Preserve __init__ ordering", }, + { + "code span before punctuation", + "**Handle no-op changes in `SetFleetSolver`.**\n\nDetail.", + "Handle no-op changes in SetFleetSolver.", + }, } { t.Run(tc.name, func(t *testing.T) { if got := ThreadTitle(true, tc.body); got != tc.want { @@ -72,6 +77,79 @@ func TestThreadTitleReadsAsText(t *testing.T) { } } +func TestReviewLabelsComeFromTheRubricNotTheExplanation(t *testing.T) { + body := `_🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_ + +**Preserve the setting.** + +This touches a major code path, but the finding itself is minor.` + labels := ReviewLabelsFor("coderabbitai[bot]", body) + if labels.Category != "Functional Correctness" || labels.Severity != "minor" || + labels.Scale != "Minor" || labels.Effort != "Quick win" { + t.Fatalf("labels = %+v", labels) + } + if got := SeverityFor("coderabbitai[bot]", body); got != "minor" { + t.Fatalf("SeverityFor = %q, want rubric severity minor", got) + } +} + +func TestDataIntegrityRubricDoesNotBecomeTheFindingTitle(t *testing.T) { + body := `_🗄️ Data Integrity & Integration_ | _🟠 Major_ | _⚡ Quick win_ + +**Keep archived dispatch state intact.** + +The rubric belongs in chips, not in the finding title.` + if got := ReviewTitleFor("coderabbitai[bot]", body); got != "Keep archived dispatch state intact." { + t.Fatalf("ReviewTitleFor = %q", got) + } +} + +func TestCodeRabbitTitleIgnoresAnalysisBeforeFinding(t *testing.T) { + body := `_🔒 Security & Privacy_ | _🟠 Major_ | _⚡ Quick win_ + +
+🧩 Analysis chain + +` + "```shell" + ` +# How is the token resolved, and is it re-read anywhere else? +rg LookupToken +` + "```" + ` +
+ +**Refresh the GitHub token before using cached icon misses.** + +The startup token can expire.` + if got := ReviewTitleFor("coderabbitai[bot]", body); got != "Refresh the GitHub token before using cached icon misses." { + t.Fatalf("ReviewTitleFor = %q", got) + } +} + +func TestCodeRabbitDataIntegrityRubricIsNotATitle(t *testing.T) { + body := `_🗄️ Data Integrity & Integration_ | _🟡 Minor_ | _⚡ Quick win_ + +**Keep an empty model list as an array.**` + if got := ReviewTitleFor("coderabbitai[bot]", body); got != "Keep an empty model list as an array." { + t.Fatalf("ReviewTitleFor = %q", got) + } +} + +func TestReviewLabelsKeepEachBotsNativeScale(t *testing.T) { + codex := ReviewLabelsFor("chatgpt-codex-connector[bot]", + `**![P2 Badge](https://img.shields.io/badge/P2-yellow) Preserve fallback ordering**`) + if codex.Scale != "P2" || codex.Severity != "potential" || codex.Category != "" { + t.Fatalf("codex labels = %+v", codex) + } + if got := SeverityFor("chatgpt-codex-connector[bot]", + `![P2 Badge](https://img.shields.io/badge/P2-yellow) this mentions a major path`); got != "potential" { + t.Fatalf("Codex SeverityFor = %q, want P2/potential", got) + } + + bugbot := ReviewLabelsFor("cursor[bot]", "**High Severity**\n\nA nil map write.") + if bugbot.Scale != "High" || bugbot.Severity != "major" { + t.Fatalf("bugbot labels = %+v", bugbot) + } +} + // Every bot wraps its summary differently, and the first bold span is the // SEVERITY for two of them — a listing that says "High Severity" for every // Bugbot finding has told the reader nothing. diff --git a/internal/dialect/vendor.go b/internal/dialect/vendor.go new file mode 100644 index 00000000..aa3874d2 --- /dev/null +++ b/internal/dialect/vendor.go @@ -0,0 +1,120 @@ +package dialect + +// Vendor is what a person needs to decide whether to use a review bot: what it +// is, what it costs, and what setting it up involves. +// +// It lives in dialect for the same reason every other bot string does — this is +// the one package allowed to know a bot by name. Nothing here affects a +// decision crq makes; it is read only by the guide. +// +// Prices carry no figures that are not in pricing.go, and no claim is made +// about which bot is better. The honest version of "which should I use" is a +// description of each and the criterion that distinguishes them. +type Vendor struct { + // Site is where you sign up; Docs is where the setup instructions are. + Site string + Docs string + // Pitch is two or three lines, in plain terms, with no superlatives. + Pitch string + // Cost is one line about money, in the vendor's own terms. + Cost string + // Setup is what installing it actually involves, in order. + Setup []string + // SuitedTo names the case this bot is the obvious answer for, and is shown + // as the reason behind any suggestion — a badge with no stated criterion is + // an advertisement. + SuitedTo string +} + +// PrimaryVendor describes the configured primary. It is keyed by login rather +// than held on a registry entry, because the primary is deliberately NOT in the +// co-reviewer registry. +func PrimaryVendor(login string) (Vendor, bool) { + if NormalizeBotName(login) != NormalizeBotName(CodeRabbitLogin) { + return Vendor{}, false + } + return Vendor{ + Site: "https://coderabbit.ai", + Docs: "https://docs.coderabbit.ai", + Pitch: "Reviews the whole pull request and leaves line comments, then keeps " + + "answering as you push. It is the reviewer crq queues around: its allowance " + + "is per-account and shared, which is why one review at a time fires fleet-wide.", + Cost: "Free for public repositories. Paid per developer for private ones, with " + + "usage-based overage past the plan's allowance — see crq cost.", + Setup: []string{ + "Sign in at coderabbit.ai with GitHub and authorise the app.", + "Add the repositories you want reviewed.", + "Optionally commit a .coderabbit.yaml — note an org-level config overrides it.", + }, + SuitedTo: "open-source repositories, where it is free and covers the whole diff", + }, true +} + +// CodeRabbitLogin is the default primary's login. Named here so the guide can +// ask about it without the caller hardcoding a string. +const CodeRabbitLogin = "coderabbitai[bot]" + +// vendorFor returns the descriptor for a registry co-reviewer. +func vendorFor(name string) Vendor { + switch name { + case "codex": + return Vendor{ + Site: "https://chatgpt.com/codex", + Docs: "https://developers.openai.com/codex/cloud/code-review", + Pitch: "OpenAI's reviewer, driven from a ChatGPT plan rather than a per-repository " + + "subscription. It reads the diff in the context of the whole repository and " + + "tends to find the defects that need that context.", + Cost: "Included with a ChatGPT Plus/Pro/Business plan — no per-review charge, so " + + "crq never queues it behind the shared allowance.", + Setup: []string{ + "Connect your GitHub account in ChatGPT's Codex settings.", + "Enable code review for the repositories you want it on.", + }, + SuitedTo: "anyone already paying for ChatGPT — it costs nothing further", + } + case "bugbot": + return Vendor{ + Site: "https://cursor.com/bugbot", + Docs: "https://docs.cursor.com/bugbot", + Pitch: "Cursor's reviewer. Narrower than a full review by design: it looks for " + + "bugs rather than commenting on style, and posts a check run even when it " + + "finds nothing.", + Cost: "Part of a Cursor subscription; no per-review charge.", + Setup: []string{ + "Install the Cursor GitHub app on the repositories you want it on.", + "Enable Bugbot for them in Cursor's dashboard.", + }, + SuitedTo: "teams already using Cursor", + } + case "macroscope": + return Vendor{ + Site: "https://macroscope.com", + Docs: "https://docs.macroscope.com", + Pitch: "Reviews against rules you write into the repository — .macroscope/ holds " + + "correctness checks and per-check agents — so it enforces this project's " + + "conventions rather than generic ones.", + Cost: "Pay per kilobyte of diff reviewed, with a per-review minimum and cap. It " + + "is the one co-reviewer that spends money per review; crq estimates it.", + Setup: []string{ + "Sign up at macroscope.com and install its GitHub app.", + "Add .macroscope/ rules to the repository's default branch.", + "Top up the balance — work is SKIPPED, not queued, when it runs out.", + }, + SuitedTo: "repositories with conventions worth enforcing in writing", + } + } + return Vendor{} +} + +// VendorFor returns the descriptor for any bot crq knows, primary or +// co-reviewer, and whether there is one. +func VendorFor(nameOrLogin string) (Vendor, bool) { + if v, ok := PrimaryVendor(nameOrLogin); ok { + return v, true + } + if co, ok := CoReviewerByName(nameOrLogin); ok { + v := vendorFor(co.Name) + return v, v.Site != "" + } + return Vendor{}, false +} diff --git a/internal/dialect/vendor_test.go b/internal/dialect/vendor_test.go new file mode 100644 index 00000000..768ff298 --- /dev/null +++ b/internal/dialect/vendor_test.go @@ -0,0 +1,14 @@ +package dialect + +import "testing" + +func TestKnownCoReviewersHaveVendorDescriptors(t *testing.T) { + for _, reviewer := range KnownCoReviewers() { + t.Run(reviewer.Name, func(t *testing.T) { + vendor, ok := VendorFor(reviewer.Name) + if !ok || vendor.Site == "" || vendor.Docs == "" { + t.Fatalf("VendorFor(%q) = %#v, %v; want a non-empty descriptor", reviewer.Name, vendor, ok) + } + }) + } +} diff --git a/internal/engine/coreview.go b/internal/engine/coreview.go index 44ff5d1e..fc98a16d 100644 --- a/internal/engine/coreview.go +++ b/internal/engine/coreview.go @@ -388,11 +388,10 @@ func CoOnlyEligible(r state.Round, obs Observation, login string, blockedUntil * // the bot has not reviewed the head, and no check run of its exists for the // head (including in-progress — a running check will deliver evidence). // -// Modes: never — false. always — post unless the bot auto-reviews (today's -// Codex behavior; required-ness lives in the config default that picks the -// mode). selfheal — post only for a bot observed active that missed the head, -// once the anchor (the round's fire) is older than the grace period; the -// caller passes anchor and now so the fire path and the sweep share the rule. +// Modes: never — false. always — post whenever the common current-head guards +// above allow it. selfheal — post only for a bot observed active that missed +// the head, once the anchor (the round's fire) is older than the grace period; +// the caller passes anchor and now so the fire path and the sweep share the rule. func DecideCoPost(r state.Round, obs Observation, cp CoReviewerPolicy, commandPresent bool, anchor, now time.Time) bool { if roundCoCommandID(r, cp.Login) != 0 { return false @@ -409,16 +408,23 @@ func DecideCoPost(r state.Round, obs Observation, cp CoReviewerPolicy, commandPr if coCheckAny(obs, cp.Login) { return false } - // Fail closed: with the head's checks unreadable, a missing check is not - // evidence the bot is idle, and posting would double-ask a run already in - // flight. - if obs.co(cp.Login).ChecksUnknown { - return false - } switch cp.Trigger { case TriggerAlways: - return !obs.co(cp.Login).AutoActive + // "Always" is the operator's explicit instruction to ask this reviewer + // on every head. AutoActive can come from an older head and therefore + // cannot override that instruction; current-head review/check/command + // evidence already suppresses duplicates in the common guards above. + // Likewise, an unreadable checks endpoint is not evidence that a review + // exists. Suppressing either case leaves a required reviewer pending + // until the round times out with no recovery path. + return true case TriggerSelfHeal: + // Self-heal is deliberately conservative: when checks are unreadable, + // the missing check is not evidence that an auto-review was missed. + // Avoid double-asking a run that may already be in flight. + if obs.co(cp.Login).ChecksUnknown { + return false + } if r.ForceCoReviewer(cp.Login) { return true } @@ -434,3 +440,24 @@ func DecideCoPost(r state.Round, obs Observation, cp CoReviewerPolicy, commandPr return false } } + +// CoReviewerActive reports whether this reviewer has produced any observable +// activity on the pull request, regardless of which head it belongs to. +func CoReviewerActive(obs Observation, login string) bool { + for _, review := range obs.Reviews { + if sameBot(review.Bot, login) { + return true + } + } + for _, ev := range obs.Events { + if ev.Kind != dialect.EvCoCommand && eventConcerns(ev, login) { + return true + } + } + for _, check := range obs.Checks { + if sameBot(check.Bot, login) { + return true + } + } + return false +} diff --git a/internal/engine/coreview_test.go b/internal/engine/coreview_test.go index 5fa0d028..aa9cdd6a 100644 --- a/internal/engine/coreview_test.go +++ b/internal/engine/coreview_test.go @@ -13,6 +13,58 @@ const ( macroLogin = dialect.MacroscopeLogin ) +func TestCoReviewerActiveIgnoresHeadScope(t *testing.T) { + cases := []struct { + name string + obs Observation + want bool + }{ + { + name: "review on an earlier head", + obs: Observation{Head: "new", Reviews: []ReviewSeen{{Bot: bugbotLogin, Commit: "old"}}}, + want: true, + }, + { + name: "classified comment", + obs: Observation{Head: "new", Events: []dialect.BotEvent{{Bot: bugbotLogin}}}, + want: true, + }, + { + name: "comment attributed to reviewer", + obs: Observation{Head: "new", Events: []dialect.BotEvent{{Bot: macroLogin, For: bugbotLogin}}}, + want: true, + }, + { + name: "trigger command attributed to reviewer", + obs: Observation{Head: "new", Events: []dialect.BotEvent{{ + Kind: dialect.EvCoCommand, Bot: "human", For: bugbotLogin, + }}}, + }, + { + name: "explicit attribution takes precedence over author", + obs: Observation{Head: "new", Events: []dialect.BotEvent{{Bot: bugbotLogin, For: macroLogin}}}, + }, + { + name: "check activity", + obs: Observation{Head: "new", Checks: []CheckSeen{{Bot: bugbotLogin}}}, + want: true, + }, + { + name: "different reviewer", + obs: Observation{Head: "new", Reviews: []ReviewSeen{{Bot: macroLogin, Commit: "old"}}}, + }, + {name: "no activity", obs: Observation{Head: "new"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := CoReviewerActive(tc.obs, bugbotLogin); got != tc.want { + t.Fatalf("CoReviewerActive = %v, want %v", got, tc.want) + } + }) + } +} + // TestDecideCoPostTriggerMatrix is the trigger-mode decision matrix: // never/selfheal/always crossed with the bot's observed activity, plus the // mode-independent guards (already commanded, live command, head evidence, @@ -45,7 +97,7 @@ func TestDecideCoPostTriggerMatrix(t *testing.T) { }{ {name: "never posts nothing even when silent", cp: policy(TriggerNever), obs: obsWith(CoSeen{}), anchor: staleAnchor, want: false}, {name: "always posts for a silent bot", cp: policy(TriggerAlways), obs: obsWith(CoSeen{}), want: true}, - {name: "always defers to auto-review", cp: policy(TriggerAlways), obs: obsWith(CoSeen{AutoActive: true}), want: false}, + {name: "always overrides historical auto-review activity", cp: policy(TriggerAlways), obs: obsWith(CoSeen{AutoActive: true}), want: true}, {name: "always respects a live command", cp: policy(TriggerAlways), obs: obsWith(CoSeen{}), commandPresent: true, want: false}, {name: "always respects the recorded round command", cp: policy(TriggerAlways), round: commanded, obs: obsWith(CoSeen{}), want: false}, { @@ -66,7 +118,14 @@ func TestDecideCoPostTriggerMatrix(t *testing.T) { obs: obsWith(CoSeen{}, CheckSeen{Bot: macroLogin, Verdict: dialect.CheckDone, CompletedAt: now}), want: true, }, + { + name: "always posts when checks are unreadable", + cp: policy(TriggerAlways), + obs: obsWith(CoSeen{ChecksUnknown: true}), + want: true, + }, {name: "selfheal stays quiet for an inactive bot", cp: policy(TriggerSelfHeal), obs: obsWith(CoSeen{}), anchor: staleAnchor, want: false}, + {name: "selfheal stays quiet when checks are unreadable", cp: policy(TriggerSelfHeal), obs: obsWith(CoSeen{AutoActive: true, ChecksUnknown: true}), anchor: staleAnchor, want: false}, {name: "selfheal posts for an active bot that missed the head past grace", cp: policy(TriggerSelfHeal), obs: obsWith(CoSeen{AutoActive: true}), anchor: staleAnchor, want: true}, {name: "selfheal counts round activity as active", cp: policy(TriggerSelfHeal), obs: obsWith(CoSeen{ActiveThisRound: true}), anchor: staleAnchor, want: true}, {name: "selfheal waits out the grace period", cp: policy(TriggerSelfHeal), obs: obsWith(CoSeen{AutoActive: true}), anchor: freshAnchor, want: false}, @@ -328,13 +387,13 @@ func TestSummaryOnlyPlanRunsCoReviewersAlone(t *testing.T) { } }) - t.Run("waits bounded for an auto-reviewing codex", func(t *testing.T) { + t.Run("always mode commands an historically auto-reviewing codex", func(t *testing.T) { d := DecideFire(free, queued, obs([]dialect.BotEvent{notice}, nil, CoSeen{AutoActive: true}), now, p) - if d.Verdict != FireCoReviewWait { - t.Fatalf("verdict = %v (%s), want FireCoReviewWait", d.Verdict, d.Reason) + if d.Verdict != FireCoOnly { + t.Fatalf("verdict = %v (%s), want FireCoOnly", d.Verdict, d.Reason) } - if len(d.PostCo) != 0 { - t.Fatalf("PostCo = %v, want no post for an auto-reviewing bot", d.PostCo) + if len(d.PostCo) != 1 { + t.Fatalf("PostCo = %v, want one explicit always-mode post", d.PostCo) } }) @@ -447,7 +506,10 @@ func TestReviewSkippedRunsCoReviewersInsteadOfFiring(t *testing.T) { RequiredBots: []string{"coderabbitai[bot]", dialect.CodexBotLogin}, CoReviewers: []CoReviewerPolicy{{Login: dialect.CodexBotLogin, Command: "@codex review", Trigger: TriggerAlways}}} obs := Observation{Head: head, Open: true, - Events: []dialect.BotEvent{skippedEvent("56150a0423a243224b03f355c3a3ba6941011b5b", now)}} + Events: []dialect.BotEvent{skippedEvent("56150a0423a243224b03f355c3a3ba6941011b5b", now)}, + Co: map[string]CoSeen{ + dialect.NormalizeBotName(dialect.CodexBotLogin): {ChecksUnknown: true}, + }} // Even with a free slot and no block, crq must not fire CodeRabbit. d := DecideFire(Global{SlotFree: true}, queued, obs, now, p) diff --git a/internal/engine/engine.go b/internal/engine/engine.go index f32afc06..f2d57bd0 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -18,7 +18,7 @@ type TriggerMode string const ( TriggerNever TriggerMode = "never" // crq never posts this bot's command TriggerSelfHeal TriggerMode = "selfheal" // post only when an active bot missed the head past a grace period - TriggerAlways TriggerMode = "always" // post at fire time unless the bot auto-reviews + TriggerAlways TriggerMode = "always" // post at fire time unless current-head evidence suppresses it ) // CoReviewerPolicy is the configured stance toward one co-reviewer: whether @@ -44,6 +44,13 @@ type Policy struct { Bot string // configured CodeRabbit login RequiredBots []string // bots that gate round completion + // PrimaryOff means the metered primary does not review this repository at + // all. It reads as one more way a primary review cannot arrive, so every + // rule that already handles "no review is coming" handles this too — and + // resolves before the slot, quota and pacing gates, which is what keeps a + // repository crq does not fire from queueing behind one it does. + PrimaryOff bool + // CoReviewers are the enabled co-reviewer bots with their trigger stances. CoReviewers []CoReviewerPolicy @@ -219,6 +226,12 @@ func PrimaryReviewUnavailable(obs Observation, p Policy, head string) bool { // account quota that cannot apply — waiting, and holding the head, for a review // that will never arrive. Empty when a review is still expected. func PrimaryUnavailableReason(obs Observation, p Policy, head string) string { + // Configuration outranks evidence: a primary that does not run here will + // never produce a skip notice or a plan to read, so asking the observation + // first would just mean answering "still expected" for ever. + if p.PrimaryOff { + return "does not review this repository" + } // The skip is the more specific and more actionable of the two: it names a // fixable cause (narrow the PR) and binds to this head only. if ReviewSkippedHead(obs, p, head) { diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 7b510abc..2f78cad2 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -240,6 +240,61 @@ func TestSubmittedPrimaryReviewAcknowledgesTheRound(t *testing.T) { } } +func TestRegistryPrimaryCompletedCheckAcknowledgesTheRound(t *testing.T) { + p := policy + p.Bot = dialect.CodexBotLogin + p.RequiredBots = []string{"some-other-bot[bot]"} + cases := []struct { + name string + check CheckSeen + outcome Outcome + reason string + }{ + { + name: "current terminal primary check", + check: CheckSeen{ + Bot: dialect.CodexBotLogin, Verdict: dialect.CheckDoneClean, + CompletedAt: t0.Add(time.Minute), + }, + outcome: OutReviewing, reason: "check completed", + }, + { + name: "stale terminal check", + check: CheckSeen{ + Bot: dialect.CodexBotLogin, Verdict: dialect.CheckDone, + CompletedAt: t0, + }, + outcome: KeepWaiting, reason: "review in flight", + }, + { + name: "wrong bot", + check: CheckSeen{ + Bot: "other[bot]", Verdict: dialect.CheckDoneClean, + CompletedAt: t0.Add(time.Minute), + }, + outcome: KeepWaiting, reason: "review in flight", + }, + { + name: "non-terminal primary check", + check: CheckSeen{ + Bot: dialect.CodexBotLogin, Verdict: dialect.CheckInProgress, + CompletedAt: t0.Add(time.Minute), + }, + outcome: KeepWaiting, reason: "review in flight", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := firedRound(t, "abcdef123") + obs := Observation{Head: "abcdef123", Open: true, Checks: []CheckSeen{tc.check}} + tr := Progress(r, state.AccountQuota{}, obs, t0.Add(2*time.Minute), p) + if tr.Outcome != tc.outcome || tr.Reason != tc.reason { + t.Fatalf("Progress = %+v, want outcome %v reason %q", tr, tc.outcome, tc.reason) + } + }) + } +} + func TestInflightTimeoutCarriesCooldown(t *testing.T) { r := firedRound(t, "abcdef123") now := t0.Add(16 * time.Minute) @@ -484,7 +539,7 @@ func TestDecideCodexPost(t *testing.T) { want bool }{ {name: "required, no auto, first fire", round: state.Round{Head: head}, obs: base, policy: codexReq, want: true}, - {name: "auto-active never posts", round: state.Round{Head: head}, obs: Observation{Head: head, Open: true, Co: codexSeen(CoSeen{AutoActive: true})}, policy: codexReq, want: false}, + {name: "always mode overrides historical auto activity", round: state.Round{Head: head}, obs: Observation{Head: head, Open: true, Co: codexSeen(CoSeen{AutoActive: true})}, policy: codexReq, want: true}, {name: "already reviewed head", round: state.Round{Head: head}, obs: Observation{Head: head, Open: true, Reviews: []ReviewSeen{codexReviewHead}}, policy: codexReq, want: false}, {name: "command already present", round: state.Round{Head: head}, obs: base, policy: codexReq, commandPresent: true, want: false}, {name: "not required", round: state.Round{Head: head}, obs: base, policy: policy, want: false}, @@ -581,8 +636,8 @@ func TestDecideFireCodexDedupe(t *testing.T) { // Same, but Codex auto-reviews: crq must not post; wait for its own review, // bounded (FireCoReviewWait) rather than left queued with no deadline. autoObs := Observation{Head: head, Open: true, Co: codexSeen(CoSeen{AutoActive: true}), Reviews: []ReviewSeen{crReviewed}} - if d := DecideFire(free, queued, autoObs, now, codexReq); d.Verdict != FireCoReviewWait { - t.Fatalf("auto-active codex must wait (bounded), not dedupe, got %+v", d) + if d := DecideFire(free, queued, autoObs, now, codexReq); d.Verdict != FireCoOnly || !codexPosted(d) { + t.Fatalf("always-mode codex must be commanded, not deduped, got %+v", d) } // A live `@codex review` command already on the PR: crq must not repost it; // wait for its answer, bounded. @@ -891,8 +946,8 @@ func TestDecideFireBlockedCodexDeferred(t *testing.T) { } // Auto-active Codex reviews unprompted → nothing to post; blocked FireNo. autoObs := Observation{Head: head, Open: true, Co: codexSeen(CoSeen{AutoActive: true})} - if d := DecideFire(g, queued, autoObs, now, degrade); d.Verdict != FireNo { - t.Fatalf("auto-active codex must not be commanded, got %+v", d) + if d := DecideFire(g, queued, autoObs, now, degrade); d.Verdict != FireCoDeferred || !codexPosted(d) { + t.Fatalf("always-mode codex must be commanded while the primary is blocked, got %+v", d) } // Unblocked → the normal fire path is untouched. if d := DecideFire(Global{SlotFree: true}, queued, open, now, degrade); d.Verdict != FirePost || !codexPosted(d) { @@ -1149,3 +1204,49 @@ func TestReopenedRoundDedupesOnlyOnCompletionEvidence(t *testing.T) { }) } } + +// TestPrimaryOffNeverFiresAndNeverWaitsOnTheQuota pins the repository-level +// primary switch. A private repo on a free plan gets nothing from the metered +// reviewer, so crq must neither spend the account on it nor let that account's +// state — or another PR's fire slot — delay the co-reviewers that DO run there. +func TestPrimaryOffNeverFiresAndNeverWaitsOnTheQuota(t *testing.T) { + now := t0.Add(10 * time.Minute) + head := "abcdef123" + queued := state.Round{Repo: "owner/private", PR: 7, Head: head, Phase: state.PhaseQueued, Seq: 1} + obs := Observation{Head: head, Open: true} + + off := withCodex(policy, "@codex review") + off.PrimaryOff = true + off.RequiredBots = []string{dialect.CodexBotLogin} // ForRepo drops the primary + + blocked := now.Add(time.Hour) + for _, g := range []Global{ + {SlotFree: true}, + {SlotFree: false}, // another PR holds the slot + {SlotFree: true, BlockedUntil: &blocked}, // account quota blocked + {SlotFree: true, LastFired: &now}, // inside the pacing window + } { + d := DecideFire(g, queued, obs, now, off) + if d.Verdict != FireCoOnly { + t.Fatalf("primary off must resolve on the co-reviewers alone whatever the account is doing, got %+v (global %+v)", d, g) + } + if len(d.PostCo) != 1 || d.PostCo[0] != dialect.CodexBotLogin { + t.Fatalf("primary off must still command the co-reviewers, got %+v", d.PostCo) + } + } + + // With the primary on, the same round is an ordinary metered fire — the + // switch is what changed the decision, not the observation. + on := off + on.PrimaryOff = false + on.RequiredBots = []string{"coderabbitai[bot]", dialect.CodexBotLogin} + if d := DecideFire(Global{SlotFree: true}, queued, obs, now, on); d.Verdict != FirePost { + t.Fatalf("primary on must still fire, got %+v", d) + } + + // And the reason is reportable, so an agent is never left reasoning about + // quota for a reviewer that does not run here. + if why := PrimaryUnavailableReason(obs, off, head); why == "" { + t.Fatal("primary off must name itself as the reason no review is coming") + } +} diff --git a/internal/engine/progress.go b/internal/engine/progress.go index 1319dafe..9ed3721c 100644 --- a/internal/engine/progress.go +++ b/internal/engine/progress.go @@ -186,6 +186,13 @@ func primaryAck(r state.Round, obs Observation, p Policy, firedAt time.Time) (st return "review submitted", true } } + for _, check := range obs.Checks { + if sameBot(check.Bot, p.Bot) && + (check.Verdict == dialect.CheckDone || check.Verdict == dialect.CheckDoneClean) && + !check.CompletedAt.Before(firedAt) { + return "check completed", true + } + } for _, ev := range obs.Events { if !sameBot(ev.Bot, p.Bot) || ev.CommentID == r.CommandID || ev.UpdatedAt.Before(firedAt) { continue diff --git a/internal/gh/github.go b/internal/gh/github.go index fdc5ed38..9f80fc65 100644 --- a/internal/gh/github.go +++ b/internal/gh/github.go @@ -14,6 +14,7 @@ import ( "os" "os/exec" "reflect" + "sort" "strconv" "strings" "sync" @@ -50,6 +51,8 @@ type GitHub struct { networkMaxWait time.Duration acctTypeMu sync.Mutex acctType map[string]string // scope login -> "org:" | "user:" search qualifier + viewerMu sync.Mutex + viewer string // the token's own login, "" until looked up, "-" when unreadable etagMu sync.Mutex etags map[string]*etagEntry // GET URL -> last 200 response, replayed on 304 } @@ -410,6 +413,21 @@ func IsThrottled(err error) bool { return errors.As(err, &rl) } +// IsRecoverableRead reports a failure local to one resource that a bounded +// multi-PR preview can count as unexamined while continuing. Authentication, +// permission, validation and state errors are deliberately excluded. +func IsRecoverableRead(err error) bool { + if errors.Is(err, ErrNotFound) { + return true + } + var api *APIError + if errors.As(err, &api) { + return api.Status == http.StatusNotFound || api.Status >= 500 + } + var network net.Error + return errors.As(err, &network) +} + // ThrottleWait returns how long to wait before retrying a rate-limited error. // The bool is true when err is a rate limit; the duration is 0 when GitHub gave // no reset hint (the caller should apply its own default backoff). @@ -792,9 +810,16 @@ type Issue struct { type Pull struct { Number int `json:"number"` State string `json:"state"` + Title string `json:"title"` Body string `json:"body"` HTMLURL string `json:"html_url"` - Head struct { + // User is who opened the pull request. The listing carries it for free, and + // the skip-author rules are applied per pull request by every path that acts + // on one — so a caller that reads a Pull never has to ask again. + User struct { + Login string `json:"login"` + } `json:"user"` + Head struct { SHA string `json:"sha"` Ref string `json:"ref"` // Repo is the head's repository, which differs from the base on a fork @@ -804,6 +829,12 @@ type Pull struct { } `json:"repo"` } `json:"head"` Merged bool `json:"merged"` + // Additions/Deletions/ChangedFiles are only populated by the single-pull + // endpoint, not by list or search results. They cost nothing extra there, + // and they are what a cost estimate is computed from. + Additions int `json:"additions"` + Deletions int `json:"deletions"` + ChangedFiles int `json:"changed_files"` } type RepoInfo struct { @@ -990,6 +1021,7 @@ func (g *GitHub) EachOpenPR(ctx context.Context, target string, byRepo bool, fn Items []struct { Number int `json:"number"` RepositoryURL string `json:"repository_url"` + Title string `json:"title"` Body string `json:"body"` User struct { Login string `json:"login"` @@ -1007,7 +1039,7 @@ func (g *GitHub) EachOpenPR(ctx context.Context, target string, byRepo bool, fn if repo == "" { continue } - stop, err := fn(SearchPR{Repo: repo, Number: item.Number, Author: item.User.Login, Body: item.Body}) + stop, err := fn(SearchPR{Repo: repo, Number: item.Number, Author: item.User.Login, Title: item.Title, Body: item.Body}) if err != nil { return err } @@ -1064,10 +1096,76 @@ func (g *GitHub) searchOwnerQualifier(ctx context.Context, login string) (string return qualifier, nil } +// viewerLogin is the login the token authenticates as, cached for the run, or +// "" when it cannot be read. +// +// Unreadable is a legitimate answer, not an error: a token without the scope to +// read /user can still list public repositories, and a caller asking "is this +// owner me" should get "not that I can tell" rather than a failed page. +func (g *GitHub) viewerLogin(ctx context.Context) (string, error) { + g.viewerMu.Lock() + cached := g.viewer + g.viewerMu.Unlock() + switch cached { + case "": + case "-": + return "", nil + default: + return cached, nil + } + var me struct { + Login string `json:"login"` + } + login := "-" + err := g.request(ctx, http.MethodGet, "/user", nil, &me) + switch { + case err == nil && me.Login != "": + login = me.Login + case err != nil && !deniedIdentity(err): + // A timeout, a 5xx or an exhausted quota is not an answer about this + // token. Remembering "-" for one would keep every later repository + // picker on /users/{owner}/repos — which omits the caller's own private + // repositories — for the rest of the process, long after GitHub came + // back. Nothing is cached, so the next caller asks again. + return "", err + } + g.viewerMu.Lock() + // Another first caller may have completed while this request was in + // flight. Keep its answer rather than letting a later refusal overwrite a + // successfully identified viewer (or vice versa). + if g.viewer == "" || (g.viewer == "-" && login != "-") { + g.viewer = login + } else { + login = g.viewer + } + g.viewerMu.Unlock() + if login == "-" { + return "", nil + } + return login, nil +} + +// deniedIdentity reports whether an error is GitHub REFUSING to say who the +// token is, rather than failing to answer. Only the refusal is worth caching: a +// token without the scope for /user will never grow one mid-process, and a rate +// limit surfaces as its own error rather than an APIError. +func deniedIdentity(err error) bool { + var api *APIError + if !errors.As(err, &api) { + return errors.Is(err, ErrNotFound) + } + switch api.Status { + case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound: + return true + } + return false +} + type SearchPR struct { Repo string Number int Author string + Title string Body string } @@ -1202,3 +1300,108 @@ func refPath(ref string) string { } return strings.Join(parts, "/") } + +// Repo is one repository as the dashboard's repository picker needs it: enough +// to recognize and to judge whether it is worth enrolling, and nothing more. +type Repo struct { + FullName string `json:"full_name"` + Private bool `json:"private"` + Archived bool `json:"archived"` + Fork bool `json:"fork"` + // OpenIssues is GitHub's open_issues_count, which counts issues AND pull + // requests together. There is no per-repo open-PR count without a search per + // repository, so this is reported as what it is rather than relabelled. + OpenIssues int `json:"open_issues_count"` + PushedAt time.Time `json:"pushed_at"` + Language string `json:"language"` +} + +// ListOwnerRepos lists the repositories an owner has, following pagination up +// to limit. It resolves user-vs-organization the same way the PR search does, and +// through the same cache — the two ask the identical question about a login. +// +// Archived repositories are kept rather than filtered: the caller decides, and a +// picker that silently omits a repository somebody is looking for is worse than +// one that shows it greyed out. +func (g *GitHub) ListOwnerRepos(ctx context.Context, owner string, limit int) ([]Repo, error) { + qualifier, err := g.searchOwnerQualifier(ctx, owner) + if err != nil { + return nil, err + } + viewer, err := g.viewerLogin(ctx) + if err != nil { + return nil, err + } + if limit <= 0 { + limit = 200 + } + if qualifier == "org:" { + return g.listRepos(ctx, "/orgs/"+owner+"/repos", "", limit) + } + if strings.EqualFold(owner, viewer) { + return g.listRepos(ctx, "/user/repos?affiliation=owner,collaborator", owner, limit) + } + public, err := g.listRepos(ctx, "/users/"+owner+"/repos", "", limit) + if err != nil { + return nil, err + } + if viewer == "" { + return public, nil + } + // Another personal owner needs both views. The public endpoint includes + // repositories where the viewer has no affiliation; the authenticated list + // adds private repositories on which the viewer collaborates. + private, err := g.listRepos(ctx, "/user/repos?affiliation=owner,collaborator", owner, limit) + if err != nil { + return nil, err + } + byName := make(map[string]Repo, len(public)+len(private)) + for _, repo := range append(public, private...) { + byName[strings.ToLower(repo.FullName)] = repo + } + out := make([]Repo, 0, len(byName)) + for _, repo := range byName { + out = append(out, repo) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].PushedAt.After(out[j].PushedAt) }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (g *GitHub) listRepos(ctx context.Context, base, filterOwner string, limit int) ([]Repo, error) { + out := make([]Repo, 0, limit) + maxPages := (limit + 99) / 100 + for page := 1; len(out) < limit && (filterOwner != "" || page <= maxPages); page++ { + var batch []Repo + sep := "?" + if strings.Contains(base, "?") { + sep = "&" + } + path := fmt.Sprintf("%s%sper_page=100&page=%d&sort=pushed", base, sep, page) + if err := g.request(ctx, http.MethodGet, path, nil, &batch); err != nil { + return nil, err + } + for _, repo := range batch { + if filterOwner == "" || strings.EqualFold(ownerOfRepo(repo.FullName), filterOwner) { + out = append(out, repo) + } + if len(out) == limit { + break + } + } + if len(batch) < 100 { + break + } + } + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func ownerOfRepo(fullName string) string { + owner, _, _ := strings.Cut(fullName, "/") + return owner +} diff --git a/internal/gh/github_test.go b/internal/gh/github_test.go index 3eeff88b..738bbee9 100644 --- a/internal/gh/github_test.go +++ b/internal/gh/github_test.go @@ -2,12 +2,14 @@ package gh import ( "context" + "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strconv" "strings" + "sync" "sync/atomic" "testing" "time" @@ -701,3 +703,307 @@ func TestListCheckRunsRidesETagCache(t *testing.T) { t.Fatalf("the second call must carry If-None-Match, got %d conditional requests", got) } } + +// The repository picker offers what is in scope, and /users/{owner}/repos lists +// only PUBLIC repositories whoever asks. A personal account is the ordinary +// case for the private workflow, so the picker showed a partial list — or an +// empty one — while claiming to show everything in scope. Only the +// authenticated-user endpoint answers for private repositories owned by the +// token and for private repositories it collaborates on under another user. +func TestListOwnerReposUsesTheAuthenticatedEndpointForPersonalAccounts(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "t") + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/user": + _, _ = w.Write([]byte(`{"login":"alice"}`)) + case "/users/alice", "/users/bob": + _, _ = w.Write([]byte(`{"type":"User"}`)) + case "/user/repos": + paths = append(paths, r.URL.String()) + _, _ = w.Write([]byte(`[ + {"full_name":"alice/private","private":true}, + {"full_name":"bob/private","private":true} + ]`)) + case "/users/bob/repos": + paths = append(paths, r.URL.String()) + _, _ = w.Write([]byte(`[ + {"full_name":"bob/public"}, + {"full_name":"bob/private","private":false} + ]`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + g := &GitHub{token: "t", httpClient: srv.Client(), apiBase: srv.URL, maxRetries: 2, maxWait: time.Second, backoffBase: time.Millisecond, networkMaxWait: time.Second} + + own, err := g.ListOwnerRepos(context.Background(), "alice", 100) + if err != nil { + t.Fatal(err) + } + if len(own) != 1 || own[0].FullName != "alice/private" || !own[0].Private { + t.Fatalf("repos = %+v, want the token's own private repository", own) + } + if len(paths) != 1 || !strings.Contains(paths[0], "affiliation=owner,collaborator") || + !strings.Contains(paths[0], "per_page=100") { + t.Fatalf("requested %q, want the authenticated endpoint with both query halves intact", paths) + } + + // Another personal owner is filtered from the authenticated list, which + // includes private repositories on which the viewer collaborates. + other, err := g.ListOwnerRepos(context.Background(), "bob", 100) + if err != nil { + t.Fatal(err) + } + if len(other) != 2 { + t.Fatalf("repos = %+v, want the public and private collaborator repositories", other) + } + byName := map[string]Repo{} + for _, repo := range other { + byName[repo.FullName] = repo + } + if !byName["bob/private"].Private || byName["bob/public"].Private { + t.Fatalf("repos = %+v, want deduplicated public plus authenticated-private metadata", other) + } + if len(paths) != 3 || + !strings.HasPrefix(paths[1], "/users/bob/repos?per_page=100") || + !strings.HasPrefix(paths[2], "/user/repos?affiliation=owner,collaborator") { + t.Fatalf("requested %q, want both public and authenticated endpoints for another personal owner", paths) + } +} + +func TestListOwnerReposCanReadOneSentinelPastTenPages(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "t") + pages := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/user": + _, _ = w.Write([]byte(`{"login":"alice"}`)) + case "/users/alice": + _, _ = w.Write([]byte(`{"type":"User"}`)) + case "/user/repos": + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + pages = max(pages, page) + count := 100 + if page == 11 { + count = 1 + } + repos := make([]Repo, 0, count) + for i := 0; i < count; i++ { + repos = append(repos, Repo{FullName: "alice/repo-" + strconv.Itoa((page-1)*100+i)}) + } + _ = json.NewEncoder(w).Encode(repos) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + g := &GitHub{token: "t", httpClient: srv.Client(), apiBase: srv.URL, maxRetries: 2, maxWait: time.Second, backoffBase: time.Millisecond, networkMaxWait: time.Second} + + repos, err := g.ListOwnerRepos(context.Background(), "alice", 1001) + if err != nil { + t.Fatal(err) + } + if len(repos) != 1001 || pages != 11 { + t.Fatalf("repos=%d pages=%d, want the one-row sentinel from page 11", len(repos), pages) + } +} + +func TestListOwnerReposPaginatesPastNonOwnerRows(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "t") + pages := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/user": + _, _ = w.Write([]byte(`{"login":"alice"}`)) + case "/users/alice": + _, _ = w.Write([]byte(`{"type":"User"}`)) + case "/user/repos": + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + pages = max(pages, page) + if page == 1 { + repos := make([]Repo, 100) + for i := range repos { + repos[i].FullName = "other/repo-" + strconv.Itoa(i) + } + _ = json.NewEncoder(w).Encode(repos) + return + } + _ = json.NewEncoder(w).Encode([]Repo{{FullName: "alice/private-old"}}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + g := &GitHub{token: "t", httpClient: srv.Client(), apiBase: srv.URL, maxRetries: 2, maxWait: time.Second, backoffBase: time.Millisecond, networkMaxWait: time.Second} + + repos, err := g.ListOwnerRepos(context.Background(), "alice", 1) + if err != nil { + t.Fatal(err) + } + if len(repos) != 1 || repos[0].FullName != "alice/private-old" || pages != 2 { + t.Fatalf("repos=%+v pages=%d, want the owner match from page 2", repos, pages) + } +} + +// "crq cannot read who this token is" is cached for the process, because a +// token without the scope will not grow one. A 500 or a timeout is not that +// answer — caching it kept every later repository picker on the public-only +// listing, which omits the caller's own private repositories, long after GitHub +// came back. +func TestViewerLoginRetriesAfterATransientFailureButNotARefusal(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "t") + var down int32 = 1 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/user" { + http.NotFound(w, r) + return + } + if atomic.LoadInt32(&down) == 1 { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"message":"unavailable"}`)) + return + } + _, _ = w.Write([]byte(`{"login":"kristofferR"}`)) + })) + defer srv.Close() + + g := &GitHub{token: "t", httpClient: srv.Client(), apiBase: srv.URL, maxRetries: 1, maxWait: time.Millisecond, backoffBase: time.Millisecond, networkMaxWait: time.Millisecond} + if got, err := g.viewerLogin(context.Background()); err == nil || got != "" { + t.Fatalf("viewerLogin = %q, err %v, want the transient failure propagated", got, err) + } + atomic.StoreInt32(&down, 0) + if got, err := g.viewerLogin(context.Background()); err != nil || got != "kristofferR" { + t.Errorf("viewerLogin = %q, err %v, want the identity once GitHub answers again", got, err) + } + + // A refusal IS an answer, and asking again every time would spend quota on + // a question whose answer cannot change within the process. + var denials int32 + denied := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&denials, 1) + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Bad credentials"}`)) + })) + defer denied.Close() + d := &GitHub{token: "t", httpClient: denied.Client(), apiBase: denied.URL, maxRetries: 1, maxWait: time.Millisecond, backoffBase: time.Millisecond, networkMaxWait: time.Millisecond} + if got, err := d.viewerLogin(context.Background()); err != nil || got != "" { + t.Fatalf("viewerLogin = %q, err %v, want none from a refused token", got, err) + } + // Counted from here: send retries a 401 once with a refreshed token, so the + // question is whether a SECOND call asks again, not how many requests the + // first one took. + asked := atomic.LoadInt32(&denials) + if got, err := d.viewerLogin(context.Background()); err != nil || got != "" { + t.Fatalf("viewerLogin = %q, err %v, want none from a refused token", got, err) + } + if got := atomic.LoadInt32(&denials); got != asked { + t.Errorf("asked %d more times, want the refusal remembered", got-asked) + } +} + +func TestViewerLoginDoesNotOverwriteConcurrentIdentity(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "t") + firstEntered := make(chan struct{}) + secondEntered := make(chan struct{}) + releaseDenied := make(chan struct{}) + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + switch atomic.AddInt32(&calls, 1) { + case 1: + close(firstEntered) + <-secondEntered + _, _ = w.Write([]byte(`{"login":"alice"}`)) + case 2: + close(secondEntered) + <-releaseDenied + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Bad credentials"}`)) + default: + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Bad credentials"}`)) + } + })) + defer srv.Close() + g := &GitHub{token: "t", httpClient: srv.Client(), apiBase: srv.URL, maxRetries: 1, maxWait: time.Millisecond, backoffBase: time.Millisecond, networkMaxWait: time.Millisecond} + + var firstLogin string + var firstErr error + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + firstLogin, firstErr = g.viewerLogin(t.Context()) + }() + <-firstEntered + secondResult := make(chan struct { + login string + err error + }, 1) + go func() { + login, err := g.viewerLogin(t.Context()) + secondResult <- struct { + login string + err error + }{login, err} + }() + wg.Wait() + close(releaseDenied) + second := <-secondResult + + if firstErr != nil || firstLogin != "alice" { + t.Fatalf("successful viewer lookup = %q, %v", firstLogin, firstErr) + } + if second.err != nil || second.login != "alice" { + t.Fatalf("concurrent refusal returned %q, %v; want cached identity", second.login, second.err) + } + if got, err := g.viewerLogin(t.Context()); err != nil || got != "alice" { + t.Fatalf("cached viewer = %q, %v; successful identity was overwritten", got, err) + } +} + +func TestViewerLoginConcurrentIdentityReplacesRefusal(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "t") + successEntered := make(chan struct{}) + releaseSuccess := make(chan struct{}) + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + switch atomic.AddInt32(&calls, 1) { + case 1: + close(successEntered) + <-releaseSuccess + _, _ = w.Write([]byte(`{"login":"alice"}`)) + default: + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Bad credentials"}`)) + } + })) + defer srv.Close() + g := &GitHub{token: "t", httpClient: srv.Client(), apiBase: srv.URL, maxRetries: 1, maxWait: time.Millisecond, backoffBase: time.Millisecond, networkMaxWait: time.Millisecond} + + successResult := make(chan struct { + login string + err error + }, 1) + go func() { + login, err := g.viewerLogin(t.Context()) + successResult <- struct { + login string + err error + }{login, err} + }() + <-successEntered + if login, err := g.viewerLogin(t.Context()); err != nil || login != "" { + t.Fatalf("concurrent refusal = %q, %v; want an unreadable identity", login, err) + } + close(releaseSuccess) + success := <-successResult + + if success.err != nil || success.login != "alice" { + t.Fatalf("successful viewer lookup = %q, %v", success.login, success.err) + } + if got, err := g.viewerLogin(t.Context()); err != nil || got != "alice" { + t.Fatalf("cached viewer = %q, %v; successful identity did not replace refusal", got, err) + } +} diff --git a/internal/serve/actions.go b/internal/serve/actions.go new file mode 100644 index 00000000..c9f9c602 --- /dev/null +++ b/internal/serve/actions.go @@ -0,0 +1,545 @@ +package serve + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +// Actor performs the mutations the dashboard offers. Every one is a thin mirror +// of a CLI verb and goes through the same Service method, so the dashboard can +// never become a second way to change state with different rules. +type Actor interface { + Hold(ctx context.Context, repo string, pr int, reason string) error + Unhold(ctx context.Context, repo string, pr int) error + Prioritize(ctx context.Context, repo string, pr int) error + Cancel(ctx context.Context, repo string, pr int) error + SetAutofix(ctx context.Context, repo string, enabled bool, reason string) error + ClearAutofix(ctx context.Context, repo string) error + // SetEnrollment decides whether crq reviews a repository at all, and + // ClearEnrollment hands it back to the hosts' env files. Like the reviewer + // override, they report the hosts that will not honour the record. + SetEnrollment(ctx context.Context, repo string, enabled bool, reason string, expectedRev *int64) (lagging []string, err error) + ClearEnrollment(ctx context.Context, repo string, expectedRev *int64) error + // Fleet reads the recorded defaults; SetFleet applies a change, or with + // preview reports what it WOULD do and writes nothing. A fleet save reaches + // every repository that has not overridden the setting, so the preview is + // not a nicety — it is how someone finds out that adding a required reviewer + // reopens nineteen completed rounds before they click. + Fleet(ctx context.Context) (*FleetSettings, error) + // EnvSettings is every individual setting with its effective value and the + // layer that decided it. Pure: it reads the state it is handed. + EnvSettings(st state.State) []EnvSetting + // SetEnv previews or records one fleet-wide setting by its env name. + SetEnv(ctx context.Context, key, value string, unset bool, expectedRev *int64, preview bool) (FleetImpact, error) + // SetSolver records how one repository's fix sessions run, or with an empty + // repo the fleet default every repository inherits. + SetSolver(ctx context.Context, repo string, change SolverChange) error + SetFleet(ctx context.Context, change FleetChange, preview bool) (FleetImpact, error) + // SetReviewers returns the hosts that will not honour the override, so the + // UI can say so rather than reporting a save that some daemon ignores. + SetReviewers( + ctx context.Context, + repo string, + coBots, required []string, + primary *bool, + expectedRev *int64, + preview bool, + ) (lagging []string, impact FleetImpact, err error) + ClearReviewers(ctx context.Context, repo string, expectedRev *int64, preview bool) (FleetImpact, error) + + // The three ways a finding stops blocking. They are distinct on purpose: + // resolving says it was handled, declining says it was considered and + // rejected (and posts that reasoning back), dismissing accounts for a + // finding GitHub gives no way to close. + ResolveThreads(ctx context.Context, threadIDs []string) error + DeclineThreads(ctx context.Context, threadIDs []string, reason string, resolve bool) error + DismissFindings(ctx context.Context, repo string, pr int, ids []string, reason string) error +} + +type actionRequest struct { + Repo string `json:"repo"` + PR int `json:"pr"` + Reason string `json:"reason"` + Enabled *bool `json:"enabled"` + // ExpectedRev binds a confirmed enrollment preview to the state it priced. + ExpectedRev *int64 `json:"expected_rev,omitempty"` + // CoBots and Required are the whole intended sets, not a delta: a save that + // sent only changes could not express "explicitly none". + CoBots []string `json:"cobots"` + Required []string `json:"required"` + // Primary is nil to leave the metered reviewer's switch alone, or points at + // whether it runs on this repository at all. + Primary *bool `json:"primary"` + Clear bool `json:"clear"` + // Fleet carries a fleet-defaults change, raw so its own pointer fields keep + // "not chosen" distinct from "chosen to be zero". + Fleet json.RawMessage `json:"fleet"` + // Preview asks what a change would do without making it. + Preview bool `json:"preview"` + // Solver carries a fix-session change, raw so its pointer fields keep "not + // chosen" distinct from "chosen to be empty". + Solver json.RawMessage `json:"solver"` + // Key/Value address one setting by its environment-variable name. + Key string `json:"key"` + Value string `json:"value"` + + ThreadIDs []string `json:"thread_ids"` + FindingIDs []string `json:"finding_ids"` + // KeepOpen declines a finding without resolving its thread, for when the + // disagreement is worth leaving visible. + KeepOpen bool `json:"keep_open"` +} + +// handleAction runs one action and returns the refreshed snapshot, so the UI +// never has to guess whether the write landed — it sees the new state or an +// error, and nothing in between. +func (s *Server) handleAction(w http.ResponseWriter, r *http.Request) { + if s.actor == nil || s.opts.ReadOnly { + writeJSON(w, http.StatusForbidden, map[string]string{ + "error": "this dashboard is read-only", + }) + return + } + // A custom header a browser cannot set cross-origin without a preflight. + // The server is unauthenticated on a tailnet, so this is what stops a page + // on another site from posting to it behind your back. + if r.Header.Get("X-CRQ-Dashboard") != "1" { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "missing dashboard header"}) + return + } + // The header is not enough on its own, because a DNS rebind makes the + // attacker's page same-origin: a name they control, re-pointed at 127.0.0.1, + // reaches this server with any header it likes and no preflight at all. The + // one thing that still tells the two apart is the name the request was + // addressed to, so this checks that it is a name this dashboard answers to. + if err := s.addressedHere(r); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + return + } + + var req actionRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed request"}) + return + } + // Fleet settings are the one action with no repository: they are the answer + // for every repository that has not overridden them. + req.Repo = strings.TrimSpace(req.Repo) + action := r.PathValue("action") + fleetWide := action == "fleet" || action == "env" || (action == "solver" && req.Repo == "") + if !fleetWide && (req.Repo == "" || !strings.Contains(req.Repo, "/")) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "repo must be owner/name"}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + var err error + switch action { + case "hold": + // The reason is the record: every screen that shows a hold shows why, + // and an unexplained hold is the one nobody dares lift. + if strings.TrimSpace(req.Reason) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "a reason is required"}) + return + } + err = s.needPR(req, func() error { return s.actor.Hold(ctx, req.Repo, req.PR, req.Reason) }) + case "unhold": + err = s.needPR(req, func() error { return s.actor.Unhold(ctx, req.Repo, req.PR) }) + case "prioritize": + err = s.needPR(req, func() error { return s.actor.Prioritize(ctx, req.Repo, req.PR) }) + case "cancel": + err = s.needPR(req, func() error { return s.actor.Cancel(ctx, req.Repo, req.PR) }) + case "autofix": + if req.Enabled == nil { + err = s.actor.ClearAutofix(ctx, req.Repo) // back to the fleet default + } else { + err = s.actor.SetAutofix(ctx, req.Repo, *req.Enabled, req.Reason) + } + case "reviewers": + if req.Clear { + impact, clearErr := s.actor.ClearReviewers(ctx, req.Repo, req.ExpectedRev, req.Preview) + if clearErr != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": clearErr.Error()}) + return + } + if req.Preview { + writeJSON(w, http.StatusOK, map[string]any{"impact": impact}) + return + } + snap, warning := s.actionSnapshot(ctx) + response := map[string]any{"snapshot": snap, "impact": impact} + if warning != "" { + response["warning"] = warning + } + writeJSON(w, http.StatusOK, response) + return + } + // A nil list was not sent at all (a save that only flips the primary + // switch), which is different from a sent-but-empty one. The service + // re-checks the RESOLVED set either way — this is the early, specific + // error for the case the UI can produce directly. + if req.Required != nil && len(req.Required) == 0 { + // Convergence would never be reachable, so refuse here rather than + // letting a round wait for a set nothing can satisfy. + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "at least one reviewer must be required, or convergence can never happen", + }) + return + } + var lagging []string + var impact FleetImpact + lagging, impact, err = s.actor.SetReviewers( + ctx, req.Repo, req.CoBots, req.Required, req.Primary, req.ExpectedRev, req.Preview, + ) + if err == nil && req.Preview { + writeJSON(w, http.StatusOK, map[string]any{"impact": impact}) + return + } + if err == nil && len(lagging) > 0 { + s.writeActionSnapshot(w, ctx, "saved, but these hosts run an older binary and will ignore it: "+ + strings.Join(hostsOf(lagging), ", ")) + return + } + case "enroll": + if req.Clear { + err = s.actor.ClearEnrollment(ctx, req.Repo, req.ExpectedRev) + break + } + if req.Enabled == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "enabled must be true or false"}) + return + } + // The service refuses an unexplained removal too; asking here means the + // dialog can say so before the round trip. + if !*req.Enabled && strings.TrimSpace(req.Reason) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "a reason is required to stop reviewing a repository"}) + return + } + var lagging []string + lagging, err = s.actor.SetEnrollment(ctx, req.Repo, *req.Enabled, req.Reason, req.ExpectedRev) + if err == nil && len(lagging) > 0 { + s.writeActionSnapshot(w, ctx, "saved, but these hosts run an older binary and decide from their own env alone: "+ + strings.Join(hostsOf(lagging), ", ")) + return + } + case "fleet": + var change FleetChange + if err := json.Unmarshal(req.Fleet, &change); len(req.Fleet) > 0 && err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed fleet change"}) + return + } + impact, ferr := s.actor.SetFleet(ctx, change, req.Preview) + if ferr != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": ferr.Error()}) + return + } + if req.Preview { + // A preview writes nothing, so there is no new snapshot to return + // and returning the old one would read as a save that did nothing. + writeJSON(w, http.StatusOK, map[string]any{"impact": impact}) + return + } + snap, warning := s.actionSnapshot(ctx) + response := map[string]any{"snapshot": snap, "impact": impact} + if warning != "" { + response["warning"] = warning + } + writeJSON(w, http.StatusOK, response) + return + case "env": + if strings.TrimSpace(req.Key) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no setting named"}) + return + } + impact, envErr := s.actor.SetEnv(ctx, req.Key, req.Value, req.Clear, req.ExpectedRev, req.Preview) + if envErr != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": envErr.Error()}) + return + } + if req.Preview { + writeJSON(w, http.StatusOK, map[string]any{"impact": impact}) + return + } + snap, warning := s.actionSnapshot(ctx) + response := map[string]any{"snapshot": snap, "impact": impact} + if warning != "" { + response["warning"] = warning + } + writeJSON(w, http.StatusOK, response) + return + case "solver": + var change SolverChange + if err := json.Unmarshal(req.Solver, &change); len(req.Solver) > 0 && err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed solver change"}) + return + } + // An empty repo is the fleet default, which is why this action is one of + // the two that does not require one. + err = s.actor.SetSolver(ctx, req.Repo, change) + case "resolve": + if len(req.ThreadIDs) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no thread given"}) + return + } + err = s.actor.ResolveThreads(ctx, req.ThreadIDs) + case "decline": + if len(req.ThreadIDs) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no thread given"}) + return + } + if strings.TrimSpace(req.Reason) == "" { + // The reason is posted to the pull request as a reply, so an empty + // one would leave a bare rejection on someone else's review. + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "a reason is required"}) + return + } + err = s.actor.DeclineThreads(ctx, req.ThreadIDs, req.Reason, !req.KeepOpen) + case "dismiss": + if len(req.FindingIDs) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no finding given"}) + return + } + if strings.TrimSpace(req.Reason) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "a reason is required"}) + return + } + err = s.needPR(req, func() error { + return s.actor.DismissFindings(ctx, req.Repo, req.PR, req.FindingIDs, req.Reason) + }) + default: + http.NotFound(w, r) + return + } + if err != nil { + status := http.StatusBadGateway + if errors.Is(err, errBadPR) { + status = http.StatusBadRequest + } + writeJSON(w, status, map[string]string{"error": err.Error()}) + return + } + + // Re-read immediately rather than waiting for the next poll: the person who + // clicked is watching, and a stale answer reads as a failed click. + s.writeActionSnapshot(w, ctx, "") +} + +// writeActionSnapshot reports a completed mutation even if its confirming read +// failed. Repeating a non-idempotent action such as decline could otherwise +// post duplicate replies. The previous snapshot remains useful while the next +// poll retries the read, and the warning tells the client it may be stale. +func (s *Server) writeActionSnapshot(w http.ResponseWriter, ctx context.Context, warning string) { + snap, refreshWarning := s.actionSnapshot(ctx) + if refreshWarning != "" { + if warning != "" { + warning += " " + } + warning += refreshWarning + } + if warning == "" { + writeJSON(w, http.StatusOK, snap) + return + } + writeJSON(w, http.StatusOK, map[string]any{"snapshot": snap, "warning": warning}) +} + +func (s *Server) actionSnapshot(ctx context.Context) (Snapshot, string) { + snap, err := s.refreshedSnapshot(ctx) + if err == nil { + return snap, "" + } + return snap, err.Error() +} + +func (s *Server) refreshedSnapshot(ctx context.Context) (Snapshot, error) { + s.refresh(ctx) + snap, loaded, err := s.snapshot() + if err != nil { + return snap, fmt.Errorf("action succeeded, but refreshing state failed: %w", err) + } + if !loaded { + return Snapshot{}, errors.New("action succeeded, but refreshing state produced no snapshot") + } + return snap, nil +} + +// addressedHere reports whether an action request was addressed to this +// dashboard, rather than to a name that merely resolves to it. +// +// This is the anti-rebinding check, and it is about NAMES. An address bar +// holding an IP literal cannot be rebound — the browser dials it, and the origin +// is the address itself — so those pass, as does loopback by its own name. Every +// other name has to be one this host answers to: the address the server was +// asked to bind, the machine's own hostname — itself, or its short name in a +// zone nobody outside can publish in, so `mac.local` and a tailnet's +// `mac.tailnet.ts.net` are the same machine — or one named with --allow-host for +// a proxy or an alias crq cannot know about. +// +// An Origin, when the browser sends one, must agree with it: same-origin is the +// whole claim being made, and a request that contradicts itself is not one. +func (s *Server) addressedHere(r *http.Request) error { + host := hostname(r.Host) + if host == "" { + return errors.New("refusing an action with no Host: crq serve answers only to its own address") + } + if origin := strings.TrimSpace(r.Header.Get("Origin")); origin != "" { + u, err := url.Parse(origin) + if err != nil || !strings.EqualFold(hostname(u.Host), host) { + return fmt.Errorf("refusing an action sent from %s to %s: the two must be the same dashboard", origin, r.Host) + } + } + if s.answersTo(host) { + return nil + } + return fmt.Errorf("refusing an action addressed to %q: crq serve is unauthenticated, so it acts only on its own address — "+ + "a name that resolves here is how a page on another site reaches a local dashboard. Start it with --allow-host %s if that name is yours", + host, host) +} + +// answersTo reports whether host names this server. +func (s *Server) answersTo(host string) bool { + switch { + case net.ParseIP(host) != nil: + return true + case host == "localhost" || strings.HasSuffix(host, ".localhost"): + return true + } + for _, allowed := range s.opts.AllowedHosts { + if strings.EqualFold(hostname(strings.TrimSpace(allowed)), host) { + return true + } + } + // The bound address and this machine's own name, exactly as configured — and + // the short forms of it: a host is reached as `mac`, `mac.local` and + // `mac..ts.net` interchangeably, and all of those are the same + // machine answering. + for _, own := range []string{hostname(s.opts.Addr), hostname(s.opts.Host)} { + if own == "" || own == "0.0.0.0" || own == "::" { + continue + } + if host == own { + return true + } + if short := firstLabel(own); short != "" && (host == short || isOwnNameIn(host, short)) { + return true + } + } + return false +} + +// localZones are the suffixes this machine's own short name may be reached +// under. They are the zones nobody outside can publish a record in: mDNS on the +// local link, a home network's own domain, and a Tailscale tailnet. +// +// The list is what makes the name check a check at all. Comparing first labels +// alone accepted ANY zone sharing this machine's name — `mac.attacker.example` +// for a machine called `mac` — and that name is registrable, points wherever its +// owner says, and is same-origin with the page doing the pointing. It defeated +// the whole anti-rebinding check for any host whose name an attacker can guess, +// which is most of them. A name in some other zone is a proxy or an alias crq +// cannot know about, and --allow-host is how it is told. +var localZones = []string{"local", "lan", "home.arpa", "internal"} + +// isOwnNameIn reports whether host is this machine's short name in one of the +// zones above — `mac.local` — or in a tailnet, which puts its own name between +// the machine and the zone: `mac.tail1234.ts.net`. +func isOwnNameIn(host, own string) bool { + rest, ok := strings.CutPrefix(host, own+".") + if !ok { + return false + } + for _, zone := range localZones { + if rest == zone { + return true + } + } + return strings.HasSuffix(rest, ".ts.net") +} + +// hostname is the lowercased name in an authority, without its port. +func hostname(authority string) string { + authority = strings.TrimSpace(authority) + if h, _, err := net.SplitHostPort(authority); err == nil { + authority = h + } + return strings.ToLower(strings.Trim(authority, "[]")) +} + +func firstLabel(host string) string { + name, _, _ := strings.Cut(hostname(host), ".") + return name +} + +func (s *Server) needPR(req actionRequest, run func() error) error { + if req.PR <= 0 { + return errBadPR + } + return run() +} + +var errBadPR = errPR("a pull request number is required") + +type errPR string + +func (e errPR) Error() string { return string(e) } + +// hostsOf reduces writer keys ("host=X pid=… run=…") to the machine names a +// person would act on. +func hostsOf(writers []string) []string { + out := make([]string, 0, len(writers)) + seen := map[string]bool{} + for _, w := range writers { + h := state.WriterHost(w) + if seen[h] { + continue + } + seen[h] = true + out = append(out, h) + } + return out +} + +// FleetChange mirrors crq.FleetChange on the wire. Pointers throughout so a +// form that posts its whole state cannot overwrite a setting another host +// changed a second earlier. +type FleetChange struct { + CoBots []string `json:"cobots"` + Required []string `json:"required"` + MinInterval *string `json:"min_interval"` + WeeklyLimit *int `json:"weekly_limit"` + AutofixDefault *bool `json:"autofix_default"` + ExpectedRev *int64 `json:"expected_rev,omitempty"` + Clear bool `json:"clear"` +} + +// SolverChange mirrors crq.SolverChange on the wire. +type SolverChange struct { + Models []string `json:"models"` + Model *string `json:"model"` + Effort *string `json:"effort"` + Prompt *string `json:"prompt"` + MaxAttempts *int `json:"max_attempts"` + Severities []string `json:"severities"` + AskMode *string `json:"ask_mode"` + Forks *bool `json:"forks"` + SkipAuthors []string `json:"skip_authors"` + UnsetModels bool `json:"unset_models,omitempty"` + UnsetEffort bool `json:"unset_effort,omitempty"` + UnsetPrompt bool `json:"unset_prompt,omitempty"` + UnsetSeverities bool `json:"unset_severities,omitempty"` + UnsetAskMode bool `json:"unset_ask_mode,omitempty"` + UnsetForks bool `json:"unset_forks,omitempty"` + UnsetSkipAuthors bool `json:"unset_skip_authors,omitempty"` + Clear bool `json:"clear"` +} diff --git a/internal/serve/assets.go b/internal/serve/assets.go new file mode 100644 index 00000000..aa9b9328 --- /dev/null +++ b/internal/serve/assets.go @@ -0,0 +1,25 @@ +package serve + +import ( + "embed" + "io/fs" +) + +// The SPA is embedded from web/dist. The directory is committed with a +// placeholder so a source checkout still builds before anyone runs the +// frontend toolchain — Assets() reports "not built" instead of failing. +// +//go:embed all:dist +var dist embed.FS + +// Assets returns the built SPA, or nil when only the placeholder is present. +func Assets() fs.FS { + sub, err := fs.Sub(dist, "dist") + if err != nil { + return nil + } + if _, err := fs.Stat(sub, "index.html"); err != nil { + return nil + } + return sub +} diff --git a/internal/serve/dist/assets/BotsRoute-Coq2BPzC.js b/internal/serve/dist/assets/BotsRoute-Coq2BPzC.js new file mode 100644 index 00000000..5992a861 --- /dev/null +++ b/internal/serve/dist/assets/BotsRoute-Coq2BPzC.js @@ -0,0 +1 @@ +import{c as e,o as t,t as n}from"./ui-Bim2uazI.js";import{b as r,c as i,g as a,n as o,r as s,s as c,v as l}from"./index-DlC1aBiV.js";var u=a(),d=r(),f={working:{tone:`ok`,label:`Working`,note:`crq saw it answer here in the last week`},quiet:{tone:`warn`,label:`Quiet`,note:`crq saw it answer once, but not lately`},silent:{tone:`bad`,label:`Never answered`,note:`crq has asked it and never seen an answer — most likely it is not set up on this account, and every trigger crq posts for it is a comment nobody reads`},unverified:{tone:`mut`,label:`Not verified`,note:`enabled, but crq has no evidence either way yet — it records a bot's answers only as it observes rounds, so this is normal for a while after an upgrade`},off:{tone:`mut`,label:`Not enabled`,note:`crq does not ask for its review on this fleet`}};function p(r){let i=(0,u.c)(13),{bots:a}=r,o=c(5e3),p;i[0]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,d.jsx)(`h1`,{className:`text-xl font-[650] tracking-tight`,children:`Review bots`}),i[0]=p):p=i[0];let h;i[1]===Symbol.for(`react.memo_cache_sentinel`)?(h=(0,d.jsx)(`b`,{children:`Reviewers`}),i[1]=h):h=i[1];let g;i[2]===Symbol.for(`react.memo_cache_sentinel`)?(g=(0,d.jsxs)(`p`,{className:`mt-1 max-w-[760px] text-[13.5px] text-mut`,children:[`Every reviewer crq knows how to drive. Status is what crq itself recorded — a trigger it posted, a claim it observed — never a status read from the vendor, which none of them offers. To change which bots run, use a repository's `,h,` card, or the fleet default under `,(0,d.jsx)(`b`,{children:`Settings`}),`.`]}),i[2]=g):g=i[2];let _;if(i[3]!==a||i[4]!==o){let r;i[6]===o?r=i[7]:(r=r=>{let i=f[r.status]??f.off;return(0,d.jsxs)(`section`,{className:`flex flex-col rounded-[10px] border bg-card shadow-card ${r.enabled?`border-edge`:`border-dashed border-edge`}`,children:[(0,d.jsxs)(`header`,{className:`flex items-start gap-3 px-5 pt-4`,children:[(0,d.jsx)(n,{login:r.login,name:r.name,size:38}),(0,d.jsxs)(`div`,{className:`min-w-0`,children:[(0,d.jsx)(`h2`,{className:`text-base font-[650]`,children:r.name}),(0,d.jsxs)(`div`,{className:`text-xs text-faint`,children:[r.primary?`primary reviewer`:`co-reviewer`,` ·`,` `,r.metered?`spends the shared quota`:`free, no crq quota`]})]}),(0,d.jsx)(`span`,{className:`ml-auto shrink-0`,title:i.note,children:(0,d.jsx)(e,{tone:i.tone,children:i.label})})]}),(0,d.jsxs)(`div`,{className:`flex-1 px-5 pt-3 text-[13px] text-mut`,children:[r.pitch&&(0,d.jsx)(`p`,{children:r.pitch}),r.cost&&(0,d.jsxs)(`p`,{className:`mt-2`,children:[(0,d.jsx)(`span`,{className:`text-faint`,children:`Cost — `}),r.cost,r.prices_checked_at&&(0,d.jsxs)(`span`,{className:`text-faint`,children:[` (checked `,r.prices_checked_at,`)`]})]}),r.suggested&&r.because&&(0,d.jsxs)(`p`,{className:`mt-2 rounded-lg border border-ok-edge bg-ok-bg px-2.5 py-1.5 text-[12.5px] text-ok`,children:[(0,d.jsx)(`b`,{children:`Suggested here`}),` — `,r.because,`.`]}),r.suited_to&&(0,d.jsxs)(`p`,{className:`mt-2 rounded-lg border border-acc-edge bg-acc-bg px-2.5 py-1.5 text-[12.5px] text-acc`,children:[`Worth it for `,r.suited_to,`.`]}),(0,d.jsxs)(`p`,{className:`mt-2.5 text-[12.5px] text-faint`,children:[i.note,r.last_seen&&r.seen_on&&(()=>{let[e,n]=r.seen_on.split(`#`),i=e===void 0?``:e,a=Number(n===void 0?``:n);return!i||!Number.isInteger(a)||a<=0?null:(0,d.jsxs)(d.Fragment,{children:[` — last on `,(0,d.jsx)(t,{repo:i,pr:a}),` `,s(r.last_seen,o)]})})(),r.repo_count>0&&` · ${r.repo_count} repo override(s) name it`]}),r.status===`silent`&&r.last_asked&&(0,d.jsxs)(`p`,{className:`mt-2 rounded-lg border border-bad-edge bg-bad-bg px-2.5 py-1.5 text-[12.5px] text-bad`,children:[`Last asked `,s(r.last_asked,o),` and it has never answered. Turn it off on the repositories that use it, or finish setting it up — until then crq posts a trigger comment on every round and waits out the grace period for nothing.`]}),r.status!==`working`&&(r.setup?.length??0)>0&&(0,d.jsxs)(`details`,{className:`mt-2.5 rounded-lg border border-edge bg-[#FBFBFC] px-2.5 py-1.5`,children:[(0,d.jsx)(`summary`,{className:`cursor-pointer text-[12.5px] font-[550]`,children:`Setting it up`}),(0,d.jsx)(`ol`,{className:`mt-1.5 list-decimal pl-4 text-[12.5px]`,children:r.setup?.map(m)})]})]}),(0,d.jsxs)(`footer`,{className:`mt-3 flex flex-wrap items-center gap-3 border-t border-[#EEF0F3] px-5 py-2.5 text-[12.5px]`,children:[r.site&&(0,d.jsx)(`a`,{href:r.site,target:`_blank`,rel:`noreferrer`,className:`text-acc hover:underline`,children:r.status===`off`||r.status===`unverified`?`Sign up ↗`:`Vendor ↗`}),r.docs&&(0,d.jsx)(`a`,{href:r.docs,target:`_blank`,rel:`noreferrer`,className:`text-acc hover:underline`,children:`Docs ↗`}),(0,d.jsx)(l,{to:`/repos`,className:`ml-auto text-mut hover:underline`,children:r.enabled?`Choose where it runs →`:`Turn it on →`})]})]},r.login)},i[6]=o,i[7]=r),_=a.map(r),i[3]=a,i[4]=o,i[5]=_}else _=i[5];let v;i[8]===_?v=i[9]:(v=(0,d.jsx)(`div`,{className:`mt-4 grid grid-cols-2 gap-4 max-[940px]:grid-cols-1`,children:_}),i[8]=_,i[9]=v);let y;i[10]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,d.jsx)(`p`,{className:`mt-4 max-w-[760px] text-[12px] text-faint`,children:`Links go straight to each vendor. There are no referral links here: if any are added they will say so on the link itself, and suggestions will stay based on what fits your setup rather than on what pays.`}),i[10]=y):y=i[10];let b;return i[11]===v?b=i[12]:(b=(0,d.jsxs)(`main`,{className:`mx-auto max-w-[1120px] px-6 pt-5 pb-16`,children:[p,g,v,y]}),i[11]=v,i[12]=b),b}function m(e){return(0,d.jsx)(`li`,{className:`py-0.5`,children:e},e)}function h(){let e=(0,u.c)(3),{snapshot:t}=i();if(!t){let t;return e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,d.jsx)(o,{}),e[0]=t):t=e[0],t}let n;return e[1]===t.bots?n=e[2]:(n=(0,d.jsx)(p,{bots:t.bots}),e[1]=t.bots,e[2]=n),n}export{h as BotsRoute}; \ No newline at end of file diff --git a/internal/serve/dist/assets/OverviewRoute-DRxoPlyS.js b/internal/serve/dist/assets/OverviewRoute-DRxoPlyS.js new file mode 100644 index 00000000..8beea80c --- /dev/null +++ b/internal/serve/dist/assets/OverviewRoute-DRxoPlyS.js @@ -0,0 +1 @@ +import{a as e,c as t,d as n,f as r,i,l as a,n as o,o as s,r as c,s as l,t as u,u as d}from"./ui-Bim2uazI.js";import{a as f,i as p,n as m,o as h,r as g,s as _,t as v}from"./useOperation-BqNdl-Dh.js";import{S as y,_ as b,a as x,b as S,c as C,g as w,i as T,n as E,o as D,p as ee,r as O,s as k,v as A,x as j}from"./index-DlC1aBiV.js";var M=b(`circle-x`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m15 9-6 6`,key:`1uzhvr`}],[`path`,{d:`m9 9 6 6`,key:`z0biqf`}]]),N=b(`pause`,[[`rect`,{x:`14`,y:`3`,width:`5`,height:`18`,rx:`1`,key:`kaeet6`}],[`rect`,{x:`5`,y:`3`,width:`5`,height:`18`,rx:`1`,key:`1wsw3u`}]]),P=b(`settings-2`,[[`path`,{d:`M14 17H5`,key:`gfn3mx`}],[`path`,{d:`M19 7h-9`,key:`6i9tg`}],[`circle`,{cx:`17`,cy:`17`,r:`3`,key:`18b49y`}],[`circle`,{cx:`7`,cy:`7`,r:`3`,key:`dfmy0x`}]]),F=w(),I=S();function L(e){let t=e.repos.filter(e=>e.reviewed).length,n=e.settings.config,r=(n.scope?.length??0)>0&&(n.allow_repos?.length??0)===0,i=e.overview.counts.in_flight+e.overview.counts.queued+e.overview.counts.held+e.overview.counts.fixing>0;return!r&&t===0&&!i&&e.overview.finished.length===0}function R(e){let t=(0,F.c)(42),{snap:n}=e,r=n.setup,i;t[0]===n.bots?i=t[1]:(i=n.bots.filter(W),t[0]=n.bots,t[1]=i);let a=i,o;t[2]===n.bots?o=t[3]:(o=n.bots.filter(U),t[2]=n.bots,t[3]=o);let s=o.length,c;t[4]===r.fleet?c=t[5]:(c=r.fleet??[],t[4]=r.fleet,t[5]=c);let l;t[6]===c?l=t[7]:(l=c.some(H),t[6]=c,t[7]=l);let u=l,d=r.fleet?.length??0,f;t[8]===r.checks?f=t[9]:(f=r.checks.some(V),t[8]=r.checks,t[9]=f);let p;t[10]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,I.jsxs)(I.Fragment,{children:[`crq keeps its whole state in a git ref on one repository, so every host reads the same answer and nothing needs a database. `,(0,I.jsx)(`code`,{children:`crq init`}),` sets it up.`]}),t[10]=p):p=t[10];let m;t[11]===f?m=t[12]:(m={title:`A place for the queue to live`,done:f,body:p},t[11]=f,t[12]=m);let h=n.overview.leader!=null&&!n.overview.leader.expired,g=d>0?`${d} host(s) are reporting in.`:`No host is reporting yet.`,_;t[13]===g?_=t[14]:(_=(0,I.jsxs)(I.Fragment,{children:[`One host holds a lease and does the firing, so two machines never ask for the same review.`,` `,g]}),t[13]=g,t[14]=_);let v;t[15]!==_||t[16]!==h?(v={title:`A daemon to drive it`,done:h,body:_},t[15]=_,t[16]=h,t[17]=v):v=t[17];let y=s>0,b;t[18]===Symbol.for(`react.memo_cache_sentinel`)?(b=(0,I.jsx)(A,{to:`/bots`,className:`text-acc hover:underline`,children:`Compare and set them up →`}),t[18]=b):b=t[18];let x;t[19]!==a.length||t[20]!==s?(x=(0,I.jsxs)(I.Fragment,{children:[a.length,` reviewer(s) enabled, `,s,` of which crq has seen working here. Enabled and working are different things — a bot nobody signed up for looks configured and reviews nothing.`,` `,b]}),t[19]=a.length,t[20]=s,t[21]=x):x=t[21];let S;t[22]!==y||t[23]!==x?(S={title:`Reviewers that are actually set up`,done:y,body:x},t[22]=y,t[23]=x,t[24]=S):S=t[24];let C;t[25]===Symbol.for(`react.memo_cache_sentinel`)?(C=(0,I.jsxs)(I.Fragment,{children:[`Optional, and the half most people skip: crq can run a coding agent over the findings and push the fixes. `,(0,I.jsx)(`code`,{children:`crq autofix install`}),` sets it up on a host.`]}),t[25]=C):C=t[25];let w;t[26]===u?w=t[27]:(w={title:`An agent to fix what they find`,done:u,body:C},t[26]=u,t[27]=w);let T;t[28]===Symbol.for(`react.memo_cache_sentinel`)?(T={title:`A repository to review`,done:!1,body:(0,I.jsxs)(I.Fragment,{children:[`Nothing is enrolled yet, which is why this page is here instead of the queue.`,` `,(0,I.jsx)(A,{to:`/repos`,className:`font-semibold text-acc hover:underline`,children:`Add a repository →`})]})},t[28]=T):T=t[28];let E;t[29]!==v||t[30]!==S||t[31]!==w||t[32]!==m?(E=[m,v,S,w,T],t[29]=v,t[30]=S,t[31]=w,t[32]=m,t[33]=E):E=t[33];let D=E,ee,O;t[34]===Symbol.for(`react.memo_cache_sentinel`)?(ee=(0,I.jsx)(`h1`,{className:`text-[26px] font-[680] tracking-tight`,children:`Nothing is enrolled yet`}),O=(0,I.jsx)(`p`,{className:`mt-2 text-[14.5px] text-mut`,children:`crq keeps automated reviewers off each other's toes. It runs three kinds of work, and knowing which is which explains most of what the rest of this dashboard says.`}),t[34]=ee,t[35]=O):(ee=t[34],O=t[35]);let k;t[36]===Symbol.for(`react.memo_cache_sentinel`)?(k=(0,I.jsx)(`div`,{className:`mt-4 grid grid-cols-3 gap-3 max-[760px]:grid-cols-1`,children:[{k:`Metered review`,v:`The reviewer that costs an account allowance. One at a time across the whole fleet, which is what the queue is for.`},{k:`Co-review`,v:`Reviewers covered by their own subscriptions. They cost nothing per review, so they never wait for the queue.`},{k:`Autofix`,v:`A coding agent that fixes what the reviewers found and pushes. Separate from both, and off wherever you say so.`}].map(B)}),t[36]=k):k=t[36];let j;t[37]===D?j=t[38]:(j=(0,I.jsx)(`ol`,{className:`mt-6`,children:D.map(z)}),t[37]=D,t[38]=j);let M;t[39]===Symbol.for(`react.memo_cache_sentinel`)?(M=(0,I.jsxs)(`p`,{className:`mt-5 text-[12.5px] text-faint`,children:[`This page is not a mode and there is no flag to get stuck on: it is showing because nothing is enrolled and nothing has ever been queued. Enrol one repository and the queue replaces it.`,` `,(0,I.jsx)(A,{to:`/setup`,className:`text-acc hover:underline`,children:`Setup`}),` `,`has the full check list either way.`]}),t[39]=M):M=t[39];let N;return t[40]===j?N=t[41]:(N=(0,I.jsxs)(`main`,{className:`mx-auto max-w-[860px] px-6 pt-8 pb-16`,children:[ee,O,k,j,M]}),t[40]=j,t[41]=N),N}function z(e,t){return(0,I.jsxs)(`li`,{className:`flex gap-3 border-b border-[#EEF0F3] py-3 last:border-none`,children:[(0,I.jsx)(`span`,{className:`mt-0.5 flex size-[22px] shrink-0 items-center justify-center rounded-full text-[12px] font-semibold ${e.done?`bg-ok text-white`:`border border-edge text-mut`}`,children:e.done?`✓`:t+1}),(0,I.jsxs)(`span`,{children:[(0,I.jsx)(`div`,{className:`text-[14px] font-[650]`,children:e.title}),(0,I.jsx)(`div`,{className:`mt-0.5 text-[13px] text-mut`,children:e.body})]})]},e.title)}function B(e){return(0,I.jsxs)(`div`,{className:`rounded-[10px] border border-edge bg-card px-4 py-3 shadow-card`,children:[(0,I.jsx)(`div`,{className:`text-[13px] font-[650]`,children:e.k}),(0,I.jsx)(`div`,{className:`mt-1 text-[12.5px] text-mut`,children:e.v})]},e.k)}function V(e){return e.key===`state`&&e.status===`ok`}function H(e){return e.agent}function U(e){return e.status===`working`}function W(e){return e.enabled}var G=y(j(),1);function te(e){let n=(0,F.c)(142),{repo:i,bots:a,onClose:o,onSnapshot:s}=e,[c,l]=(0,G.useState)(i.reviewers),[d,y]=(0,G.useState)(i.required),{run:b,running:x,error:S}=v(),[C,w]=(0,G.useState)(null),[T,E]=(0,G.useState)(null),D;n[0]===i.reviewers?D=n[1]:(D=[...i.reviewers].sort().join(`\0`),n[0]=i.reviewers,n[1]=D);let O=D,k;n[2]===i.required?k=n[3]:(k=[...i.required].sort().join(`\0`),n[2]=i.required,n[3]=k);let j=k,M,N;n[4]!==j||n[5]!==O?(M=()=>{l(O?O.split(`\0`):[]),y(j?j.split(`\0`):[]),E(null)},N=[O,j],n[4]=j,n[5]=O,n[6]=M,n[7]=N):(M=n[6],N=n[7]),(0,G.useEffect)(M,N);let P;n[8]===a?P=n[9]:(P=a.find(ae),n[8]=a,n[9]=P);let L=P,R;n[10]!==i.required||n[11]!==i.reviewers||n[12]!==d||n[13]!==c?(R=!_(c,i.reviewers)||!_(d,i.required),n[10]=i.required,n[11]=i.reviewers,n[12]=d,n[13]=c,n[14]=R):R=n[14];let z=R,B,V,H,U,W,te,oe,se,ce,K,le,ue,de;if(n[15]!==a||n[16]!==z||n[17]!==o||n[18]!==s||n[19]!==L||n[20]!==i.override||n[21]!==i.override_by||n[22]!==i.repo||n[23]!==i.reviewers||n[24]!==d||n[25]!==b||n[26]!==c){let e;n[40]===i.reviewers?e=n[41]:(e=e=>!i.reviewers.includes(e),n[40]=i.reviewers,n[41]=e);let m=c.filter(e),_;n[42]===a?_=n[43]:(_=new Set(a.filter(ie).map(re)),n[42]=a,n[43]=_);let v=_,x;if(n[44]!==v||n[45]!==L?.name||n[46]!==d||n[47]!==c){let e;n[49]!==v||n[50]!==L?.name||n[51]!==d?(e=e=>e!==L?.name&&!v.has(e)&&!d.includes(e),n[49]=v,n[50]=L?.name,n[51]=d,n[52]=e):e=n[52],x=new Set(c.filter(e)),n[44]=v,n[45]=L?.name,n[46]=d,n[47]=c,n[48]=x}else x=n[48];let S;n[53]===x?S=n[54]:(S=[...x],n[53]=x,n[54]=S);let C=S,T;n[55]!==v||n[56]!==L||n[57]!==i.repo||n[58]!==d||n[59]!==c?(T=()=>({repo:i.repo,cobots:c.filter(e=>e!==L?.name&&v.has(e)),required:d,primary:L?c.includes(L.name):void 0}),n[55]=v,n[56]=L,n[57]=i.repo,n[58]=d,n[59]=c,n[60]=T):T=n[60];let D=T,O;n[61]!==o||n[62]!==s||n[63]!==D||n[64]!==b?(O=e=>{w(null),b(ee(`reviewers`,{...D(),expected_rev:e}),{onSuccess:e=>{let{snapshot:t,warning:n}=e;if(s?.(t),n){w(n);return}o()}})},n[61]=o,n[62]=s,n[63]=D,n[64]=b,n[65]=O):O=n[65],U=O;let k;n[66]!==D||n[67]!==b?(k=()=>b(ee(`reviewers`,{...D(),preview:!0}),{onSuccess:e=>E(e)}),n[66]=D,n[67]=b,n[68]=k):k=n[68],H=k,V=g,ce=!0,n[69]===o?K=n[70]:(K=e=>!e&&o(),n[69]=o,n[70]=K),B=p,te=`sheet`,oe=`Close reviewers for ${i.repo}`;let A;n[71]===i.repo?A=n[72]:(A=(0,I.jsx)(h,{className:`min-w-0 truncate`,children:i.repo}),n[71]=i.repo,n[72]=A);let j=i.override?`warn`:`mut`,M=i.override?`override`:`fleet default`,N;n[73]!==j||n[74]!==M?(N=(0,I.jsx)(t,{tone:j,children:M}),n[73]=j,n[74]=M,n[75]=N):N=n[75];let P;n[76]===Symbol.for(`react.memo_cache_sentinel`)?(P=(0,I.jsx)(f,{className:`sr-only`,children:`Choose which review bots run and gate convergence for this repository.`}),n[76]=P):P=n[76],n[77]!==A||n[78]!==N?(se=(0,I.jsxs)(`div`,{className:`flex items-center gap-2 border-b border-edge px-5 py-3.5`,children:[A,N,P]}),n[77]=A,n[78]=N,n[79]=se):se=n[79],le=`min-h-0 flex-1 overflow-auto px-5 py-3`;let F;n[80]===Symbol.for(`react.memo_cache_sentinel`)?(F=(0,I.jsx)(`b`,{children:`Runs`}),n[80]=F):F=n[80];let R;n[81]===Symbol.for(`react.memo_cache_sentinel`)?(R=(0,I.jsx)(`b`,{children:`Required`}),n[81]=R):R=n[81];let G=i.override_by&&` Set by ${i.override_by}.`;n[82]===G?ue=n[83]:(ue=(0,I.jsxs)(`p`,{className:`text-[12.5px] text-faint`,children:[F,` — the bot reviews this repo. `,R,` — convergence waits for it.`,G]}),n[82]=G,n[83]=ue);let ne;if(n[84]!==a||n[85]!==d||n[86]!==c){let e;n[88]!==d||n[89]!==c?(e=e=>(0,I.jsxs)(`tr`,{className:`border-b border-[#EEF0F3] last:border-none`,children:[(0,I.jsx)(`td`,{className:`py-2 pr-2`,children:(0,I.jsxs)(`span`,{className:`flex items-center gap-2 text-[13px]`,children:[(0,I.jsx)(u,{login:e.login,name:e.name,size:18}),(0,I.jsx)(`span`,{className:`font-[550]`,children:e.name})]})}),(0,I.jsxs)(`td`,{className:`py-2 text-center`,children:[(0,I.jsx)(r,{on:c.includes(e.name),label:`${e.name} runs`,locked:!e.primary&&!e.configurable,title:e.primary?`the metered reviewer — off here spends no quota on this repo`:e.configurable?void 0:`this retired reviewer is shown for history and cannot be configured`,onClick:()=>l(t=>{let n=t.includes(e.name);return n&&y(t=>t.filter(t=>t!==e.name)),n?t.filter(t=>t!==e.name):[...t,e.name]})}),(0,I.jsx)(`div`,{className:`text-[11px] text-faint`,children:`runs`})]}),(0,I.jsxs)(`td`,{className:`py-2 text-center`,children:[(0,I.jsx)(r,{on:d.includes(e.name),label:`${e.name} required`,locked:!e.primary&&!e.configurable,title:!e.primary&&!e.configurable?`this retired reviewer is shown for history and cannot be configured`:void 0,onClick:()=>y(t=>{let n=t.includes(e.name);return n||l(t=>t.includes(e.name)?t:[...t,e.name]),n?t.filter(t=>t!==e.name):[...t,e.name]})}),(0,I.jsx)(`div`,{className:`text-[11px] text-faint`,children:`required`})]})]},e.login),n[88]=d,n[89]=c,n[90]=e):e=n[90],ne=a.map(e),n[84]=a,n[85]=d,n[86]=c,n[87]=ne}else ne=n[87];n[91]===ne?de=n[92]:(de=(0,I.jsx)(`table`,{className:`mt-2 w-full border-collapse`,children:(0,I.jsx)(`tbody`,{children:ne})}),n[91]=ne,n[92]=de),W=z&&(0,I.jsxs)(`p`,{className:`mt-3 rounded-lg border border-warn-edge bg-warn-bg px-2.5 py-1.5 text-[12.5px] text-warn`,children:[`Saving would`,m.length>0?` enable ${m.join(`, `)} here`:` change who gates`,C.length>0?` and remove retired ${C.join(`, `)} from the saved reviewer sets`:``,`. The consequence preview checks current open heads before anything is saved.`]}),n[15]=a,n[16]=z,n[17]=o,n[18]=s,n[19]=L,n[20]=i.override,n[21]=i.override_by,n[22]=i.repo,n[23]=i.reviewers,n[24]=d,n[25]=b,n[26]=c,n[27]=B,n[28]=V,n[29]=H,n[30]=U,n[31]=W,n[32]=te,n[33]=oe,n[34]=se,n[35]=ce,n[36]=K,n[37]=le,n[38]=ue,n[39]=de}else B=n[27],V=n[28],H=n[29],U=n[30],W=n[31],te=n[32],oe=n[33],se=n[34],ce=n[35],K=n[36],le=n[37],ue=n[38],de=n[39];let fe;n[93]===S?fe=n[94]:(fe=S&&(0,I.jsx)(`div`,{className:`mt-3 rounded-lg border border-bad-edge bg-bad-bg px-3 py-2 text-[12.5px] text-bad`,children:S}),n[93]=S,n[94]=fe);let pe;n[95]===C?pe=n[96]:(pe=C&&(0,I.jsx)(`div`,{role:`status`,className:`mt-3 rounded-lg border border-warn-edge bg-warn-bg px-3 py-2 text-[12.5px] text-warn`,children:C}),n[95]=C,n[96]=pe);let me;n[97]===Symbol.for(`react.memo_cache_sentinel`)?(me=(0,I.jsx)(A,{to:`/repos`,className:`mt-3 inline-block text-[12.5px] text-acc hover:underline`,children:`Everything else for this repository →`}),n[97]=me):me=n[97];let he;n[98]!==W||n[99]!==fe||n[100]!==pe||n[101]!==le||n[102]!==ue||n[103]!==de?(he=(0,I.jsxs)(`div`,{className:le,children:[ue,de,W,fe,pe,me]}),n[98]=W,n[99]=fe,n[100]=pe,n[101]=le,n[102]=ue,n[103]=de,n[104]=he):he=n[104];let ge=!z||x,q=x?`Saving…`:`Save`,J;n[105]!==H||n[106]!==ge||n[107]!==q?(J=(0,I.jsx)(`button`,{type:`button`,disabled:ge,onClick:H,className:`rounded-lg bg-ink px-4 py-1.5 text-[13px] font-semibold text-white disabled:opacity-45`,children:q}),n[105]=H,n[106]=ge,n[107]=q,n[108]=J):J=n[108];let Y=!z||x,X;n[109]!==i.required||n[110]!==i.reviewers?(X=()=>{l(i.reviewers),y(i.required),w(null),E(null)},n[109]=i.required,n[110]=i.reviewers,n[111]=X):X=n[111];let Z;n[112]!==Y||n[113]!==X?(Z=(0,I.jsx)(`button`,{type:`button`,disabled:Y,onClick:X,className:`rounded-lg border border-edge px-4 py-1.5 text-[13px] font-semibold text-mut disabled:opacity-45`,children:`Discard`}),n[112]=Y,n[113]=X,n[114]=Z):Z=n[114];let _e;n[115]===z?_e=n[116]:(_e=z&&(0,I.jsx)(`span`,{className:`text-[12.5px] text-warn`,children:`1 change pending`}),n[115]=z,n[116]=_e);let ve;n[117]!==J||n[118]!==Z||n[119]!==_e?(ve=(0,I.jsxs)(`div`,{className:`flex items-center gap-2.5 border-t border-edge px-5 py-3`,children:[J,Z,_e]}),n[117]=J,n[118]=Z,n[119]=_e,n[120]=ve):ve=n[120];let ye;n[121]!==B||n[122]!==te||n[123]!==oe||n[124]!==se||n[125]!==he||n[126]!==ve?(ye=(0,I.jsxs)(B,{variant:te,closeLabel:oe,children:[se,he,ve]}),n[121]=B,n[122]=te,n[123]=oe,n[124]=se,n[125]=he,n[126]=ve,n[127]=ye):ye=n[127];let Q;n[128]!==V||n[129]!==ce||n[130]!==K||n[131]!==ye?(Q=(0,I.jsx)(V,{open:ce,onOpenChange:K,children:ye}),n[128]=V,n[129]=ce,n[130]=K,n[131]=ye,n[132]=Q):Q=n[132];let $;n[133]!==x||n[134]!==S||n[135]!==T||n[136]!==i.repo||n[137]!==U?($=T&&(0,I.jsx)(m,{title:`Save reviewers for ${i.repo.split(`/`).pop()}?`,danger:T.reopened>0,confirmLabel:T.reopened>0?`Save and reopen ${T.reopened}`:`Save`,busy:x,error:S,body:(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(`ul`,{className:`mb-2 list-disc pl-4`,children:T.changes.map(ne)}),T.summary,T.reopened>0&&(0,I.jsx)(`p`,{className:`mt-2 text-warn`,children:`Reopened rounds are reviewed again, and metered reviews spend the shared allowance.`})]}),onConfirm:()=>U(T.rev),onCancel:()=>E(null)}),n[133]=x,n[134]=S,n[135]=T,n[136]=i.repo,n[137]=U,n[138]=$):$=n[138];let be;return n[139]!==Q||n[140]!==$?(be=(0,I.jsxs)(I.Fragment,{children:[Q,$]}),n[139]=Q,n[140]=$,n[141]=be):be=n[141],be}function ne(e){return(0,I.jsx)(`li`,{children:e},e)}function re(e){return e.name}function ie(e){return e.configurable}function ae(e){return e.primary}var oe={"":`ok`,"account blocked":`warn`,"cooling down":`warn`,pacing:`warn`,"slot busy":`mut`,"behind an earlier round":`mut`},se={all:`All`,in_flight:`In flight`,queued:`Queued`,held:`Held`,fixing:`Fixing`,attention:`Needs attention`};function ce(e,t,n){return n&&e!==t?`${e} of ${t}`:t}function K(r){let u=(0,F.c)(105),{ov:f,events:p,repos:h,bots:g,onSnapshot:_}=r,y=k(),[b,S]=(0,G.useState)(``),[C,w]=(0,G.useState)(`all`),[E,A]=(0,G.useState)(null),[j,M]=(0,G.useState)(null),{run:N,running:P,error:L,clearError:R}=v(),[z,B]=(0,G.useState)(null),{run:V,running:H,error:U}=v(),W;u[0]!==_||u[1]!==E||u[2]!==N?(W=e=>{E&&N(ee(E.kind,{repo:E.repo,pr:E.pr,reason:e}),{onSuccess:e=>{let{snapshot:t}=e;_?.(t),A(null)}})},u[0]=_,u[1]=E,u[2]=N,u[3]=W):W=u[3];let ne=W,re;u[4]!==_||u[5]!==H||u[6]!==V?(re=(e,t)=>{H||(B(`${e.toLowerCase()}#${t}`),V(ee(`prioritize`,{repo:e,pr:t}),{onSuccess:e=>{let{snapshot:t}=e;return _?.(t)},onFinally:()=>B(null)}))},u[4]=_,u[5]=H,u[6]=V,u[7]=re):re=u[7];let ie=re,ae,K,me,q,J,Y,X,Z,_e,ve,ye,Q,$,be;if(u[8]!==C||u[9]!==y||u[10]!==f||u[11]!==ie||u[12]!==U||u[13]!==H||u[14]!==z||u[15]!==b){let r=b.trim().toLowerCase(),p=e=>r===``||`${e.repo}#${e.pr}`.toLowerCase().includes(r)||(e.title??``).toLowerCase().includes(r),m;u[30]===f.attention?m=u[31]:(m=new Set(f.attention.map(fe).filter(Boolean)),u[30]=f.attention,u[31]=m);let h=m,g=(e,t)=>e.filter(e=>p(e)&&(C===`all`||C===t||C===`attention`&&h.has((e.key??``).toLowerCase()))),_=g(f.in_flight,`in_flight`),v=g(f.queue,`queued`),E=g(f.held,`held`),ee=g(f.autofix.sessions,`fixing`),k=g(f.finished,`finished`),j=r!==``||C!==`all`;me=`mx-auto max-w-[1400px] px-6 pt-4.5 pb-16`,u[32]!==y||u[33]!==f?(q=(0,I.jsx)(ge,{ov:f,now:y}),u[32]=y,u[33]=f,u[34]=q):q=u[34],u[35]===f.attention?J=u[36]:(J=f.attention.map(de),u[35]=f.attention,u[36]=J),u[37]===U?Y=u[38]:(Y=U&&(0,I.jsxs)(`div`,{role:`alert`,className:`mb-3.5 rounded-[10px] border border-bad-edge bg-bad-bg px-4 py-2.5 text-[13px] text-bad`,children:[`Could not prioritize the pull request: `,U]}),u[37]=U,u[38]=Y);let N;u[39]===Symbol.for(`react.memo_cache_sentinel`)?(N=e=>S(e.target.value),u[39]=N):N=u[39];let P;u[40]===b?P=u[41]:(P=(0,I.jsx)(`input`,{"aria-label":`Filter pull requests`,value:b,onChange:N,placeholder:`Search repo, PR number or title…`,className:`w-[300px] rounded-lg border border-edge bg-card px-2.5 py-1.5 text-[13px]`}),u[40]=b,u[41]=P);let F;u[42]===Symbol.for(`react.memo_cache_sentinel`)?(F=[`all`,`in_flight`,`queued`,`held`,`fixing`,`attention`],u[42]=F):F=u[42];let L;u[43]===C?L=u[44]:(L=F.map(e=>(0,I.jsx)(`button`,{type:`button`,onClick:()=>w(e),className:`rounded-full border px-3 py-1 text-[12.5px] font-medium ${C===e?`border-ink bg-ink text-white`:`border-edge text-mut hover:bg-[#F7F8FA]`}`,children:se[e]},e)),u[43]=C,u[44]=L);let R;u[45]===j?R=u[46]:(R=j&&(0,I.jsx)(`button`,{type:`button`,onClick:()=>{S(``),w(`all`)},className:`text-[12.5px] text-acc hover:underline`,children:`Clear`}),u[45]=j,u[46]=R),u[47]!==P||u[48]!==L||u[49]!==R?(X=(0,I.jsxs)(`div`,{className:`mb-3 flex flex-wrap items-center gap-2`,children:[P,L,R]}),u[47]=P,u[48]=L,u[49]=R,u[50]=X):X=u[50],K=`grid grid-cols-[minmax(0,1fr)_360px] items-start gap-4 max-[1400px]:grid-cols-[minmax(0,1fr)]`,ye=(0,I.jsx)(c,{title:`In flight`,count:ce(_.length,f.counts.in_flight,j),children:_.length===0?(0,I.jsx)(e,{children:`Nothing is being reviewed right now.`}):(0,I.jsxs)(`table`,{className:`mt-1.5 w-full border-collapse`,children:[(0,I.jsx)(`thead`,{children:(0,I.jsxs)(`tr`,{children:[(0,I.jsx)(n,{children:`Pull request`}),(0,I.jsx)(n,{children:`Head`}),(0,I.jsx)(n,{children:`Phase`}),(0,I.jsx)(n,{children:`Fired`}),(0,I.jsx)(n,{children:`Deadline`}),(0,I.jsx)(n,{children:`Reviewers`}),(0,I.jsx)(n,{className:`c-host`,children:`Host`}),(0,I.jsx)(n,{className:`c-actions`})]})}),(0,I.jsx)(`tbody`,{children:_.map(e=>(0,I.jsxs)(`tr`,{className:`group hover:bg-[#F7F8FA]`,children:[(0,I.jsxs)(d,{children:[(0,I.jsxs)(`div`,{className:`flex items-center gap-2 font-[550]`,children:[(0,I.jsx)(a,{repo:e.repo}),(0,I.jsx)(l,{repo:e.repo,pr:e.pr,title:e.title}),e.fixing&&(0,I.jsx)(t,{tone:`ok`,children:`fixing`})]}),e.next&&(0,I.jsx)(`div`,{className:`mt-1 ml-6 text-[12.5px] text-mut`,children:e.next})]}),(0,I.jsx)(d,{className:`text-[13px]`,children:(0,I.jsx)(i,{repo:e.repo,sha:e.head})}),(0,I.jsx)(d,{children:(0,I.jsx)(t,{tone:e.phase===`reviewing`?`ok`:`acc`,children:e.phase})}),(0,I.jsxs)(d,{className:`tabular-nums`,children:[T(e.fired_at),(0,I.jsx)(`div`,{className:`text-[11.5px] text-faint`,children:O(e.fired_at,y)})]}),(0,I.jsxs)(d,{className:`tabular-nums`,children:[(0,I.jsx)(`b`,{children:x(e.deadline,y)}),(0,I.jsx)(`div`,{className:`text-[11.5px] text-faint`,children:T(e.deadline)})]}),(0,I.jsx)(d,{children:(0,I.jsx)(o,{bots:e.bots})}),(0,I.jsx)(d,{className:`c-host font-mono text-[13px] text-mut`,children:e.host??`—`}),(0,I.jsx)(d,{className:`c-actions`,children:(0,I.jsx)(he,{onSettings:()=>M(e.repo),onHold:void 0,onCancel:()=>A({kind:`cancel`,repo:e.repo,pr:e.pr,phase:e.phase,slot:f.slot.key===e.key})})})]},e.key))})]})});let B=`${ce(v.length,f.counts.queued,j)} waiting`,V=v.length===0?(0,I.jsx)(e,{children:`The queue is empty.`}):(0,I.jsxs)(`table`,{className:`mt-1.5 w-full border-collapse`,children:[(0,I.jsx)(`thead`,{children:(0,I.jsxs)(`tr`,{children:[(0,I.jsx)(n,{className:`w-6`,children:`#`}),(0,I.jsx)(n,{children:`Pull request`}),(0,I.jsx)(n,{children:`Head`}),(0,I.jsx)(n,{children:`Ready`}),(0,I.jsx)(n,{children:`Why`}),(0,I.jsx)(n,{className:`c-att text-right`,children:`Att.`}),(0,I.jsx)(n,{className:`c-host`,children:`Host`}),(0,I.jsx)(n,{className:`c-actions`})]})}),(0,I.jsx)(`tbody`,{children:v.map(e=>(0,I.jsxs)(`tr`,{className:`group hover:bg-[#F7F8FA]`,children:[(0,I.jsx)(d,{className:`tabular-nums`,children:e.position?e.position:(0,I.jsx)(`span`,{className:`text-faint`,children:`—`})}),(0,I.jsxs)(d,{children:[(0,I.jsxs)(`div`,{className:`flex items-center gap-2 font-[550]`,children:[(0,I.jsx)(a,{repo:e.repo}),(0,I.jsx)(l,{repo:e.repo,pr:e.pr,title:e.title})]}),e.next&&(0,I.jsx)(`div`,{className:`mt-1 ml-6 text-[12.5px] text-mut`,children:e.next})]}),(0,I.jsx)(d,{className:`text-[13px]`,children:(0,I.jsx)(i,{repo:e.repo,sha:e.head})}),(0,I.jsx)(d,{className:`tabular-nums`,children:e.ready_at?(0,I.jsx)(`b`,{children:x(e.ready_at,y)}):e.why?(0,I.jsx)(`span`,{className:`text-faint`,children:`—`}):(0,I.jsx)(`b`,{children:`now`})}),(0,I.jsx)(d,{children:(0,I.jsxs)(t,{tone:oe[e.why??``]??`mut`,children:[e.why||`next up`,e.co_only?` · co-only`:``]})}),(0,I.jsx)(d,{className:`c-att text-right tabular-nums`,children:e.attempts??0}),(0,I.jsx)(d,{className:`c-host font-mono text-[13px] text-mut`,children:e.host??`—`}),(0,I.jsx)(d,{className:`c-actions`,children:(0,I.jsx)(he,{onPrioritize:()=>ie(e.repo,e.pr),prioritizing:H&&z===`${e.repo.toLowerCase()}#${e.pr}`,prioritizeDisabled:H,onSettings:()=>M(e.repo),onHold:void 0,onCancel:()=>A({kind:`cancel`,repo:e.repo,pr:e.pr,phase:`queued`,slot:!1})})})]},e.key))})]});u[51]!==B||u[52]!==V?(Q=(0,I.jsx)(c,{title:`Queue`,count:B,end:`only the front row carries a time — later starts are unknowable`,children:V}),u[51]=B,u[52]=V,u[53]=Q):Q=u[53],$=f.held.length>0&&(0,I.jsx)(c,{title:`Held`,count:ce(E.length,f.counts.held,j),children:(0,I.jsxs)(`table`,{className:`mt-1.5 w-full border-collapse`,children:[(0,I.jsx)(`thead`,{children:(0,I.jsxs)(`tr`,{children:[(0,I.jsx)(n,{children:`Pull request`}),(0,I.jsx)(n,{children:`Head`}),(0,I.jsx)(n,{className:`c-host`,children:`Held by`}),(0,I.jsx)(n,{children:`Since`}),(0,I.jsx)(n,{children:`Reason`}),(0,I.jsx)(n,{className:`c-actions`})]})}),(0,I.jsx)(`tbody`,{children:E.map(e=>(0,I.jsxs)(`tr`,{className:`hover:bg-[#F7F8FA]`,children:[(0,I.jsx)(d,{children:(0,I.jsxs)(`div`,{className:`flex items-center gap-2 font-[550]`,children:[(0,I.jsx)(a,{repo:e.repo}),(0,I.jsx)(l,{repo:e.repo,pr:e.pr,title:e.title})]})}),(0,I.jsx)(d,{className:`text-[13px]`,children:(0,I.jsx)(i,{repo:e.repo,sha:e.head})}),(0,I.jsx)(d,{className:`c-host font-mono text-[13px] text-mut`,children:e.by||`—`}),(0,I.jsx)(d,{className:`tabular-nums`,children:T(e.at)}),(0,I.jsxs)(d,{children:[(0,I.jsx)(t,{tone:`bad`,children:`Held`}),e.reason&&(0,I.jsxs)(`span`,{className:`ml-2 text-faint`,children:[`“`,e.reason,`”`]})]}),(0,I.jsx)(d,{className:`c-actions`,children:(0,I.jsx)(`button`,{type:`button`,onClick:()=>A({kind:`unhold`,repo:e.repo,pr:e.pr}),className:`text-[12.5px] text-acc hover:underline`,children:`Unhold`})})]},e.key))})]})});let W;u[54]===f.autofix.hosts.length?W=u[55]:(W=f.autofix.hosts.length===0&&(0,I.jsx)(`span`,{className:`text-[13px] text-faint`,children:`No host has reported yet.`}),u[54]=f.autofix.hosts.length,u[55]=W);let G;u[56]===f.autofix.hosts?G=u[57]:(G=f.autofix.hosts.map(ue),u[56]=f.autofix.hosts,u[57]=G);let te;u[58]!==W||u[59]!==G?(te=(0,I.jsxs)(`div`,{className:`flex flex-wrap gap-3 px-[18px] py-3`,children:[W,G]}),u[58]=W,u[59]=G,u[60]=te):te=u[60],be=(0,I.jsxs)(c,{title:`Autofix`,count:`${ce(ee.length,f.autofix.sessions.length,j)} running · ${f.autofix.hosts.length} hosts`,children:[ee.length===0?(0,I.jsx)(e,{children:j?`No fix session matches.`:`No fix session is running.`}):ee.map(e=>(0,I.jsxs)(`div`,{className:`flex flex-wrap items-center gap-4 border-b border-[#EEF0F3] px-[18px] py-3 text-[13px]`,children:[(0,I.jsxs)(t,{tone:`ok`,children:[`Fixing · `,D(e.since,y)]}),(0,I.jsxs)(`span`,{className:`flex items-center gap-2`,children:[(0,I.jsx)(a,{repo:e.repo}),(0,I.jsx)(s,{repo:e.repo,pr:e.pr,className:`font-[550]`}),(0,I.jsx)(i,{repo:e.repo,sha:e.head})]}),(0,I.jsxs)(`span`,{className:`text-faint`,children:[e.host,e.model?` · ${e.model}`:``,e.attempt?` · attempt ${e.attempt}`:``,e.heartbeat?` · heartbeat ${T(e.heartbeat)}`:``]})]},e.key)),te]}),ae=c,Z=`Recently finished`,_e=ce(k.length,f.finished.length,j),ve=k.length===0?(0,I.jsx)(e,{children:`Nothing has finished yet.`}):(0,I.jsxs)(`table`,{className:`mt-1.5 w-full border-collapse`,children:[(0,I.jsx)(`thead`,{children:(0,I.jsxs)(`tr`,{children:[(0,I.jsx)(n,{children:`Pull request`}),(0,I.jsx)(n,{children:`Head`}),(0,I.jsx)(n,{children:`Outcome`}),(0,I.jsx)(n,{children:`When`})]})}),(0,I.jsx)(`tbody`,{children:k.map(le)})]}),u[8]=C,u[9]=y,u[10]=f,u[11]=ie,u[12]=U,u[13]=H,u[14]=z,u[15]=b,u[16]=ae,u[17]=K,u[18]=me,u[19]=q,u[20]=J,u[21]=Y,u[22]=X,u[23]=Z,u[24]=_e,u[25]=ve,u[26]=ye,u[27]=Q,u[28]=$,u[29]=be}else ae=u[16],K=u[17],me=u[18],q=u[19],J=u[20],Y=u[21],X=u[22],Z=u[23],_e=u[24],ve=u[25],ye=u[26],Q=u[27],$=u[28],be=u[29];let xe;u[61]!==ae||u[62]!==Z||u[63]!==_e||u[64]!==ve?(xe=(0,I.jsx)(ae,{title:Z,count:_e,children:ve}),u[61]=ae,u[62]=Z,u[63]=_e,u[64]=ve,u[65]=xe):xe=u[65];let Se;u[66]!==xe||u[67]!==ye||u[68]!==Q||u[69]!==$||u[70]!==be?(Se=(0,I.jsxs)(`div`,{children:[ye,Q,$,be,xe]}),u[66]=xe,u[67]=ye,u[68]=Q,u[69]=$,u[70]=be,u[71]=Se):Se=u[71];let Ce;u[72]===p?Ce=u[73]:(Ce=(0,I.jsx)(pe,{events:p}),u[72]=p,u[73]=Ce);let we;u[74]!==K||u[75]!==Se||u[76]!==Ce?(we=(0,I.jsxs)(`div`,{className:K,children:[Se,Ce]}),u[74]=K,u[75]=Se,u[76]=Ce,u[77]=we):we=u[77];let Te;u[78]!==g||u[79]!==_||u[80]!==h||u[81]!==j?(Te=j&&(()=>{let e=h.find(e=>e.repo.toLowerCase()===j.toLowerCase());return e?(0,I.jsx)(te,{repo:e,bots:g,onClose:()=>M(null),onSnapshot:_}):null})(),u[78]=g,u[79]=_,u[80]=h,u[81]=j,u[82]=Te):Te=u[82];let Ee;u[83]!==P||u[84]!==R||u[85]!==L||u[86]!==E||u[87]!==ne?(Ee=E&&(0,I.jsx)(m,{title:E.kind===`hold`?`Hold ${E.repo.split(`/`).pop()}#${E.pr}?`:E.kind===`unhold`?`Resume reviews on ${E.repo.split(`/`).pop()}#${E.pr}?`:`Cancel the round on ${E.repo.split(`/`).pop()}#${E.pr}?`,body:E.kind===`hold`?(0,I.jsx)(I.Fragment,{children:`crq will stop reviewing this pull request until the hold is lifted. Nothing already in flight is undone.`}):E.kind===`unhold`?(0,I.jsx)(I.Fragment,{children:`It rejoins the queue on the daemon's next pass.`}):(0,I.jsxs)(I.Fragment,{children:[`The round is archived and its state discarded — it is currently`,` `,(0,I.jsx)(`b`,{children:E.phase}),`. A new push re-enqueues the pull request.`,E.slot&&(0,I.jsxs)(I.Fragment,{children:[` `,(0,I.jsx)(`b`,{className:`text-bad`,children:`This round holds the fire slot`}),`, so cancelling releases it for the next PR.`]})]}),confirmLabel:E.kind===`hold`?`Hold it`:E.kind===`unhold`?`Unhold`:`Cancel the round`,danger:E.kind===`cancel`,needsReason:E.kind===`hold`,reasonLabel:`Reason for the hold`,busy:P,error:L,onConfirm:ne,onCancel:()=>{A(null),R()}}),u[83]=P,u[84]=R,u[85]=L,u[86]=E,u[87]=ne,u[88]=Ee):Ee=u[88];let De;u[89]===Symbol.for(`react.memo_cache_sentinel`)?(De=(0,I.jsx)(`span`,{children:`✓ commanded · ⏳ claim posted, not yet recorded · — not enabled`}),u[89]=De):De=u[89];let Oe=f.rev,ke;u[90]===f.wrote_at?ke=u[91]:(ke=T(f.wrote_at),u[90]=f.wrote_at,u[91]=ke);let Ae;u[92]!==f.rev||u[93]!==ke?(Ae=(0,I.jsxs)(`footer`,{className:`flex flex-wrap gap-4 px-0.5 py-2.5 text-xs text-faint`,children:[De,(0,I.jsxs)(`span`,{className:`ml-auto`,children:[`rev `,Oe,` · written `,ke]})]}),u[92]=f.rev,u[93]=ke,u[94]=Ae):Ae=u[94];let je;return u[95]!==me||u[96]!==q||u[97]!==J||u[98]!==Y||u[99]!==X||u[100]!==we||u[101]!==Te||u[102]!==Ee||u[103]!==Ae?(je=(0,I.jsxs)(`main`,{className:me,children:[q,J,Y,X,we,Te,Ee,Ae]}),u[95]=me,u[96]=q,u[97]=J,u[98]=Y,u[99]=X,u[100]=we,u[101]=Te,u[102]=Ee,u[103]=Ae,u[104]=je):je=u[104],je}function le(e){return(0,I.jsxs)(`tr`,{className:`hover:bg-[#F7F8FA]`,children:[(0,I.jsx)(d,{children:(0,I.jsxs)(`div`,{className:`flex items-center gap-2 font-[550]`,children:[(0,I.jsx)(a,{repo:e.repo}),(0,I.jsx)(l,{repo:e.repo,pr:e.pr,title:e.title})]})}),(0,I.jsx)(d,{className:`text-[13px]`,children:(0,I.jsx)(i,{repo:e.repo,sha:e.head})}),(0,I.jsxs)(d,{children:[(0,I.jsx)(t,{tone:e.outcome===`completed`?`ok`:`mut`,children:e.outcome}),e.note&&(0,I.jsx)(`span`,{className:`ml-2 text-faint`,children:e.note})]}),(0,I.jsx)(d,{className:`tabular-nums`,children:T(e.at)})]},`${e.key}-${e.head}`)}function ue(e){return(0,I.jsxs)(`div`,{className:`min-w-[170px] flex-1 rounded-lg border px-3 py-2 text-[12.5px] text-mut ${e.health===`unhealthy`?`border-bad-edge bg-bad-bg`:`border-edge`}`,children:[(0,I.jsx)(`span`,{className:`font-mono font-semibold text-ink`,children:e.name}),` `,(0,I.jsx)(t,{tone:e.health===`healthy`?`ok`:e.health===`unhealthy`?`bad`:`mut`,children:e.health===`unknown`?`No recent signal`:e.health}),(0,I.jsx)(`div`,{className:`mt-1`,children:e.health===`unhealthy`?`${e.failures} consecutive failures`:e.last_success?`last success ${T(e.last_success)}`:`nothing reported`}),e.last_error&&(0,I.jsx)(`div`,{className:`mt-1 font-mono text-[11.5px] break-words`,children:e.last_error})]},e.name)}function de(e){return(0,I.jsxs)(`div`,{className:`mb-3.5 flex flex-wrap items-center gap-3 rounded-[10px] border border-l-4 px-4 py-2.5 text-[13.5px] ${e.level===`bad`?`border-bad-edge border-l-bad bg-bad-bg`:`border-warn-edge border-l-warn-fg bg-warn-bg`}`,children:[(0,I.jsx)(t,{tone:e.level===`bad`?`bad`:`warn`,children:`Needs attention`}),(0,I.jsxs)(`span`,{className:`min-w-0`,children:[e.text,e.detail&&(0,I.jsx)(`span`,{className:`ml-2 text-mut`,children:e.detail})]}),e.link&&(0,I.jsxs)(`a`,{href:e.link,className:`ml-auto shrink-0 text-[12.5px] font-semibold text-acc hover:underline`,children:[e.link_text??`Open`,` →`]})]},`${e.kind}-${e.subject??``}-${e.text}`)}function fe(e){return(e.subject??``).toLowerCase()}function pe(n){let r=(0,F.c)(17),{events:i}=n,a=me,o,l,u,d,f;if(r[0]!==i){let n=new Map,p=i.slice(0,60).map(e=>{let t=`${e.at}-${e.kind}-${e.repo??``}-${e.pr??``}-${e.text}`,r=n.get(t)??0;return n.set(t,r+1),{event:e,key:`${t}-${r}`}});f=`sticky top-16`,o=c,l=`Activity`,u=`live — since this dashboard started`,d=i.length===0?(0,I.jsx)(e,{children:`Nothing has changed since this dashboard started.`}):(0,I.jsx)(`ol`,{className:`px-[18px] pt-1.5 pb-3`,children:p.map(e=>{let{event:n,key:r}=e;return(0,I.jsxs)(`li`,{className:`flex gap-2.5 border-b border-dashed border-[#EEF0F3] py-1.5 text-[12.5px] last:border-none`,children:[(0,I.jsx)(`span`,{className:`w-[52px] shrink-0 pt-0.5 font-mono text-[11px] text-faint`,children:T(n.at)}),(0,I.jsx)(`span`,{className:`shrink-0`,children:(0,I.jsx)(t,{tone:a(n.level),children:n.kind})}),(0,I.jsxs)(`span`,{className:`text-mut`,children:[n.repo&&n.pr?(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(s,{repo:n.repo,pr:n.pr}),` —`,` `]}):n.repo?(0,I.jsxs)(I.Fragment,{children:[n.repo.split(`/`).pop(),` — `]}):null,n.text,n.detail&&(0,I.jsxs)(`span`,{className:`text-faint`,children:[` · `,n.detail]})]})]},r)})}),r[0]=i,r[1]=o,r[2]=l,r[3]=u,r[4]=d,r[5]=f}else o=r[1],l=r[2],u=r[3],d=r[4],f=r[5];let p;r[6]===i.length?p=r[7]:(p=(0,I.jsxs)(`p`,{className:`border-t border-[#EEF0F3] px-[18px] py-2 text-[11.5px] text-faint`,children:[`Derived by comparing state revisions — not an audit log. `,i.length,` event(s) held.`]}),r[6]=i.length,r[7]=p);let m;r[8]!==o||r[9]!==l||r[10]!==u||r[11]!==d||r[12]!==p?(m=(0,I.jsxs)(o,{title:l,end:u,children:[d,p]}),r[8]=o,r[9]=l,r[10]=u,r[11]=d,r[12]=p,r[13]=m):m=r[13];let h;return r[14]!==f||r[15]!==m?(h=(0,I.jsx)(`aside`,{className:f,children:m}),r[14]=f,r[15]=m,r[16]=h):h=r[16],h}function me(e){return e===`bad`?`bad`:e===`warn`?`warn`:e===`ok`?`ok`:`acc`}function he(e){let t=(0,F.c)(16),{onPrioritize:n,onHold:r,onCancel:i,onSettings:a,prioritizing:o,prioritizeDisabled:s}=e,c;t[0]!==n||t[1]!==s||t[2]!==o?(c=n&&(0,I.jsx)(`button`,{type:`button`,disabled:s,onClick:n,title:`move to top of review and autofix queues`,className:`inline-flex h-7 items-center rounded-md px-1.5 text-[12px] text-acc hover:bg-acc-bg disabled:opacity-45`,children:o?`Moving…`:`↑ Top`}),t[0]=n,t[1]=s,t[2]=o,t[3]=c):c=t[3];let l;t[4]===a?l=t[5]:(l=a&&(0,I.jsx)(`button`,{type:`button`,onClick:a,"aria-label":`Reviewers for this repository`,className:`inline-flex size-7 items-center justify-center rounded-md text-acc hover:bg-acc-bg`,children:(0,I.jsx)(P,{"aria-hidden":!0,className:`size-3.5`})}),t[4]=a,t[5]=l);let u;t[6]===r?u=t[7]:(u=r&&(0,I.jsxs)(`button`,{type:`button`,onClick:r,className:`inline-flex h-7 items-center gap-1 rounded-md px-1.5 text-[12px] text-acc hover:bg-acc-bg`,children:[(0,I.jsx)(N,{"aria-hidden":!0,className:`size-3`}),` Hold`]}),t[6]=r,t[7]=u);let d;t[8]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,I.jsx)(M,{"aria-hidden":!0,className:`size-3`}),t[8]=d):d=t[8];let f;t[9]===i?f=t[10]:(f=(0,I.jsxs)(`button`,{type:`button`,onClick:i,className:`inline-flex h-7 items-center gap-1 rounded-md px-1.5 text-[12px] text-acc hover:bg-acc-bg`,children:[d,` Cancel`]}),t[9]=i,t[10]=f);let p;return t[11]!==c||t[12]!==l||t[13]!==u||t[14]!==f?(p=(0,I.jsxs)(`span`,{className:`flex justify-end gap-1 whitespace-nowrap opacity-70 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100`,children:[c,l,u,f]}),t[11]=c,t[12]=l,t[13]=u,t[14]=f,t[15]=p):p=t[15],p}function ge(e){let n=(0,F.c)(36),{ov:r,now:i}=e,a=r.headline.kind===`blocked`,o=`mb-2.5 grid grid-cols-[1fr_auto_auto_auto] items-center gap-4 rounded-[10px] border px-5 py-3.5 shadow-card max-[1150px]:flex max-[1150px]:flex-wrap ${a?`border-warn-edge bg-gradient-to-br from-[#FDF7EA] to-warn-bg`:`border-edge bg-card`}`,s=`text-[17px] font-[650] ${a?`text-warn`:`text-ink`}`,c;n[0]!==r.headline.text||n[1]!==s?(c=(0,I.jsx)(`span`,{className:s,children:r.headline.text}),n[0]=r.headline.text,n[1]=s,n[2]=c):c=n[2];let l;n[3]!==a||n[4]!==i||n[5]!==r.quota.blocked_until?(l=a&&r.quota.blocked_until&&(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(`span`,{className:`text-[27px] font-[650] tracking-tight text-warn-fg tabular-nums`,children:x(r.quota.blocked_until,i)}),(0,I.jsxs)(`span`,{className:`text-faint tabular-nums`,children:[`reopens `,T(r.quota.blocked_until)]})]}),n[3]=a,n[4]=i,n[5]=r.quota.blocked_until,n[6]=l):l=n[6];let u;n[7]!==c||n[8]!==l?(u=(0,I.jsxs)(`div`,{className:`flex flex-wrap items-baseline gap-3`,children:[c,l]}),n[7]=c,n[8]=l,n[9]=u):u=n[9];let d;n[10]===r.headline.detail?d=n[11]:(d=r.headline.detail&&(0,I.jsx)(`div`,{className:`mt-0.5 text-[13px] text-mut`,children:r.headline.detail}),n[10]=r.headline.detail,n[11]=d);let f;n[12]!==r.counts.fixing||n[13]!==r.counts.held||n[14]!==r.counts.in_flight||n[15]!==r.counts.queued?(f=(0,I.jsxs)(`div`,{className:`mt-0.5 text-[13px] text-mut`,children:[r.counts.in_flight,` in flight · `,r.counts.queued,` queued · `,r.counts.held,` held ·`,` `,r.counts.fixing,` fixing`]}),n[12]=r.counts.fixing,n[13]=r.counts.held,n[14]=r.counts.in_flight,n[15]=r.counts.queued,n[16]=f):f=n[16];let p;n[17]!==u||n[18]!==d||n[19]!==f?(p=(0,I.jsxs)(`div`,{className:`max-[1150px]:basis-full`,children:[u,d,f]}),n[17]=u,n[18]=d,n[19]=f,n[20]=p):p=n[20];let m;n[21]===r.quota.remaining?m=n[22]:(m=(0,I.jsx)(J,{k:`Quota`,children:r.quota.remaining===null||r.quota.remaining===void 0?(0,I.jsx)(`span`,{className:`text-faint`,children:`unknown`}):(0,I.jsxs)(`span`,{className:`tabular-nums`,children:[r.quota.remaining,` remaining`]})}),n[21]=r.quota.remaining,n[22]=m);let h;n[23]===r.quota.fair_use?h=n[24]:(h=(0,I.jsx)(q,{fu:r.quota.fair_use}),n[23]=r.quota.fair_use,n[24]=h);let g;n[25]===r.slot.held?g=n[26]:(g=(0,I.jsx)(J,{k:`Fire slot`,children:r.slot.held?(0,I.jsx)(t,{tone:`warn`,children:`Held`}):(0,I.jsx)(t,{tone:`ok`,children:`Open`})}),n[25]=r.slot.held,n[26]=g);let _;n[27]===r.leader?_=n[28]:(_=(0,I.jsx)(J,{k:`Leader`,children:r.leader&&!r.leader.expired?(0,I.jsx)(t,{tone:`ok`,children:r.leader.host}):(0,I.jsx)(t,{tone:`warn`,children:`none`})}),n[27]=r.leader,n[28]=_);let v;return n[29]!==o||n[30]!==h||n[31]!==g||n[32]!==_||n[33]!==p||n[34]!==m?(v=(0,I.jsxs)(`div`,{className:o,children:[p,m,h,g,_]}),n[29]=o,n[30]=h,n[31]=g,n[32]=_,n[33]=p,n[34]=m,n[35]=v):v=n[35],v}function q(e){let t=(0,F.c)(8),{fu:n}=e,r=`tabular-nums ${n.level===`over`?`text-bad`:n.level===`warn`?`text-warn`:``}`,i=n.limit?` / ${n.limit}`:``,a;t[0]===n.complete?a=t[1]:(a=!n.complete&&(0,I.jsx)(`span`,{className:`text-faint`,children:`+`}),t[0]=n.complete,t[1]=a);let o;return t[2]!==n.fires||t[3]!==n.note||t[4]!==r||t[5]!==i||t[6]!==a?(o=(0,I.jsx)(J,{k:`This week`,children:(0,I.jsxs)(`span`,{className:r,title:n.note,children:[n.fires,i,a]})}),t[2]=n.fires,t[3]=n.note,t[4]=r,t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function J(e){let t=(0,F.c)(7),{k:n,children:r}=e,i;t[0]===n?i=t[1]:(i=(0,I.jsx)(`div`,{className:`text-[11px] font-medium tracking-[0.06em] text-faint uppercase`,children:n}),t[0]=n,t[1]=i);let a;t[2]===r?a=t[3]:(a=(0,I.jsx)(`div`,{className:`flex items-center gap-2 text-[14.5px] font-semibold`,children:r}),t[2]=r,t[3]=a);let o;return t[4]!==i||t[5]!==a?(o=(0,I.jsxs)(`div`,{className:`min-w-[104px] rounded-lg border border-edge bg-card px-3.5 py-2`,children:[i,a]}),t[4]=i,t[5]=a,t[6]=o):o=t[6],o}function Y(){let e=(0,F.c)(9),{snapshot:t,setSnapshot:n}=C();if(!t){let t;return e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,I.jsx)(E,{}),e[0]=t):t=e[0],t}if(L(t)){let n;return e[1]===t?n=e[2]:(n=(0,I.jsx)(R,{snap:t}),e[1]=t,e[2]=n),n}let r;return e[3]!==n||e[4]!==t.bots||e[5]!==t.events||e[6]!==t.overview||e[7]!==t.repos?(r=(0,I.jsx)(K,{ov:t.overview,events:t.events,repos:t.repos,bots:t.bots,onSnapshot:n}),e[3]=n,e[4]=t.bots,e[5]=t.events,e[6]=t.overview,e[7]=t.repos,e[8]=r):r=e[8],r}export{Y as OverviewRoute}; \ No newline at end of file diff --git a/internal/serve/dist/assets/PRRoute-B5ErkSzr.js b/internal/serve/dist/assets/PRRoute-B5ErkSzr.js new file mode 100644 index 00000000..3d4c09ca --- /dev/null +++ b/internal/serve/dist/assets/PRRoute-B5ErkSzr.js @@ -0,0 +1 @@ +import{a as e,c as t,i as n,l as r,n as i,o as a,p as o,r as s,t as c}from"./ui-Bim2uazI.js";import{n as l,t as u}from"./useOperation-BqNdl-Dh.js";import{S as d,a as f,b as p,c as m,f as h,g,i as _,o as v,p as y,r as b,s as x,t as S,v as C,x as w}from"./index-DlC1aBiV.js";var T=g(),E=d(w(),1),D=p();function O({repo:e,pr:t}){let[n,r]=(0,E.useState)(!1),[i,a]=(0,E.useState)(null),[o,s]=(0,E.useState)(null),c=(0,E.useRef)(null),l=i?.text;return(0,E.useEffect)(()=>{if(!n)return;let r=new AbortController,i,o=async()=>{try{let n=await fetch(`/api/autofix-log/${e}/${t}`,{headers:{"X-CRQ-Dashboard":`1`},signal:r.signal}),i=await n.json();if(!n.ok)throw Error(i.error||`HTTP ${n.status}`);a(i),s(null)}catch(e){r.signal.aborted||s(e.message)}finally{r.signal.aborted||(i=window.setTimeout(()=>void o(),1e3))}};return o(),()=>{r.abort(),window.clearTimeout(i)}},[n,e,t]),(0,E.useEffect)(()=>{n&&l!==void 0&&c.current&&(c.current.scrollTop=c.current.scrollHeight)},[n,l]),(0,D.jsxs)(`div`,{className:`w-full`,children:[(0,D.jsx)(`button`,{type:`button`,"aria-expanded":n,onClick:()=>r(e=>!e),className:`text-[12px] font-semibold text-acc hover:underline`,children:n?`Hide live log`:`Watch live log`}),n&&(0,D.jsxs)(`div`,{className:`mt-2 overflow-hidden rounded-lg border border-[#263447] bg-[#111827] text-[#D8E2F0] shadow-inner`,children:[(0,D.jsxs)(`div`,{className:`flex items-center gap-2 border-b border-white/10 px-3 py-1.5 text-[10.5px] text-[#93A4B8]`,children:[(0,D.jsx)(`span`,{className:`size-1.5 animate-pulse rounded-full bg-[#5DDB9D]`}),`live tail · refreshes every second`,i?.host&&(0,D.jsxs)(`span`,{children:[`· `,i.host]}),i?.truncated&&(0,D.jsx)(`span`,{className:`ml-auto`,children:`showing newest 128 KB`})]}),o?(0,D.jsx)(`div`,{className:`px-3 py-3 text-[11.5px] text-[#FFB4B4]`,children:o}):(0,D.jsx)(`pre`,{ref:c,className:`max-h-80 min-h-28 overflow-auto whitespace-pre-wrap break-words px-3 py-2.5 font-mono text-[11px] leading-relaxed`,children:i?.text||`Waiting for the session to write output…`})]})]})}var ee=[`critical`,`major`,`potential`,`minor`,`unknown`],te={critical:`bad`,major:`warn`,potential:`warn`,minor:`mut`,unknown:`mut`};function k(e,t){if(!e)return t;if(t.revpe(y(e,{repo:d,pr:p,reason:t===void 0?``:t}),{onSuccess:()=>{fe(null),U(!0)}}),W=e=>{if(!I)return;let t=I.finding,n=t.thread_id;if(I.kind!==`dismiss`&&!n)return;let r=n?[n]:[],i=I.kind===`resolve`?y(`resolve`,{repo:d,pr:p,thread_ids:r}):I.kind===`decline`?y(`decline`,{repo:d,pr:p,thread_ids:r,reason:e}):y(`dismiss`,{repo:d,pr:p,finding_ids:[t.id],reason:e});B(i,{onSuccess:()=>{se(null),U(!0)}})};let e;c[12]!==p||c[13]!==d||c[14]!==L?(e=e=>{let t=e!==void 0&&e;N(t),L(h(d,p,t),{onSuccess:e=>te(t=>t&&e.revN(!1)})},c[12]=p,c[13]=d,c[14]=L,c[15]=e):e=c[15],U=e,c[3]=I,c[4]=p,c[5]=d,c[6]=B,c[7]=L,c[8]=pe,c[9]=U,c[10]=W,c[11]=G}else U=c[9],W=c[10],G=c[11];let ve;c[16]===U?ve=c[17]:(ve=()=>{U()},c[16]=U,c[17]=ve);let ye;c[18]===U?ye=c[19]:(ye=[U],c[18]=U,c[19]=ye),(0,E.useEffect)(ve,ye);let be,xe;if(c[20]!==p||c[21]!==d||c[22]!==z||c[23]!==g?(be=()=>{let e=`${d}#${p}`;if(_e.current.key!==e){_e.current={key:e,snapshot:g};return}A(_e.current.snapshot,g)&&(_e.current.snapshot=g,z(h(d,p,!1,!0),{onSuccess:e=>te(t=>k(t,e))}))},xe=[p,d,z,g],c[20]=p,c[21]=d,c[22]=z,c[23]=g,c[24]=be,c[25]=xe):(be=c[24],xe=c[25]),(0,E.useEffect)(be,xe),R&&!w){let e;return c[26]!==R||c[27]!==p||c[28]!==d?(e=(0,D.jsxs)(`main`,{className:`mx-auto max-w-[1180px] px-6 py-16 text-mut`,children:[`Could not load `,d,`#`,p,`: `,R]}),c[26]=R,c[27]=p,c[28]=d,c[29]=e):e=c[29],e}if(!w){let e;return c[30]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,D.jsx)(`main`,{className:`mx-auto max-w-[1180px] px-6 py-16 text-mut`,children:`Reading state…`}),c[30]=e):e=c[30],e}let Se,K,q,Ce,we,J,Y,X,Z,Q,Te,Ee,De;if(c[31]!==ue||c[32]!==H||c[33]!==I||c[34]!==le||c[35]!==de||c[36]!==ce||c[37]!==R||c[38]!==U||c[39]!==S||c[40]!==V||c[41]!==p||c[42]!==j||c[43]!==d||c[44]!==me||c[45]!==W||c[46]!==G||c[47]!==w.hold||c[48]!==w.observe_error||c[49]!==w.observed||c[50]!==w.round||c[51]!==w.title){let i=w.observed?.findings??[],o;c[65]===w.round?.dismissed?o=c[66]:(o=w.round?.dismissed??[],c[65]=w.round?.dismissed,c[66]=o),K=o;let u;c[67]===Symbol.for(`react.memo_cache_sentinel`)?(u=new Set(ee),c[67]=u):u=c[67];let f=u,m;c[68]===Symbol.for(`react.memo_cache_sentinel`)?(m=e=>!f.has(e.severity||`unknown`),c[68]=m):m=c[68];let h=[...ee.map(e=>({sev:e,items:i.filter(t=>(t.severity||`unknown`)===e)})),{sev:`other`,items:i.filter(m)}].filter(ae);we=`mx-auto max-w-[1180px] px-6 pt-4.5 pb-16`;let g;c[69]===Symbol.for(`react.memo_cache_sentinel`)?(g=(0,D.jsx)(C,{to:`/`,className:`text-acc hover:underline`,children:`Overview`}),c[69]=g):g=c[69],c[70]!==p||c[71]!==d?(J=(0,D.jsxs)(`div`,{className:`mb-2 text-[12.5px] text-faint`,children:[g,` `,`/ `,d,` / #`,p]}),c[70]=p,c[71]=d,c[72]=J):J=c[72];let v;c[73]===d?v=c[74]:(v=(0,D.jsx)(r,{repo:d,size:24}),c[73]=d,c[74]=v);let y;c[75]!==p||c[76]!==d?(y=(0,D.jsx)(`h1`,{className:`text-[18px] font-[650] tracking-tight`,children:(0,D.jsx)(a,{repo:d,pr:p})}),c[75]=p,c[76]=d,c[77]=y):y=c[77];let x;c[78]===w.title?x=c[79]:(x=w.title&&(0,D.jsx)(`span`,{className:`max-w-[46ch] truncate text-[13.5px] text-mut`,title:w.title,children:w.title}),c[78]=w.title,c[79]=x);let T;c[80]===w.round?T=c[81]:(T=w.round?(0,D.jsx)(t,{tone:w.round.phase===`reviewing`?`ok`:`acc`,children:w.round.phase}):(0,D.jsx)(t,{tone:`mut`,children:`no active round`}),c[80]=w.round,c[81]=T);let E;c[82]===w.round?.fixing?E=c[83]:(E=w.round?.fixing&&(0,D.jsx)(t,{tone:`ok`,children:`fixing`}),c[82]=w.round?.fixing,c[83]=E);let O;c[84]===w.hold?O=c[85]:(O=w.hold&&(0,D.jsx)(t,{tone:`bad`,children:`held`}),c[84]=w.hold,c[85]=O);let te=w.observed&&(0,D.jsx)(t,{tone:w.observed.converged?`ok`:`warn`,children:w.observed.converged?`converged`:`${i.length} open`}),k;c[86]!==H||c[87]!==G||c[88]!==w.hold?(k=w.hold?(0,D.jsx)(`button`,{type:`button`,disabled:H,onClick:()=>void G(`unhold`),className:`rounded-lg border border-edge px-3 py-1.5 text-[13px] font-semibold text-mut disabled:opacity-45`,children:`Unhold`}):null,c[86]=H,c[87]=G,c[88]=w.hold,c[89]=k):k=c[89];let A;c[90]!==H||c[91]!==w.round?(A=w.round&&(0,D.jsx)(`button`,{type:`button`,disabled:H,onClick:()=>fe(`cancel`),className:`rounded-lg border border-bad-edge px-3 py-1.5 text-[13px] font-semibold text-bad disabled:opacity-45`,children:`Cancel round…`}),c[90]=H,c[91]=w.round,c[92]=A):A=c[92];let M;c[93]===U?M=c[94]:(M=()=>U(!0),c[93]=U,c[94]=M);let ne=j?`Refreshing…`:`Refresh`,N;c[95]!==j||c[96]!==M||c[97]!==ne?(N=(0,D.jsx)(`button`,{type:`button`,onClick:M,disabled:j,className:`rounded-lg border border-edge px-3 py-1.5 text-[13px] font-semibold text-mut disabled:opacity-45`,children:ne}),c[95]=j,c[96]=M,c[97]=ne,c[98]=N):N=c[98];let P;c[99]!==k||c[100]!==A||c[101]!==N?(P=(0,D.jsxs)(`span`,{className:`ml-auto flex flex-wrap items-center gap-2`,children:[k,A,N]}),c[99]=k,c[100]=A,c[101]=N,c[102]=P):P=c[102];let F;c[103]!==v||c[104]!==y||c[105]!==x||c[106]!==T||c[107]!==E||c[108]!==O||c[109]!==te||c[110]!==P?(F=(0,D.jsxs)(`div`,{className:`flex flex-wrap items-center gap-3`,children:[v,y,x,T,E,O,te,P]}),c[103]=v,c[104]=y,c[105]=x,c[106]=T,c[107]=E,c[108]=O,c[109]=te,c[110]=P,c[111]=F):F=c[111];let L;c[112]!==ce||c[113]!==R?(L=R&&(0,D.jsxs)(`div`,{className:`mt-3 flex items-start gap-3 rounded-lg border border-bad-edge bg-bad-bg px-3 py-2 text-[12.5px] text-bad`,children:[(0,D.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[`Refresh failed: `,R]}),(0,D.jsx)(`button`,{type:`button`,onClick:ce,className:`shrink-0 font-semibold hover:underline`,children:`Dismiss`})]}),c[112]=ce,c[113]=R,c[114]=L):L=c[114];let z;c[115]!==d||c[116]!==w.round?(z=w.round&&(0,D.jsxs)(`div`,{className:`mt-2 flex flex-wrap gap-4 text-[12.5px] text-mut`,children:[(0,D.jsxs)(`span`,{children:[`head `,(0,D.jsx)(n,{repo:d,sha:w.round.head})]}),(0,D.jsxs)(`span`,{children:[`enqueued `,_(w.round.enqueued_at)]}),w.round.fired_at&&(0,D.jsxs)(`span`,{children:[`fired `,_(w.round.fired_at),` · attempt `,w.round.attempts||1]}),w.round.host&&(0,D.jsx)(`span`,{className:`font-mono`,children:w.round.host})]}),c[115]=d,c[116]=w.round,c[117]=z):z=c[117];let B;c[118]===w.round?B=c[119]:(B=w.round?.next&&(0,D.jsx)(`div`,{className:`mt-1.5 text-[13px] text-mut`,children:w.round.next}),c[118]=w.round,c[119]=B),c[120]!==F||c[121]!==L||c[122]!==z||c[123]!==B?(Y=(0,D.jsxs)(`div`,{className:`mb-3.5 rounded-[10px] border border-edge bg-card px-5 py-3.5 shadow-card`,children:[F,L,z,B]}),c[120]=F,c[121]=L,c[122]=z,c[123]=B,c[124]=Y):Y=c[124],c[125]!==S||c[126]!==w.hold?(X=w.hold&&(0,D.jsxs)(`div`,{className:`mb-3.5 rounded-[10px] border border-bad-edge border-l-4 border-l-bad bg-bad-bg px-4 py-2.5 text-[13.5px]`,children:[(0,D.jsx)(`b`,{children:`Held`}),` by `,w.hold.by,` `,b(w.hold.at,S),w.hold.reason&&(0,D.jsxs)(`span`,{className:`ml-2 text-mut`,children:[`“`,w.hold.reason,`”`]}),(0,D.jsx)(`span`,{className:`ml-2 text-faint`,children:`— crq will not review it until this is lifted.`})]}),c[125]=S,c[126]=w.hold,c[127]=X):X=c[127],c[128]!==H||c[129]!==V||c[130]!==p||c[131]!==d||c[132]!==me||c[133]!==G||c[134]!==w.round?.phase?(Z=V&&(0,D.jsx)(l,{title:V===`hold`?`Hold ${d}#${p}?`:`Cancel the round on ${d}#${p}?`,danger:V===`cancel`,confirmLabel:V===`hold`?`Hold it`:`Cancel the round`,needsReason:V===`hold`,reasonLabel:`Why is it held`,busy:H,error:me,body:V===`hold`?`No round is enqueued or fired here until the hold is lifted. Reviews already in flight finish.`:(0,D.jsxs)(D.Fragment,{children:[`The current round is abandoned. Auto-review may enqueue this pull request again on its next pass, at whatever head it then has.`,w.round?.phase===`fired`&&(0,D.jsx)(`p`,{className:`mt-2 text-warn`,children:`This round holds the fire slot; cancelling releases it for the next pull request.`})]}),onConfirm:e=>void G(V,e),onCancel:()=>fe(null)}),c[128]=H,c[129]=V,c[130]=p,c[131]=d,c[132]=me,c[133]=G,c[134]=w.round?.phase,c[135]=Z):Z=c[135],c[136]!==ue||c[137]!==I||c[138]!==le||c[139]!==de||c[140]!==W?(Q=I&&(0,D.jsx)(l,{title:I.kind===`resolve`?`Resolve this thread?`:I.kind===`decline`?`Decline this finding?`:`Dismiss this finding?`,body:I.kind===`resolve`?(0,D.jsx)(D.Fragment,{children:`Marks the review thread resolved on GitHub, where it can be reopened. Use this when the finding has actually been handled.`}):I.kind===`decline`?(0,D.jsx)(D.Fragment,{children:`Posts your reasoning as a reply on the thread and resolves it. crq reads the bot's answer back, so a withdrawal or a stand-by-it becomes part of the record.`}):(0,D.jsxs)(D.Fragment,{children:[`For findings GitHub gives no way to close. It is recorded against`,` `,(0,D.jsx)(`b`,{children:`this head only`}),` — a new head may report it again.`]}),confirmLabel:I.kind===`resolve`?`Resolve`:I.kind===`decline`?`Decline`:`Dismiss`,needsReason:I.kind!==`resolve`,reasonLabel:I.kind===`decline`?`Why you disagree (posted to the PR)`:`Why (kept in state)`,busy:le,error:ue,onConfirm:W,onCancel:()=>{se(null),de()}}),c[136]=ue,c[137]=I,c[138]=le,c[139]=de,c[140]=W,c[141]=Q):Q=c[141],Ce=`grid grid-cols-[minmax(0,1fr)_360px] items-start gap-4 max-[1150px]:grid-cols-[minmax(0,1fr)]`,c[142]!==S||c[143]!==d||c[144]!==w.observe_error||c[145]!==w.observed?(q=(0,D.jsx)(`div`,{className:`mb-3.5 flex flex-wrap items-center gap-2.5 rounded-lg border border-acc-edge bg-acc-bg px-3.5 py-2 text-[12.5px] text-mut`,children:w.observed?(0,D.jsxs)(D.Fragment,{children:[(0,D.jsxs)(`b`,{className:`text-acc`,children:[`Observed `,b(w.observed.checked_at,S)]}),(0,D.jsxs)(`span`,{children:[`at `,(0,D.jsx)(n,{repo:d,sha:w.observed.head}),` · reviewed by`,` `,Object.entries(w.observed.reviewed_by??{}).filter(ie).map(re).join(`, `)||`nobody yet`]})]}):(0,D.jsx)(`span`,{children:w.observe_error?`Could not reach GitHub — ${w.observe_error}`:`Reading findings from GitHub…`})}),c[142]=S,c[143]=d,c[144]=w.observe_error,c[145]=w.observed,c[146]=q):q=c[146],Se=s,Te=`Findings`,Ee=w.observed?`${i.length} open${w.observed.dismissed?` · ${w.observed.dismissed} dismissed`:``}`:`—`,De=w.observed?i.length===0?(0,D.jsxs)(`div`,{className:`px-[18px] py-6 text-center`,children:[(0,D.jsx)(`div`,{className:`text-[15px] font-semibold`,children:`No open findings`}),(0,D.jsx)(`p`,{className:`mt-1 text-[13px] text-mut`,children:w.observed.converged?`Every required reviewer finished and nothing is blocking.`:`Nothing actionable is outstanding at this head.`})]}):(0,D.jsx)(`div`,{className:`px-[18px] pb-3`,children:h.map(e=>(0,D.jsxs)(`div`,{children:[(0,D.jsxs)(`div`,{className:`pt-2.5 pb-1 text-[11px] font-semibold tracking-[0.05em] text-faint uppercase`,children:[e.sev,` · `,e.items.length]}),e.items.map(e=>(0,D.jsx)(oe,{f:e,onAct:t=>se({finding:e,kind:t})},e.id))]},e.sev))}):(0,D.jsx)(e,{children:`Findings need a GitHub read; the round above came from state.`}),c[31]=ue,c[32]=H,c[33]=I,c[34]=le,c[35]=de,c[36]=ce,c[37]=R,c[38]=U,c[39]=S,c[40]=V,c[41]=p,c[42]=j,c[43]=d,c[44]=me,c[45]=W,c[46]=G,c[47]=w.hold,c[48]=w.observe_error,c[49]=w.observed,c[50]=w.round,c[51]=w.title,c[52]=Se,c[53]=K,c[54]=q,c[55]=Ce,c[56]=we,c[57]=J,c[58]=Y,c[59]=X,c[60]=Z,c[61]=Q,c[62]=Te,c[63]=Ee,c[64]=De}else Se=c[52],K=c[53],q=c[54],Ce=c[55],we=c[56],J=c[57],Y=c[58],X=c[59],Z=c[60],Q=c[61],Te=c[62],Ee=c[63],De=c[64];let Oe;c[147]!==Se||c[148]!==Te||c[149]!==Ee||c[150]!==De?(Oe=(0,D.jsx)(Se,{title:Te,count:Ee,children:De}),c[147]=Se,c[148]=Te,c[149]=Ee,c[150]=De,c[151]=Oe):Oe=c[151];let ke;c[152]!==q||c[153]!==Oe?(ke=(0,D.jsxs)(`div`,{children:[q,Oe]}),c[152]=q,c[153]=Oe,c[154]=ke):ke=c[154];let Ae;c[155]!==S||c[156]!==p||c[157]!==d||c[158]!==w.round?(Ae=w.round?.fixing&&(0,D.jsx)(s,{title:`Fix session`,count:`live`,children:(0,D.jsxs)(`div`,{className:`px-[18px] pb-3.5 text-[13px]`,children:[(0,D.jsxs)(t,{tone:`ok`,children:[`Running · `,v(w.round.fixing.since,S)]}),(0,D.jsxs)(`div`,{className:`mt-1.5 text-mut`,children:[w.round.fixing.host,w.round.fixing.model?` · ${w.round.fixing.model}`:``,` · attempt`,` `,w.round.fixing.attempt,w.round.fixing.max_attempts?` of ${w.round.fixing.max_attempts}`:``,w.round.fixing.findings?` · working through ${w.round.fixing.findings} finding(s)`:``,w.round.fixing.heartbeat&&` · heartbeat ${_(w.round.fixing.heartbeat)}`]}),(0,D.jsx)(`div`,{className:`mt-2`,children:(0,D.jsx)(O,{repo:d,pr:p})}),(0,D.jsx)(`p`,{className:`mt-1.5 text-[11.5px] text-faint`,children:`While a session holds this round the queue leaves it alone; the claim is released when the session pushes or exits.`})]})}),c[155]=S,c[156]=p,c[157]=d,c[158]=w.round,c[159]=Ae):Ae=c[159];let $;c[160]!==S||c[161]!==w.round?($=w.round&&(0,D.jsxs)(s,{title:`Round`,count:w.round.head,children:[(0,D.jsxs)(`div`,{className:`px-[18px] pb-3 text-[13px]`,children:[(0,D.jsx)(P,{k:`Phase`,v:w.round.phase}),(0,D.jsx)(P,{k:`Enqueued`,v:_(w.round.enqueued_at)}),w.round.fired_at&&(0,D.jsx)(P,{k:`Fired`,v:_(w.round.fired_at)}),w.round.deadline&&(0,D.jsx)(P,{k:`Deadline`,v:`${f(w.round.deadline,S)} · ${_(w.round.deadline)}`}),w.round.retry_at&&(0,D.jsx)(P,{k:`Retries after`,v:_(w.round.retry_at)}),w.round.co_only&&(0,D.jsx)(P,{k:`Scope`,v:`co-reviewers only — spends no quota`}),w.round.note&&(0,D.jsx)(P,{k:`Note`,v:`“${w.round.note}”`})]}),(0,D.jsxs)(`div`,{className:`border-t border-[#EEF0F3] px-[18px] py-2.5`,children:[(0,D.jsx)(`div`,{className:`mb-1.5 text-[11px] font-medium tracking-[0.06em] text-faint uppercase`,children:`Reviewers`}),(0,D.jsx)(i,{bots:w.round.bots})]})]}),c[160]=S,c[161]=w.round,c[162]=$):$=c[162];let je;c[163]===w.observed?je=c[164]:(je=w.observed?.converged&&(0,D.jsx)(s,{title:`Verdict`,children:(0,D.jsxs)(`div`,{className:`px-[18px] pb-3.5 pt-1`,children:[(0,D.jsxs)(`p`,{className:`text-[13.5px]`,children:[(0,D.jsx)(`b`,{className:`text-ok`,children:`Nothing left to do`}),` —`,` `,w.observed.reason||`every required reviewer finished`,`.`]}),(0,D.jsxs)(`div`,{className:`mt-2 text-[12.5px] text-mut`,children:[(0,D.jsx)(`div`,{className:`mb-1 text-[11px] font-medium tracking-[0.06em] text-faint uppercase`,children:`Reviewed by`}),(0,D.jsx)(`span`,{className:`flex flex-wrap items-center gap-2.5`,children:Object.entries(w.observed.reviewed_by??{}).map(ne)})]}),(0,D.jsx)(`p`,{className:`mt-2.5 text-[12.5px] text-faint`,children:`What happens next: nothing. Merge when you are ready. Push another commit and a fresh round is enqueued for the new head — a converged round is the record that THIS head was reviewed, not that the pull request is finished.`})]})}),c[163]=w.observed,c[164]=je);let Me;c[165]!==w.cost||c[166]!==w.cost_error?(Me=(w.cost||w.cost_error)&&(0,D.jsx)(F,{cost:w.cost,error:w.cost_error}),c[165]=w.cost,c[166]=w.cost_error,c[167]=Me):Me=c[167];let Ne;c[168]===K?Ne=c[169]:(Ne=K.length>0&&(0,D.jsx)(s,{title:`Dismissed`,count:K.length,children:(0,D.jsxs)(`div`,{className:`px-[18px] pb-3 text-[12.5px] text-mut`,children:[K.map(M),(0,D.jsx)(`p`,{className:`pt-2 text-faint`,children:`Dismissals apply to this head only.`})]})}),c[168]=K,c[169]=Ne);let Pe=`${w.history.length} head(s)`,Fe;c[170]===w.history.length?Fe=c[171]:(Fe=w.history.length===0&&(0,D.jsx)(e,{children:`No round has run for this PR.`}),c[170]=w.history.length,c[171]=Fe);let Ie;if(c[172]!==d||c[173]!==w.history){let e;c[175]===d?e=c[176]:(e=e=>(0,D.jsxs)(`div`,{className:`border-b border-[#EEF0F3] py-2 text-[13px] last:border-none`,children:[(0,D.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,D.jsx)(n,{repo:d,sha:e.head}),e.current&&(0,D.jsx)(t,{tone:`acc`,children:`current`})]}),(0,D.jsxs)(`div`,{className:`text-[12px] text-faint`,children:[e.outcome,e.note&&` — ${e.note}`,e.at&&` · ${_(e.at)}`]})]},`${e.head}-${e.outcome}`),c[175]=d,c[176]=e),Ie=w.history.map(e),c[172]=d,c[173]=w.history,c[174]=Ie}else Ie=c[174];let Le;c[177]!==Fe||c[178]!==Ie?(Le=(0,D.jsxs)(`div`,{className:`px-[18px] pb-3`,children:[Fe,Ie]}),c[177]=Fe,c[178]=Ie,c[179]=Le):Le=c[179];let Re;c[180]!==Pe||c[181]!==Le?(Re=(0,D.jsx)(s,{title:`Round history`,count:Pe,children:Le}),c[180]=Pe,c[181]=Le,c[182]=Re):Re=c[182];let ze;c[183]!==Ae||c[184]!==$||c[185]!==je||c[186]!==Me||c[187]!==Ne||c[188]!==Re?(ze=(0,D.jsxs)(`aside`,{children:[Ae,$,je,Me,Ne,Re]}),c[183]=Ae,c[184]=$,c[185]=je,c[186]=Me,c[187]=Ne,c[188]=Re,c[189]=ze):ze=c[189];let Be;c[190]!==Ce||c[191]!==ke||c[192]!==ze?(Be=(0,D.jsxs)(`div`,{className:Ce,children:[ke,ze]}),c[190]=Ce,c[191]=ke,c[192]=ze,c[193]=Be):Be=c[193];let Ve;return c[194]!==we||c[195]!==J||c[196]!==Y||c[197]!==X||c[198]!==Z||c[199]!==Q||c[200]!==Be?(Ve=(0,D.jsxs)(`main`,{className:we,children:[J,Y,X,Z,Q,Be]}),c[194]=we,c[195]=J,c[196]=Y,c[197]=X,c[198]=Z,c[199]=Q,c[200]=Be,c[201]=Ve):Ve=c[201],Ve}function M(e){return(0,D.jsxs)(`div`,{className:`border-b border-[#EEF0F3] py-1.5 last:border-none`,children:[`“`,e.reason,`”`,(0,D.jsx)(`div`,{className:`font-mono text-[11px] text-faint`,children:e.id.slice(0,12)})]},e.id)}function ne(e){let[t,n]=e;return(0,D.jsxs)(`span`,{className:`flex items-center gap-1.5`,children:[(0,D.jsx)(c,{login:t,name:t,size:18}),(0,D.jsx)(`span`,{className:n?`text-ok`:`text-faint`,children:n?`✓`:`pending`})]},t)}function re(e){let[t]=e;return t}function ie(e){let[,t]=e;return t}function ae(e){return e.items.length>0}var N=new Set([`review_body`,`review_prompt`,`review_skipped`,`issue_comment`]);function oe(e){let n=(0,T.c)(33),{f:r,onAct:i}=e,[a,s]=(0,E.useState)(!1),c=r.severity||`unknown`,l=!!r.thread_id,u;n[0]!==r.source||n[1]!==l?(u=!l&&N.has(r.source??``),n[0]=r.source,n[1]=l,n[2]=u):u=n[2];let d=u,f;n[3]===a?f=n[4]:(f=()=>s(!a),n[3]=a,n[4]=f);let p=r.title||`(untitled finding)`,m;n[5]===p?m=n[6]:(m=(0,D.jsx)(`span`,{className:`text-[13.5px] font-[550]`,children:p}),n[5]=p,n[6]=m);let h=r.path?` · ${r.path}${r.line?`:${r.line}`:``}`:``,g=r.category?` · ${r.category}`:``,_=r.effort?` · ${r.effort}`:``,v=r.thread_id?` · thread open`:``,y;n[7]!==r.bot||n[8]!==h||n[9]!==g||n[10]!==_||n[11]!==v?(y=(0,D.jsxs)(`span`,{className:`mt-0.5 block font-mono text-[12px] text-faint`,children:[r.bot,h,g,_,v]}),n[7]=r.bot,n[8]=h,n[9]=g,n[10]=_,n[11]=v,n[12]=y):y=n[12];let b;n[13]!==m||n[14]!==y?(b=(0,D.jsxs)(`span`,{className:`flex-1`,children:[m,y]}),n[13]=m,n[14]=y,n[15]=b):b=n[15];let x=te[c]??`mut`,S=r.scale||c,C;n[16]!==x||n[17]!==S?(C=(0,D.jsx)(t,{tone:x,children:S}),n[16]=x,n[17]=S,n[18]=C):C=n[18];let w;n[19]!==b||n[20]!==C||n[21]!==f?(w=(0,D.jsxs)(`button`,{type:`button`,onClick:f,className:`flex w-full items-baseline gap-2.5 px-3.5 py-2.5 text-left hover:bg-[#F7F8FA]`,children:[b,C]}),n[19]=b,n[20]=C,n[21]=f,n[22]=w):w=n[22];let O;n[23]!==d||n[24]!==r.body||n[25]!==r.url||n[26]!==i||n[27]!==a||n[28]!==l?(O=a&&(0,D.jsxs)(`div`,{className:`border-t border-[#EEF0F3] px-4 py-2.5 text-[13px] text-mut`,children:[(0,D.jsx)(`p`,{className:`whitespace-pre-wrap`,children:r.body||`No detail was captured for this finding.`}),(0,D.jsxs)(`div`,{className:`mt-3 flex flex-wrap items-center gap-2`,children:[l&&(0,D.jsxs)(D.Fragment,{children:[(0,D.jsx)(`button`,{type:`button`,onClick:()=>i(`resolve`),className:`rounded-lg border border-edge px-3 py-1 text-[12.5px] font-semibold text-mut hover:border-edge2`,children:`Resolve thread`}),(0,D.jsx)(`button`,{type:`button`,onClick:()=>i(`decline`),className:`rounded-lg border border-edge px-3 py-1 text-[12.5px] font-semibold text-mut hover:border-edge2`,children:`Decline…`})]}),d&&(0,D.jsx)(`button`,{type:`button`,onClick:()=>i(`dismiss`),className:`rounded-lg border border-edge px-3 py-1 text-[12.5px] font-semibold text-mut hover:border-edge2`,children:`Dismiss…`}),!l&&!d&&(0,D.jsx)(`span`,{className:`text-[12px] text-faint`,children:`No thread to resolve, and this source cannot be dismissed — it clears when the finding stops being reported.`}),r.url&&(0,D.jsxs)(`a`,{href:r.url,target:`_blank`,rel:`noreferrer`,className:`ml-auto inline-flex items-center gap-1 text-[12.5px] text-acc hover:underline`,children:[`View on GitHub `,(0,D.jsx)(o,{"aria-hidden":!0,className:`size-3`})]})]})]}),n[23]=d,n[24]=r.body,n[25]=r.url,n[26]=i,n[27]=a,n[28]=l,n[29]=O):O=n[29];let ee;return n[30]!==w||n[31]!==O?(ee=(0,D.jsxs)(`div`,{className:`mb-2 overflow-hidden rounded-lg border border-edge`,children:[w,O]}),n[30]=w,n[31]=O,n[32]=ee):ee=n[32],ee}function P(e){let t=(0,T.c)(7),{k:n,v:r}=e,i;t[0]===n?i=t[1]:(i=(0,D.jsx)(`span`,{className:`text-mut`,children:n}),t[0]=n,t[1]=i);let a;t[2]===r?a=t[3]:(a=(0,D.jsx)(`span`,{className:`text-right font-medium`,children:r}),t[2]=r,t[3]=a);let o;return t[4]!==i||t[5]!==a?(o=(0,D.jsxs)(`div`,{className:`flex justify-between gap-3 border-b border-[#EEF0F3] py-1.5 last:border-none`,children:[i,a]}),t[4]=i,t[5]=a,t[6]=o):o=t[6],o}function F(e){let t=(0,T.c)(23),{cost:n,error:r}=e;if(!n){let e=r?`Could not work out a price — ${r}`:`No price could be worked out.`,n;return t[0]===e?n=t[1]:(n=(0,D.jsx)(s,{title:`Estimated cost`,children:(0,D.jsx)(`div`,{className:`px-[18px] pb-3.5 pt-1 text-[12.5px] text-faint`,children:e})}),t[0]=e,t[1]=n),n}let i=I,a=n.summary,o=n.diff.additions+n.diff.deletions,l;t[2]!==n.diff.changed_files||t[3]!==o?(l=(0,D.jsxs)(`p`,{className:`text-[12.5px] text-faint`,children:[o,` changed lines across `,n.diff.changed_files,` `,`file(s), for one more round at this head.`]}),t[2]=n.diff.changed_files,t[3]=o,t[4]=l):l=t[4];let u;if(t[5]!==n.reviewers){let e;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(e=e=>(0,D.jsxs)(`tr`,{className:`border-b border-[#EEF0F3] last:border-none`,children:[(0,D.jsx)(`td`,{className:`py-1.5 pr-2 align-top`,children:(0,D.jsx)(c,{login:e.bot,name:e.bot,size:18})}),(0,D.jsx)(`td`,{className:`py-1.5 pr-2 align-top text-[12.5px] text-mut`,children:e.basis}),(0,D.jsx)(`td`,{className:`py-1.5 text-right align-top font-mono text-[12.5px] whitespace-nowrap`,children:e.unknown?(0,D.jsx)(`span`,{className:`text-warn`,children:`unknown`}):e.high===0?(0,D.jsx)(`span`,{className:`text-faint`,children:`included`}):e.low===e.high?i(e.high):`${i(e.low)}–${i(e.high)}`})]},e.bot),t[7]=e):e=t[7],u=n.reviewers.map(e),t[5]=n.reviewers,t[6]=u}else u=t[6];let d;t[8]===u?d=t[9]:(d=(0,D.jsx)(`table`,{className:`mt-2 w-full border-collapse`,children:(0,D.jsx)(`tbody`,{children:u})}),t[8]=u,t[9]=d);let f;t[10]===n.unpriced?f=t[11]:(f=(n.unpriced?.length??0)>0&&(0,D.jsxs)(`p`,{className:`mt-2 rounded-lg border border-warn-edge bg-warn-bg px-2.5 py-1.5 text-[12px] text-warn`,children:[`The total is a floor: `,n.unpriced?.join(`, `),` could not be priced.`]}),t[10]=n.unpriced,t[11]=f);let p;t[12]!==n.prices_checked_at||t[13]!==n.pricing_note?(p=(0,D.jsxs)(`p`,{className:`mt-2 text-[11.5px] text-faint`,children:[`Estimate. Published prices last checked `,n.prices_checked_at,`. `,n.pricing_note]}),t[12]=n.prices_checked_at,t[13]=n.pricing_note,t[14]=p):p=t[14];let m;t[15]!==l||t[16]!==d||t[17]!==f||t[18]!==p?(m=(0,D.jsxs)(`div`,{className:`px-[18px] pb-3 pt-1`,children:[l,d,f,p]}),t[15]=l,t[16]=d,t[17]=f,t[18]=p,t[19]=m):m=t[19];let h;return t[20]!==n.summary||t[21]!==m?(h=(0,D.jsx)(s,{title:`Estimated cost`,count:a,children:m}),t[20]=n.summary,t[21]=m,t[22]=h):h=t[22],h}function I(e){return`$${e.toFixed(2)}`}function se(){let e=(0,T.c)(3),{name:t,owner:n,pr:r}=S.useParams(),i=`${n}/${t}`,a;return e[0]!==r||e[1]!==i?(a=(0,D.jsx)(j,{repo:i,pr:r}),e[0]=r,e[1]=i,e[2]=a):a=e[2],a}export{se as PRRoute}; \ No newline at end of file diff --git a/internal/serve/dist/assets/ReposRoute-Du6RfsHV.js b/internal/serve/dist/assets/ReposRoute-Du6RfsHV.js new file mode 100644 index 00000000..d1a6eb83 --- /dev/null +++ b/internal/serve/dist/assets/ReposRoute-Du6RfsHV.js @@ -0,0 +1 @@ +import{a as e,c as t,d as n,f as r,l as i,o as a,r as o,t as s,u as c}from"./ui-Bim2uazI.js";import{a as l,i as u,n as ee,o as d,r as f,s as p,t as m}from"./useOperation-BqNdl-Dh.js";import{S as h,b as g,c as _,g as v,l as y,n as b,p as te,r as x,s as S,u as C,v as w,x as T}from"./index-DlC1aBiV.js";var E=v(),D=h(T(),1),O=g();function k(e){let t=(0,E.c)(13),{preview:n}=e;if(n.errorKind===`preview`){let e;return t[0]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,O.jsx)(O.Fragment,{children:`Could not work out what this would enqueue. Enabling anyway is still safe — the daemon will pick up whatever is open — but the cost is unknown from here.`}),t[0]=e):e=t[0],e}if(n.errorKind===`enroll`){let e;return t[1]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,O.jsx)(O.Fragment,{children:`Enabling did not return a confirmed result. Refresh before retrying — the enrollment may already have been saved, and any cost estimate shown here may now be stale.`}),t[1]=e):e=t[1],e}if(!n.impact){let e;return t[2]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,O.jsx)(O.Fragment,{children:`Working out what this would enqueue, and what it would cost…`}),t[2]=e):e=t[2],e}let r;t[3]===n.impact.summary?r=t[4]:(r=(0,O.jsxs)(`p`,{children:[n.impact.summary,`.`]}),t[3]=n.impact.summary,t[4]=r);let i;t[5]===n.impact.skipped?i=t[6]:(i=Object.entries(n.impact.skipped??{}).map(A),t[5]=n.impact.skipped,t[6]=i);let a;t[7]===n.impact.prices_checked_at?a=t[8]:(a=(0,O.jsxs)(`p`,{className:`mt-2 text-[12px] text-faint`,children:[`Estimate; published prices last checked `,n.impact.prices_checked_at,`. Reviews start on the daemon's next pass, one metered review at a time across the fleet.`]}),t[7]=n.impact.prices_checked_at,t[8]=a);let o;return t[9]!==r||t[10]!==i||t[11]!==a?(o=(0,O.jsxs)(O.Fragment,{children:[r,i,a]}),t[9]=r,t[10]=i,t[11]=a,t[12]=o):o=t[12],o}function A(e){let[t,n]=e;return(0,O.jsxs)(`p`,{className:`mt-1 text-[12.5px] text-faint`,children:[n,` skipped — `,t,`.`]},t)}function j(n){let r=(0,E.c)(70),{open:a,onClose:o,onSnapshot:s}=n,c=S(3e4),[p,h]=(0,D.useState)(null),g;r[0]===Symbol.for(`react.memo_cache_sentinel`)?(g=[],r[0]=g):g=r[0];let[_,v]=(0,D.useState)(g),[b,w]=(0,D.useState)(``),[T,A]=(0,D.useState)(!1),[j,M]=(0,D.useState)(``),[N,P]=(0,D.useState)(null),ne=(0,D.useRef)(!1),{run:re,error:F}=m(),{run:I,running:L}=m(),{run:R,error:z,clearError:B}=m(),[V,H]=(0,D.useState)(null),U;r[1]===re?U=r[2]:(U=e=>{let t=e!==void 0&&e;A(!0),re(y(t),{onSuccess:e=>{let{repos:t,truncated:n}=e;h(t),v(n??[])},onFinally:()=>A(!1)})},r[1]=re,r[2]=U);let W=U,ie,G;r[3]!==W||r[4]!==a||r[5]!==p?(ie=()=>{if(!a){ne.current=!1;return}p!==null||ne.current||(ne.current=!0,W())},G=[W,a,p],r[3]=W,r[4]=a,r[5]=p,r[6]=ie,r[7]=G):(ie=r[6],G=r[7]),(0,D.useEffect)(ie,G);let ae;bb0:{if(!p){let e;r[8]===Symbol.for(`react.memo_cache_sentinel`)?(e=[],r[8]=e):e=r[8],ae=e;break bb0}let e;if(r[9]!==j||r[10]!==p){let t=j.trim().toLowerCase();e=t?p.filter(e=>e.repo.toLowerCase().includes(t)):p,r[9]=j,r[10]=p,r[11]=e}else e=r[11];ae=e}let oe=ae,se,K;if(r[12]!==b){K=b.trim();let e;r[15]===Symbol.for(`react.memo_cache_sentinel`)?(e=/^[^/\s]+\/[^/\s]+$/,r[15]=e):e=r[15],se=e.test(K),r[12]=b,r[13]=se,r[14]=K}else se=r[13],K=r[14];let ce=se;if(!a)return null;let q;r[16]!==B||r[17]!==I?(q=e=>{B(),H({repo:e}),I(C(e),{onSuccess:t=>H({repo:e,impact:t}),onFailure:t=>H({repo:e,error:t.message,errorKind:`preview`})})},r[16]=B,r[17]=I,r[18]=q):q=r[18];let J=q,Y;r[19]!==s||r[20]!==V?.impact?.rev||r[21]!==R?(Y=e=>{P(e),R(te(`enroll`,{repo:e,enabled:!0,expected_rev:V?.impact?.rev}),{onSuccess:t=>{let{snapshot:n}=t;s?.(n),H(null),h(t=>t?.map(t=>t.repo.toLowerCase()===e.toLowerCase()?{...t,enrollment:{source:`state`,enabled:!0}}:t)??t)},onFailure:t=>H(n=>n?.repo===e?{...n,error:t.message,errorKind:`enroll`}:n),onFinally:()=>P(null)})},r[19]=s,r[20]=V?.impact?.rev,r[21]=R,r[22]=Y):Y=r[22];let le=Y,X;r[23]===o?X=r[24]:(X=e=>!e&&o(),r[23]=o,r[24]=X);let ue;r[25]===Symbol.for(`react.memo_cache_sentinel`)?(ue=(0,O.jsxs)(`div`,{className:`flex items-center gap-3 border-b border-edge px-5 py-3.5`,children:[(0,O.jsx)(d,{children:`Add a repository`}),(0,O.jsx)(l,{className:`pr-8 text-[12.5px] text-faint`,children:`everything in CRQ_SCOPE, most recently pushed first`})]}),r[25]=ue):ue=r[25];let de;r[26]===Symbol.for(`react.memo_cache_sentinel`)?(de=e=>M(e.target.value),r[26]=de):de=r[26];let Z;r[27]===j?Z=r[28]:(Z=(0,O.jsx)(`input`,{"aria-label":`Filter repositories`,autoFocus:!0,value:j,onChange:de,placeholder:`filter by name…`,className:`flex-1 rounded-lg border border-edge bg-[#FBFBFC] px-2.5 py-1.5 text-[13px]`}),r[27]=j,r[28]=Z);let fe;r[29]===W?fe=r[30]:(fe=()=>void W(!0),r[29]=W,r[30]=fe);let pe=T?`Loading…`:`Refresh`,me;r[31]!==T||r[32]!==fe||r[33]!==pe?(me=(0,O.jsx)(`button`,{type:`button`,disabled:T,onClick:fe,className:`rounded-lg border border-edge px-3 py-1.5 text-[12.5px] font-semibold text-mut disabled:opacity-45`,children:pe}),r[31]=T,r[32]=fe,r[33]=pe,r[34]=me):me=r[34];let Q;r[35]!==Z||r[36]!==me?(Q=(0,O.jsxs)(`div`,{className:`flex items-center gap-2.5 border-b border-edge px-5 py-2.5`,children:[Z,me]}),r[35]=Z,r[36]=me,r[37]=Q):Q=r[37];let he;r[38]===F?he=r[39]:(he=F&&(0,O.jsx)(`div`,{className:`border-b border-bad-edge bg-bad-bg px-5 py-2 text-[12.5px] text-bad`,children:F}),r[38]=F,r[39]=he);let ge;r[40]!==b||r[41]!==ce||r[42]!==J||r[43]!==K||r[44]!==_?(ge=_.length>0&&(0,O.jsxs)(`div`,{className:`border-b border-warn-edge bg-warn-bg px-5 py-2.5 text-[12.5px] text-warn`,children:[`GitHub capped discovery for `,_.join(`, `),`. Enter a missing repository by its full owner/name:`,(0,O.jsxs)(`form`,{className:`mt-2 flex gap-2`,onSubmit:e=>{e.preventDefault(),ce&&J(K)},children:[(0,O.jsx)(`input`,{"aria-label":`Repository owner and name`,value:b,onChange:e=>w(e.target.value),placeholder:`owner/repository`,className:`min-w-0 flex-1 rounded-lg border border-warn-edge bg-white px-2.5 py-1 text-ink`}),(0,O.jsx)(`button`,{type:`submit`,disabled:!ce,className:`rounded-lg bg-ink px-3 py-1 font-semibold text-white disabled:opacity-45`,children:`Add by name`})]})]}),r[40]=b,r[41]=ce,r[42]=J,r[43]=K,r[44]=_,r[45]=ge):ge=r[45];let _e;r[46]!==N||r[47]!==T||r[48]!==c||r[49]!==J||r[50]!==p||r[51]!==oe?(_e=(0,O.jsx)(`div`,{className:`min-h-0 flex-1 overflow-auto`,children:p===null?(0,O.jsx)(e,{children:T?`Listing repositories…`:`Nothing loaded.`}):oe.length===0?(0,O.jsx)(e,{children:`No repository matches.`}):(0,O.jsx)(`ul`,{children:oe.map(e=>{let n=e.enrollment,r=n?.enabled===!0,a=n?.source===`excluded`;return(0,O.jsxs)(`li`,{className:`flex flex-wrap items-center gap-2.5 border-b border-[#EEF0F3] px-5 py-2 text-[13px] last:border-none`,children:[(0,O.jsx)(i,{repo:e.repo}),(0,O.jsx)(`span`,{className:`min-w-[160px] flex-1 break-all font-[550]`,children:e.repo}),e.private&&(0,O.jsx)(t,{tone:`mut`,children:`private`}),e.archived&&(0,O.jsx)(t,{tone:`mut`,children:`archived`}),e.fork&&(0,O.jsx)(t,{tone:`mut`,children:`fork`}),(0,O.jsxs)(`span`,{className:`text-[12px] text-faint max-sm:order-last max-sm:basis-full max-sm:pl-[26px]`,children:[e.issues>0&&`${e.issues} open issue/PR${e.issues===1?``:`s`}`,e.pushed_at&&` · pushed ${x(e.pushed_at,c)}`]}),(0,O.jsx)(`span`,{className:`ml-auto shrink-0`,children:a?(0,O.jsx)(`span`,{className:`text-[12px] text-faint`,title:`CRQ_EXCLUDE is a per-host kill switch`,children:`excluded by env`}):r?(0,O.jsx)(t,{tone:`ok`,children:n?.source===`state`?`added`:n?.source===`env`?`via env`:`in scope`}):(0,O.jsx)(`button`,{type:`button`,disabled:N===e.repo,onClick:()=>void J(e.repo),className:`rounded-lg bg-ink px-3 py-1 text-[12.5px] font-semibold text-white disabled:opacity-45`,children:N===e.repo?`Adding…`:`Add`})})]},e.repo)})})}),r[46]=N,r[47]=T,r[48]=c,r[49]=J,r[50]=p,r[51]=oe,r[52]=_e):_e=r[52];let $;r[53]!==le||r[54]!==z||r[55]!==N||r[56]!==V||r[57]!==L?($=V&&(0,O.jsx)(ee,{title:`Review ${V.repo}?`,confirmLabel:N?`Adding…`:`Add it`,busy:L||N===V.repo,error:V.error??z,body:(0,O.jsx)(k,{preview:V}),onConfirm:()=>void le(V.repo),onCancel:()=>H(null)}),r[53]=le,r[54]=z,r[55]=N,r[56]=V,r[57]=L,r[58]=$):$=r[58];let ve;r[59]===Symbol.for(`react.memo_cache_sentinel`)?(ve=(0,O.jsx)(`p`,{className:`border-t border-edge px-5 py-2.5 text-[12px] text-faint`,children:`Adding records the decision in shared state, so every host agrees. Its pull requests are picked up on the daemon's next pass, with the fleet's default reviewers — change those on the repository's own page.`}),r[59]=ve):ve=r[59];let ye;r[60]!==Q||r[61]!==he||r[62]!==ge||r[63]!==_e||r[64]!==$?(ye=(0,O.jsxs)(u,{className:`top-[8vh] flex max-h-[84vh] max-w-[720px] flex-col overflow-hidden p-0`,children:[ue,Q,he,ge,_e,$,ve]}),r[60]=Q,r[61]=he,r[62]=ge,r[63]=_e,r[64]=$,r[65]=ye):ye=r[65];let be;return r[66]!==a||r[67]!==ye||r[68]!==X?(be=(0,O.jsx)(f,{open:a,onOpenChange:X,children:ye}),r[66]=a,r[67]=ye,r[68]=X,r[69]=be):be=r[69],be}function M(e){let t=(0,E.c)(49),{repo:n,source:r,reviewed:i,envConflict:a,clearEnables:s,reason:c,by:l,active:u,onSnapshot:d}=e,[f,p]=(0,D.useState)(!1),[h,g]=(0,D.useState)(``),{run:_,running:v,error:y}=m(),{run:b,running:x}=m(),[S,w]=(0,D.useState)(null),[T,A]=(0,D.useState)(null),[j,M]=(0,D.useState)(!1),N;t[0]!==d||t[1]!==_?(N=e=>_(te(`enroll`,e),{onSuccess:e=>{let{snapshot:t,warning:n}=e;d?.(t),w(n??null),p(!1),A(null),M(!1),g(``)}}),t[0]=d,t[1]=_,t[2]=N):N=t[2];let P=N,ne;t[3]!==n||t[4]!==b?(ne=e=>{M(e!==void 0&&e),A({}),b(C(n),{onSuccess:e=>A({impact:e}),onFailure:e=>A({error:e.message,errorKind:`preview`})})},t[3]=n,t[4]=b,t[5]=ne):ne=t[5];let re=ne,F=r===`excluded`,I=r===`state`?`recorded here`:`from ${r}`,L=F?`CRQ_EXCLUDE names this repository on a host. That is a per-host kill switch and shared state does not override it — edit that host's env file.`:i?`crq enqueues review rounds for this repository's open pull requests.`:`crq does not review this repository.`,R;t[6]===a?R=t[7]:(R=a&&(0,O.jsxs)(O.Fragment,{children:[` `,(0,O.jsx)(`b`,{className:`text-warn`,children:`A host's CRQ_REPOS still lists it; this record wins, and that file is now out of date.`})]}),t[6]=a,t[7]=R);let z;t[8]!==L||t[9]!==R?(z=(0,O.jsxs)(`p`,{className:`text-[12.5px] text-mut`,children:[L,R]}),t[8]=L,t[9]=R,t[10]=z):z=t[10];let B;t[11]!==l||t[12]!==c?(B=c&&(0,O.jsxs)(`p`,{className:`mt-1.5 text-[12.5px] text-faint`,children:[`“`,c,`”`,l&&` — ${l}`]}),t[11]=l,t[12]=c,t[13]=B):B=t[13];let V;t[14]===S?V=t[15]:(V=S&&(0,O.jsx)(`div`,{className:`mt-3 rounded-lg border border-warn-edge bg-warn-bg px-3 py-2 text-[12.5px] text-warn`,children:S}),t[14]=S,t[15]=V);let H;t[16]===y?H=t[17]:(H=y&&(0,O.jsx)(`div`,{className:`mt-3 rounded-lg border border-bad-edge bg-bad-bg px-3 py-2 text-[12.5px] text-bad`,children:y}),t[16]=y,t[17]=H);let U;t[18]!==u||t[19]!==v||t[20]!==s||t[21]!==f||t[22]!==T||t[23]!==F||t[24]!==re||t[25]!==n||t[26]!==i||t[27]!==P||t[28]!==r||t[29]!==h?(U=!F&&(0,O.jsxs)(`div`,{className:`mt-3 flex flex-wrap items-center gap-2.5`,children:[i?f?(0,O.jsxs)(O.Fragment,{children:[(0,O.jsx)(`input`,{"aria-label":`Reason for stopping automatic review`,value:h,onChange:e=>g(e.target.value),placeholder:`why — every screen that shows this will show it`,className:`min-w-[280px] flex-1 rounded-lg border border-edge bg-[#FBFBFC] px-2.5 py-1.5 text-[13px]`}),(0,O.jsx)(`button`,{type:`button`,disabled:v||h.trim()===``,onClick:()=>void P({repo:n,enabled:!1,reason:h.trim()}),className:`rounded-lg bg-bad px-4 py-1.5 text-[13px] font-semibold text-white disabled:opacity-45`,children:v?`Working…`:`Stop reviewing`}),(0,O.jsxs)(`span`,{className:`basis-full text-[12px] text-faint`,children:[`No new rounds are enqueued here.`,` `,u>0?`The ${u} round(s) already in flight finish — cancel them on their own pages to stop sooner.`:`Nothing is in flight, so this takes effect immediately.`,` `,`Reviewer, autofix and fix-session settings are kept, so turning it back on restores them.`]}),(0,O.jsx)(`button`,{type:`button`,onClick:()=>p(!1),className:`rounded-lg border border-edge px-4 py-1.5 text-[13px] font-semibold text-mut`,children:`Cancel`})]}):(0,O.jsx)(`button`,{type:`button`,onClick:()=>p(!0),className:`rounded-lg border border-bad-edge px-4 py-1.5 text-[13px] font-semibold text-bad`,children:`Stop reviewing this repository`}):(0,O.jsx)(`button`,{type:`button`,disabled:v||T!==null,onClick:()=>re(),className:`rounded-lg bg-ink px-4 py-1.5 text-[13px] font-semibold text-white disabled:opacity-45`,children:T?`Checking backlog…`:`Review this repository`}),r===`state`&&(0,O.jsx)(`button`,{type:`button`,disabled:v,onClick:()=>s?re(!0):P({repo:n,clear:!0}),className:`ml-auto text-[12.5px] text-acc hover:underline disabled:opacity-45`,children:`Follow this host's env instead`})]}),t[18]=u,t[19]=v,t[20]=s,t[21]=f,t[22]=T,t[23]=F,t[24]=re,t[25]=n,t[26]=i,t[27]=P,t[28]=r,t[29]=h,t[30]=U):U=t[30];let W;t[31]!==U||t[32]!==z||t[33]!==B||t[34]!==V||t[35]!==H?(W=(0,O.jsxs)(`div`,{className:`px-[18px] pb-4 pt-1`,children:[z,B,V,H,U]}),t[31]=U,t[32]=z,t[33]=B,t[34]=V,t[35]=H,t[36]=W):W=t[36];let ie;t[37]!==v||t[38]!==T||t[39]!==y||t[40]!==x||t[41]!==j||t[42]!==n||t[43]!==P?(ie=T&&(0,O.jsx)(ee,{title:j?`Follow this host's policy for ${n}?`:`Review ${n}?`,confirmLabel:j?`Clear record and review`:`Review this repository`,busy:x||v,error:T.error??y,body:(0,O.jsx)(k,{preview:T}),onConfirm:()=>P(j?{repo:n,clear:!0,expected_rev:T.impact?.rev}:{repo:n,enabled:!0,expected_rev:T.impact?.rev}),onCancel:()=>{A(null),M(!1)}}),t[37]=v,t[38]=T,t[39]=y,t[40]=x,t[41]=j,t[42]=n,t[43]=P,t[44]=ie):ie=t[44];let G;return t[45]!==W||t[46]!==ie||t[47]!==I?(G=(0,O.jsxs)(o,{title:`Automatic review`,end:I,children:[W,ie]}),t[45]=W,t[46]=ie,t[47]=I,t[48]=G):G=t[48],G}function N(e){let t=(0,E.c)(238),{repo:n,solver:r,onSnapshot:i}=e,[a,s]=(0,D.useState)(r.model??``),[c,l]=(0,D.useState)(r.effort??``),[u,ee]=(0,D.useState)(r.prompt??``),[d,f]=(0,D.useState)(String(r.max_attempts)),[h,g]=(0,D.useState)(r.severities),[_,v]=(0,D.useState)(r.ask_mode),[y,b]=(0,D.useState)(r.forks),x;t[0]===r.skip_authors?x=t[1]:(x=r.skip_authors??[],t[0]=r.skip_authors,t[1]=x);let S;t[2]===x?S=t[3]:(S=x.join(`, `),t[2]=x,t[3]=S);let[C,w]=(0,D.useState)(S),{run:T,running:k,error:A}=m(),[j,M]=(0,D.useState)(null),N=r.model??``,R=r.effort??``,z=r.prompt??``,B=String(r.max_attempts),V;t[4]===r.severities?V=t[5]:(V=[...r.severities].sort().join(`\0`),t[4]=r.severities,t[5]=V);let H=V,U=r.ask_mode,W;t[6]===r.skip_authors?W=t[7]:(W=r.skip_authors??[],t[6]=r.skip_authors,t[7]=W);let ie;t[8]===W?ie=t[9]:(ie=W.join(`, `),t[8]=W,t[9]=ie);let G=ie,ae,oe;t[10]!==r.forks||t[11]!==U||t[12]!==B||t[13]!==G||t[14]!==R||t[15]!==N||t[16]!==z||t[17]!==H?(ae=()=>{s(N),l(R),ee(z),f(B),g(H?H.split(`\0`):[]),v(U),b(r.forks),w(G)},oe=[N,R,z,B,H,U,r.forks,G],t[10]=r.forks,t[11]=U,t[12]=B,t[13]=G,t[14]=R,t[15]=N,t[16]=z,t[17]=H,t[18]=ae,t[19]=oe):(ae=t[18],oe=t[19]),(0,D.useEffect)(ae,oe);let se;t[20]!==_||t[21]!==d||t[22]!==C||t[23]!==c||t[24]!==y||t[25]!==a||t[26]!==u||t[27]!==h||t[28]!==r.forks||t[29]!==r.severities||t[30]!==U||t[31]!==B||t[32]!==G||t[33]!==R||t[34]!==N||t[35]!==z?(se=a!==N||c!==R||u!==z||d!==B||!p(h,r.severities)||_!==U||y!==r.forks||C!==G,t[20]=_,t[21]=d,t[22]=C,t[23]=c,t[24]=y,t[25]=a,t[26]=u,t[27]=h,t[28]=r.forks,t[29]=r.severities,t[30]=U,t[31]=B,t[32]=G,t[33]=R,t[34]=N,t[35]=z,t[36]=se):se=t[36];let K=se,ce;t[37]!==_||t[38]!==d||t[39]!==C||t[40]!==c||t[41]!==y||t[42]!==a||t[43]!==i||t[44]!==u||t[45]!==n||t[46]!==T||t[47]!==h||t[48]!==r?(ce=e=>{let t=e!==void 0&&e;M(null),T(te(`solver`,{repo:n,solver:t?{clear:!0}:F(r,{model:a,effort:c,prompt:u,attempts:d,severities:h,askMode:_,forks:y,authors:C})}),{onSuccess:e=>{let{snapshot:t,warning:n}=e;i?.(t),M(n??null)}})},t[37]=_,t[38]=d,t[39]=C,t[40]=c,t[41]=y,t[42]=a,t[43]=i,t[44]=u,t[45]=n,t[46]=T,t[47]=h,t[48]=r,t[49]=ce):ce=t[49];let q=ce,J;t[50]!==i||t[51]!==n||t[52]!==T?(J=e=>{M(null),T(te(`solver`,{repo:n,solver:e===`models`?{unset_models:!0}:e===`effort`?{unset_effort:!0}:e===`prompt`?{unset_prompt:!0}:e===`severities`?{unset_severities:!0}:e===`ask_mode`?{unset_ask_mode:!0}:e===`forks`?{unset_forks:!0}:{unset_skip_authors:!0}}),{onSuccess:e=>{let{snapshot:t,warning:n}=e;i?.(t),M(n??null)}})},t[50]=i,t[51]=n,t[52]=T,t[53]=J):J=t[53];let Y=J,le;t[54]===r.sources?le=t[55]:(le=e=>r.sources?.[e]??`env`,t[54]=r.sources,t[55]=le);let X=le,ue=r.overridden?`override${r.by?` by ${r.by}`:``}`:`following the fleet`,de;t[56]===r.agent?de=t[57]:(de=(0,O.jsxs)(`p`,{className:`text-[12.5px] text-faint`,children:[r.agent?(0,O.jsxs)(O.Fragment,{children:[`Agent: `,(0,O.jsx)(`b`,{className:`font-mono`,children:r.agent.split(`/`).pop()}),` — chosen at install time and the same for every repository.`,` `]}):(0,O.jsxs)(O.Fragment,{children:[`The agent is baked into the installed session script and this server does not read it — CRQ_DISPATCH_CMD is set for the autofix service, not for this one.`,` `]}),`Each row below says which layer its value came from.`]}),t[56]=r.agent,t[57]=de);let Z;t[58]===r.agent_on?Z=t[59]:(Z=(r.agent_on?.length??0)>0&&(0,O.jsxs)(`p`,{className:`mt-2 flex flex-wrap items-center gap-2 text-[12px]`,children:[(0,O.jsx)(`span`,{className:`text-faint`,children:`Agent available on:`}),r.agent_on?.map(re)]}),t[58]=r.agent_on,t[59]=Z);let fe;t[60]===r.lagging_hosts?fe=t[61]:(fe=r.lagging_hosts&&r.lagging_hosts.length>0&&(0,O.jsxs)(`div`,{className:`mt-2.5 rounded-lg border border-warn-edge bg-warn-bg px-3 py-2 text-[12.5px] text-warn`,children:[`These hosts run a binary that predates per-repository fix settings and will use their own install-time values: `,r.lagging_hosts.join(`, `)]}),t[60]=r.lagging_hosts,t[61]=fe);let pe;t[62]===X?pe=t[63]:(pe=X(`model`),t[62]=X,t[63]=pe);let me;t[64]===Symbol.for(`react.memo_cache_sentinel`)?(me=e=>s(e.target.value),t[64]=me):me=t[64];let Q;t[65]===a?Q=t[66]:(Q=(0,O.jsx)(`input`,{"aria-label":`Autofix model`,value:a,onChange:me,placeholder:`the agent's own default`,className:`w-56 rounded-lg border border-edge bg-[#FBFBFC] px-2 py-1 font-mono text-[12.5px]`}),t[65]=a,t[66]=Q);let he;t[67]===X?he=t[68]:(he=X(`model`),t[67]=X,t[68]=he);let ge=he===`repo`,_e=k||K,$;t[69]===Y?$=t[70]:($=()=>Y(`models`),t[69]=Y,t[70]=$);let ve;t[71]!==ge||t[72]!==_e||t[73]!==$?(ve=(0,O.jsx)(I,{shown:ge,busy:_e,onClick:$}),t[71]=ge,t[72]=_e,t[73]=$,t[74]=ve):ve=t[74];let ye;t[75]!==pe||t[76]!==Q||t[77]!==ve?(ye=(0,O.jsxs)(L,{label:`Model`,source:pe,children:[Q,ve]}),t[75]=pe,t[76]=Q,t[77]=ve,t[78]=ye):ye=t[78];let be;t[79]===X?be=t[80]:(be=X(`effort`),t[79]=X,t[80]=be);let xe;t[81]===Symbol.for(`react.memo_cache_sentinel`)?(xe=e=>l(e.target.value),t[81]=xe):xe=t[81];let Se,Ce;t[82]===Symbol.for(`react.memo_cache_sentinel`)?(Se=(0,O.jsx)(`option`,{value:``,children:`the agent's own default`}),Ce=[`low`,`medium`,`high`,`xhigh`,`max`].map(ne),t[82]=Se,t[83]=Ce):(Se=t[82],Ce=t[83]);let we;t[84]===c?we=t[85]:(we=(0,O.jsxs)(`select`,{"aria-label":`Autofix reasoning effort`,value:c,onChange:xe,className:`rounded-lg border border-edge bg-[#FBFBFC] px-2 py-1 text-[12.5px]`,children:[Se,Ce]}),t[84]=c,t[85]=we);let Te;t[86]===X?Te=t[87]:(Te=X(`effort`),t[86]=X,t[87]=Te);let Ee=Te===`repo`,De=k||K,Oe;t[88]===Y?Oe=t[89]:(Oe=()=>Y(`effort`),t[88]=Y,t[89]=Oe);let ke;t[90]!==Ee||t[91]!==De||t[92]!==Oe?(ke=(0,O.jsx)(I,{shown:Ee,busy:De,onClick:Oe}),t[90]=Ee,t[91]=De,t[92]=Oe,t[93]=ke):ke=t[93];let Ae;t[94]!==be||t[95]!==we||t[96]!==ke?(Ae=(0,O.jsxs)(L,{label:`Effort`,source:be,children:[we,ke]}),t[94]=be,t[95]=we,t[96]=ke,t[97]=Ae):Ae=t[97];let je;t[98]===X?je=t[99]:(je=X(`max_attempts`),t[98]=X,t[99]=je);let Me;t[100]===Symbol.for(`react.memo_cache_sentinel`)?(Me=e=>f(e.target.value.replace(/[^0-9]/g,``)),t[100]=Me):Me=t[100];let Ne;t[101]===d?Ne=t[102]:(Ne=(0,O.jsx)(`input`,{"aria-label":`Maximum autofix attempts per head`,value:d,inputMode:`numeric`,onChange:Me,className:`w-16 rounded-lg border border-edge bg-[#FBFBFC] px-2 py-1 font-mono text-[12.5px]`}),t[101]=d,t[102]=Ne);let Pe;t[103]===Symbol.for(`react.memo_cache_sentinel`)?(Pe=(0,O.jsx)(`span`,{className:`ml-2 text-[12px] text-faint`,children:`fix sessions per head before crq stops trying; 0 inherits`}),t[103]=Pe):Pe=t[103];let Fe;t[104]!==je||t[105]!==Ne?(Fe=(0,O.jsxs)(L,{label:`Attempts`,source:je,children:[Ne,Pe]}),t[104]=je,t[105]=Ne,t[106]=Fe):Fe=t[106];let Ie;t[107]===X?Ie=t[108]:(Ie=X(`severities`),t[107]=X,t[108]=Ie);let Le;t[109]===h?Le=t[110]:(Le=(0,O.jsx)(`div`,{className:`flex flex-wrap gap-2`,children:[[`critical`,`Critical`],[`major`,`Major`],[`potential`,`Potential`],[`minor`,`Minor`],[`unknown`,`Unclassified`]].map(e=>{let[t,n]=e,r=h.includes(t);return(0,O.jsx)(`button`,{type:`button`,"aria-pressed":r,onClick:()=>g(e=>r?e.length>1?e.filter(e=>e!==t):e:[...e,t]),className:`rounded-full border px-3 py-1 text-[12.5px] font-semibold ${r?`border-ink bg-ink text-white`:`border-edge bg-white text-mut hover:border-edge2`}`,children:n},t)})}),t[109]=h,t[110]=Le);let Re;t[111]===X?Re=t[112]:(Re=X(`severities`),t[111]=X,t[112]=Re);let ze=Re===`repo`,Be=k||K,Ve;t[113]===Y?Ve=t[114]:(Ve=()=>Y(`severities`),t[113]=Y,t[114]=Ve);let He;t[115]!==ze||t[116]!==Be||t[117]!==Ve?(He=(0,O.jsx)(I,{shown:ze,busy:Be,onClick:Ve}),t[115]=ze,t[116]=Be,t[117]=Ve,t[118]=He):He=t[118];let Ue;t[119]!==Ie||t[120]!==Le||t[121]!==He?(Ue=(0,O.jsxs)(L,{label:`Fix findings`,source:Ie,children:[Le,He]}),t[119]=Ie,t[120]=Le,t[121]=He,t[122]=Ue):Ue=t[122];let We;t[123]===X?We=t[124]:(We=X(`ask_mode`),t[123]=X,t[124]=We);let Ge;t[125]===Symbol.for(`react.memo_cache_sentinel`)?(Ge=e=>v(e.target.value),t[125]=Ge):Ge=t[125];let Ke,qe,Je;t[126]===Symbol.for(`react.memo_cache_sentinel`)?(Ke=(0,O.jsx)(`option`,{value:`blocked`,children:`only when blocked`}),qe=(0,O.jsx)(`option`,{value:`uncertain`,children:`when confidence is low`}),Je=(0,O.jsx)(`option`,{value:`ambiguous`,children:`at first ambiguity`}),t[126]=Ke,t[127]=qe,t[128]=Je):(Ke=t[126],qe=t[127],Je=t[128]);let Ye;t[129]===_?Ye=t[130]:(Ye=(0,O.jsxs)(`select`,{"aria-label":`When autofix asks for clarification`,value:_,onChange:Ge,className:`rounded-lg border border-edge bg-[#FBFBFC] px-2 py-1 text-[12.5px]`,children:[Ke,qe,Je]}),t[129]=_,t[130]=Ye);let Xe;t[131]===X?Xe=t[132]:(Xe=X(`ask_mode`),t[131]=X,t[132]=Xe);let Ze=Xe===`repo`,Qe=k||K,$e;t[133]===Y?$e=t[134]:($e=()=>Y(`ask_mode`),t[133]=Y,t[134]=$e);let et;t[135]!==Ze||t[136]!==Qe||t[137]!==$e?(et=(0,O.jsx)(I,{shown:Ze,busy:Qe,onClick:$e}),t[135]=Ze,t[136]=Qe,t[137]=$e,t[138]=et):et=t[138];let tt;t[139]!==We||t[140]!==Ye||t[141]!==et?(tt=(0,O.jsxs)(L,{label:`Ask me`,source:We,children:[Ye,et]}),t[139]=We,t[140]=Ye,t[141]=et,t[142]=tt):tt=t[142];let nt;t[143]===X?nt=t[144]:(nt=X(`forks`),t[143]=X,t[144]=nt);let rt;t[145]===Symbol.for(`react.memo_cache_sentinel`)?(rt=()=>b(P),t[145]=rt):rt=t[145];let it=`rounded-lg border px-3 py-1 text-[12.5px] font-semibold ${y?`border-warn-edge bg-warn-bg text-warn`:`border-edge text-mut`}`,at=y?`Allowed`:`Blocked`,ot;t[146]!==it||t[147]!==at?(ot=(0,O.jsx)(`button`,{type:`button`,onClick:rt,className:it,children:at}),t[146]=it,t[147]=at,t[148]=ot):ot=t[148];let st;t[149]===Symbol.for(`react.memo_cache_sentinel`)?(st=(0,O.jsx)(`span`,{className:`ml-2 text-[12px] text-faint`,children:`a session runs an agent over that branch's code with approvals bypassed`}),t[149]=st):st=t[149];let ct;t[150]===X?ct=t[151]:(ct=X(`forks`),t[150]=X,t[151]=ct);let lt=ct===`repo`,ut=k||K,dt;t[152]===Y?dt=t[153]:(dt=()=>Y(`forks`),t[152]=Y,t[153]=dt);let ft;t[154]!==lt||t[155]!==ut||t[156]!==dt?(ft=(0,O.jsx)(I,{shown:lt,busy:ut,onClick:dt}),t[154]=lt,t[155]=ut,t[156]=dt,t[157]=ft):ft=t[157];let pt;t[158]!==nt||t[159]!==ot||t[160]!==ft?(pt=(0,O.jsxs)(L,{label:`Fork PRs`,source:nt,children:[ot,st,ft]}),t[158]=nt,t[159]=ot,t[160]=ft,t[161]=pt):pt=t[161];let mt;t[162]===X?mt=t[163]:(mt=X(`skip_authors`),t[162]=X,t[163]=mt);let ht;t[164]===Symbol.for(`react.memo_cache_sentinel`)?(ht=e=>w(e.target.value),t[164]=ht):ht=t[164];let gt;t[165]===C?gt=t[166]:(gt=(0,O.jsx)(`input`,{"aria-label":`Authors skipped by autofix`,value:C,onChange:ht,placeholder:`dependabot[bot], …`,className:`w-full rounded-lg border border-edge bg-[#FBFBFC] px-2 py-1 font-mono text-[12.5px]`}),t[165]=C,t[166]=gt);let _t;t[167]===X?_t=t[168]:(_t=X(`skip_authors`),t[167]=X,t[168]=_t);let vt=_t===`repo`,yt=k||K,bt;t[169]===Y?bt=t[170]:(bt=()=>Y(`skip_authors`),t[169]=Y,t[170]=bt);let xt;t[171]!==vt||t[172]!==yt||t[173]!==bt?(xt=(0,O.jsx)(I,{shown:vt,busy:yt,onClick:bt}),t[171]=vt,t[172]=yt,t[173]=bt,t[174]=xt):xt=t[174];let St;t[175]!==mt||t[176]!==gt||t[177]!==xt?(St=(0,O.jsxs)(L,{label:`Skip authors`,source:mt,children:[gt,xt]}),t[175]=mt,t[176]=gt,t[177]=xt,t[178]=St):St=t[178];let Ct;t[179]===X?Ct=t[180]:(Ct=X(`prompt`),t[179]=X,t[180]=Ct);let wt;t[181]===Symbol.for(`react.memo_cache_sentinel`)?(wt=e=>ee(e.target.value),t[181]=wt):wt=t[181];let Tt;t[182]===u?Tt=t[183]:(Tt=(0,O.jsx)(`textarea`,{"aria-label":`Extra autofix prompt`,value:u,rows:2,onChange:wt,placeholder:`standing instruction appended to every fix session here`,className:`w-full rounded-lg border border-edge bg-[#FBFBFC] px-2 py-1 text-[12.5px]`}),t[182]=u,t[183]=Tt);let Et;t[184]===X?Et=t[185]:(Et=X(`prompt`),t[184]=X,t[185]=Et);let Dt=Et===`repo`,Ot=k||K,kt;t[186]===Y?kt=t[187]:(kt=()=>Y(`prompt`),t[186]=Y,t[187]=kt);let At;t[188]!==Dt||t[189]!==Ot||t[190]!==kt?(At=(0,O.jsx)(I,{shown:Dt,busy:Ot,onClick:kt}),t[188]=Dt,t[189]=Ot,t[190]=kt,t[191]=At):At=t[191];let jt;t[192]!==Ct||t[193]!==Tt||t[194]!==At?(jt=(0,O.jsxs)(L,{label:`Extra prompt`,source:Ct,children:[Tt,At]}),t[192]=Ct,t[193]=Tt,t[194]=At,t[195]=jt):jt=t[195];let Mt;t[196]!==ye||t[197]!==Ae||t[198]!==Fe||t[199]!==Ue||t[200]!==tt||t[201]!==pt||t[202]!==St||t[203]!==jt?(Mt=(0,O.jsx)(`table`,{className:`mt-2.5 w-full border-collapse`,children:(0,O.jsxs)(`tbody`,{children:[ye,Ae,Fe,Ue,tt,pt,St,jt]})}),t[196]=ye,t[197]=Ae,t[198]=Fe,t[199]=Ue,t[200]=tt,t[201]=pt,t[202]=St,t[203]=jt,t[204]=Mt):Mt=t[204];let Nt;t[205]===j?Nt=t[206]:(Nt=j&&(0,O.jsx)(`div`,{className:`mt-3 rounded-lg border border-warn-edge bg-warn-bg px-3 py-2 text-[12.5px] text-warn`,children:j}),t[205]=j,t[206]=Nt);let Pt;t[207]===A?Pt=t[208]:(Pt=A&&(0,O.jsx)(`div`,{className:`mt-3 rounded-lg border border-bad-edge bg-bad-bg px-3 py-2 text-[12.5px] text-bad`,children:A}),t[207]=A,t[208]=Pt);let Ft=!K||k,It;t[209]===q?It=t[210]:(It=()=>void q(),t[209]=q,t[210]=It);let Lt=k?`Saving…`:`Save fix settings`,Rt;t[211]!==Ft||t[212]!==It||t[213]!==Lt?(Rt=(0,O.jsx)(`button`,{type:`button`,disabled:Ft,onClick:It,className:`rounded-lg bg-ink px-4 py-1.5 text-[13px] font-semibold text-white disabled:opacity-45`,children:Lt}),t[211]=Ft,t[212]=It,t[213]=Lt,t[214]=Rt):Rt=t[214];let zt;t[215]===K?zt=t[216]:(zt=K&&(0,O.jsx)(`span`,{className:`text-[12.5px] text-warn`,children:`unsaved changes`}),t[215]=K,t[216]=zt);let Bt;t[217]!==k||t[218]!==K||t[219]!==q||t[220]!==r.overridden?(Bt=r.overridden&&!K&&(0,O.jsx)(`button`,{type:`button`,disabled:k,onClick:()=>void q(!0),className:`ml-auto text-[12.5px] text-acc hover:underline disabled:opacity-45`,children:`Reset to the fleet default`}),t[217]=k,t[218]=K,t[219]=q,t[220]=r.overridden,t[221]=Bt):Bt=t[221];let Vt;t[222]!==Rt||t[223]!==zt||t[224]!==Bt?(Vt=(0,O.jsxs)(`div`,{className:`mt-3.5 flex flex-wrap items-center gap-2.5`,children:[Rt,zt,Bt]}),t[222]=Rt,t[223]=zt,t[224]=Bt,t[225]=Vt):Vt=t[225];let Ht;t[226]===Symbol.for(`react.memo_cache_sentinel`)?(Ht=(0,O.jsx)(`p`,{className:`mt-2.5 text-[11.5px] text-faint`,children:`These reach the session through its environment, not its arguments — the watcher's argv is fixed when it starts, and one watcher handles every repository. A session script from an install that predates this ignores them, so reinstalling autofix is what turns them on.`}),t[226]=Ht):Ht=t[226];let Ut;t[227]!==Vt||t[228]!==de||t[229]!==Z||t[230]!==fe||t[231]!==Mt||t[232]!==Nt||t[233]!==Pt?(Ut=(0,O.jsxs)(`div`,{className:`px-[18px] pb-4 pt-1`,children:[de,Z,fe,Mt,Nt,Pt,Vt,Ht]}),t[227]=Vt,t[228]=de,t[229]=Z,t[230]=fe,t[231]=Mt,t[232]=Nt,t[233]=Pt,t[234]=Ut):Ut=t[234];let Wt;return t[235]!==Ut||t[236]!==ue?(Wt=(0,O.jsx)(o,{title:`Fix sessions`,end:ue,children:Ut}),t[235]=Ut,t[236]=ue,t[237]=Wt):Wt=t[237],Wt}function P(e){return!e}function ne(e){return(0,O.jsx)(`option`,{value:e,children:e},e)}function re(e){return(0,O.jsxs)(`span`,{title:e.stale?`this host's last report is stale`:e.has?e.path:`not on the PATH that host's service runs with`,className:`rounded-full border px-2 py-0.5 ${e.stale?`border-warn-edge bg-warn-bg text-warn`:e.has===void 0?`border-edge text-faint`:e.has?`border-ok-edge bg-ok-bg text-ok`:`border-bad-edge bg-bad-bg text-bad`}`,children:[e.host,` `,e.stale?`· stale`:e.has===void 0?`· unknown`:e.has?`✓`:`missing`]},e.host)}function F(e,t){let n={},r=e.model??``;if(t.model!==r)if(t.model===``)n.models=[];else{let r=[t.model,...e.models.slice(1)].filter(Boolean);n.models=r.filter((e,t)=>r.indexOf(e)===t)}t.effort!==(e.effort??``)&&(n.effort=t.effort),t.prompt!==(e.prompt??``)&&(n.prompt=t.prompt),t.attempts!==String(e.max_attempts)&&(n.max_attempts=Number(t.attempts)||0),p(t.severities,e.severities)||(n.severities=t.severities),t.askMode!==e.ask_mode&&(n.ask_mode=t.askMode),t.forks!==e.forks&&(n.forks=t.forks);let i=(e.skip_authors??[]).join(`, `);return t.authors!==i&&(n.skip_authors=t.authors.split(`,`).map(e=>e.trim()).filter(Boolean)),n}function I(e){let t=(0,E.c)(3),{shown:n,busy:r,onClick:i}=e;if(!n)return null;let a;return t[0]!==r||t[1]!==i?(a=(0,O.jsx)(`button`,{type:`button`,disabled:r,onClick:i,className:`ml-2 text-[11.5px] text-acc hover:underline disabled:opacity-45`,children:`Use fleet default`}),t[0]=r,t[1]=i,t[2]=a):a=t[2],a}function L(e){let n=(0,E.c)(13),{label:r,source:i,children:a}=e,o;n[0]===r?o=n[1]:(o=(0,O.jsx)(`div`,{className:`text-[13px] font-[550]`,children:r}),n[0]=r,n[1]=o);let s=i===`repo`?`ok`:i===`fleet`?`acc`:`mut`,c;n[2]!==i||n[3]!==s?(c=(0,O.jsx)(t,{tone:s,children:i}),n[2]=i,n[3]=s,n[4]=c):c=n[4];let l;n[5]!==o||n[6]!==c?(l=(0,O.jsxs)(`td`,{className:`w-32 py-2 pr-3 align-top`,children:[o,c]}),n[5]=o,n[6]=c,n[7]=l):l=n[7];let u;n[8]===a?u=n[9]:(u=(0,O.jsx)(`td`,{className:`py-2 align-middle`,children:a}),n[8]=a,n[9]=u);let ee;return n[10]!==l||n[11]!==u?(ee=(0,O.jsxs)(`tr`,{className:`border-b border-[#EEF0F3] last:border-none`,children:[l,u]}),n[10]=l,n[11]=u,n[12]=ee):ee=n[12],ee}var R={state:{tone:`ok`,label:`Reviewed`,note:`recorded here, so every host agrees`},env:{tone:`acc`,label:`Reviewed`,note:`listed in CRQ_REPOS on this host`},scope:{tone:`ok`,label:`Reviewed`,note:`its owner is in CRQ_SCOPE and there is no allow-list`},excluded:{tone:`mut`,label:`Excluded`,note:`CRQ_EXCLUDE on this host, or the gate repo — a kill switch state cannot override`},off:{tone:`mut`,label:`Not reviewed`,note:`no record, and this host's allow-list omits it`}};function z(n){let r=(0,E.c)(25),{repos:a,bots:s,held:c,onSnapshot:l}=n,u=S(5e3),[ee,d]=(0,D.useState)(null),[f,p]=(0,D.useState)(!1),m;r[0]!==ee||r[1]!==a?(m=a.find(e=>e.repo===ee)??a[0],r[0]=ee,r[1]=a,r[2]=m):m=r[2];let h=m,g;r[3]===Symbol.for(`react.memo_cache_sentinel`)?(g=(0,O.jsx)(`button`,{type:`button`,onClick:()=>p(!0),className:`rounded-lg bg-ink px-2.5 py-1 text-[12px] font-semibold text-white`,children:`Add repository`}),r[3]=g):g=r[3];let _;r[4]!==a||r[5]!==h?.repo?(_=a.length===0?(0,O.jsx)(e,{children:`No repository has been seen yet.`}):(0,O.jsx)(`ul`,{children:a.map(e=>{let n=R[e.enrollment]??R.off,r=e.enrollment===`state`&&!e.reviewed?`Turned off`:n.label,a=e.enrollment===`state`&&!e.reviewed?`mut`:n.tone,o=h?.repo===e.repo;return(0,O.jsx)(`li`,{className:`border-t border-[#EEF0F3]`,children:(0,O.jsxs)(`button`,{type:`button`,onClick:()=>d(e.repo),title:n.note,className:`w-full px-4 py-2.5 text-left ${o?`border-l-[3px] border-acc bg-acc-bg pl-[13px]`:`hover:bg-[#F7F8FA]`}`,children:[(0,O.jsxs)(`div`,{className:`flex items-center gap-2 text-[13.5px] font-[550]`,children:[(0,O.jsx)(i,{repo:e.repo}),K(e.repo),(0,O.jsx)(`span`,{className:`ml-auto`,children:(0,O.jsx)(t,{tone:a,children:r})})]}),(0,O.jsxs)(`div`,{className:`mt-0.5 ml-6 text-xs text-faint`,children:[e.override?`override`:`fleet default`,e.active_rounds>0&&` · ${e.active_rounds} active`,e.held_prs>0&&` · ${e.held_prs} held`,e.fixing>0&&` · ${e.fixing} fixing`]})]})},e.repo)})}),r[4]=a,r[5]=h?.repo,r[6]=_):_=r[6];let v;r[7]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,O.jsx)(`p`,{className:`border-t border-[#EEF0F3] px-4 py-2.5 text-xs text-faint`,children:`A record in shared state wins over a host's CRQ_REPOS in both directions; CRQ_EXCLUDE wins over everything, because it is a per-host kill switch.`}),r[7]=v):v=r[7];let y;r[8]!==a.length||r[9]!==_?(y=(0,O.jsx)(`div`,{children:(0,O.jsxs)(o,{title:`Repositories`,count:a.length,end:g,children:[_,v]})}),r[8]=a.length,r[9]=_,r[10]=y):y=r[10];let b;r[11]!==s||r[12]!==c||r[13]!==u||r[14]!==l||r[15]!==h?(b=h&&(0,O.jsxs)(`div`,{children:[(0,O.jsxs)(`div`,{className:`mb-3.5 flex flex-wrap items-center gap-3.5 rounded-[10px] border border-edge bg-card px-5 py-4 shadow-card`,children:[(0,O.jsx)(i,{repo:h.repo,size:26}),(0,O.jsx)(`h1`,{className:`font-mono text-[18px] font-[650] tracking-tight`,children:h.repo}),(0,O.jsx)(t,{tone:h.reviewed?(R[h.enrollment]??R.off).tone:`mut`,children:h.reviewed?(R[h.enrollment]??R.off).label:h.enrollment===`state`?`Turned off`:(R[h.enrollment]??R.off).label}),(0,O.jsx)(`span`,{className:`text-[12px] text-faint`,children:(R[h.enrollment]??R.off).note}),h.override&&(0,O.jsx)(t,{tone:`warn`,children:`Override`}),h.primary_off&&(0,O.jsx)(t,{tone:`bad`,children:`Primary off`}),h.solver?.overridden&&(0,O.jsx)(t,{tone:`warn`,children:`Fix settings`}),(0,O.jsxs)(`span`,{className:`text-[12.5px] text-faint`,children:[h.active_rounds,` active · `,h.queued_rounds,` queued`,h.override_by&&` · set by ${h.override_by}`,h.override_at&&` ${x(h.override_at,u)}`]}),(0,O.jsx)(`a`,{href:`https://github.com/${h.repo}`,target:`_blank`,rel:`noreferrer`,className:`ml-auto text-[12.5px] text-acc hover:underline`,children:`Open on GitHub ↗`})]}),(0,O.jsx)(M,{repo:h.repo,source:h.enrollment,reviewed:h.reviewed,envConflict:h.env_conflict,clearEnables:h.clear_enables,reason:h.enroll_reason,by:h.enroll_by,active:h.active_rounds,onSnapshot:l},`${h.repo}-enroll`),(0,O.jsx)(B,{repo:h.repo,held:c.filter(e=>e.repo.toLowerCase()===h.repo.toLowerCase()),elsewhere:c.filter(e=>e.repo.toLowerCase()!==h.repo.toLowerCase()).length,now:u,onSnapshot:l},`${h.repo}-held`),(0,O.jsx)(H,{repo:h,bots:s,onSnapshot:l},h.repo),(0,O.jsx)(se,{repo:h,now:u,onSnapshot:l},`${h.repo}-autofix`),h.solver&&(0,O.jsx)(N,{repo:h.repo,solver:h.solver,onSnapshot:l},`${h.repo}-solver`)]}),r[11]=s,r[12]=c,r[13]=u,r[14]=l,r[15]=h,r[16]=b):b=r[16];let te;r[17]===Symbol.for(`react.memo_cache_sentinel`)?(te=()=>p(!1),r[17]=te):te=r[17];let C;r[18]!==f||r[19]!==l?(C=(0,O.jsx)(j,{open:f,onClose:te,onSnapshot:l}),r[18]=f,r[19]=l,r[20]=C):C=r[20];let w;return r[21]!==y||r[22]!==b||r[23]!==C?(w=(0,O.jsxs)(`main`,{className:`mx-auto grid max-w-[1400px] grid-cols-[320px_minmax(0,1fr)] items-start gap-4.5 px-6 pt-4.5 pb-16 max-[1400px]:grid-cols-[minmax(0,1fr)]`,children:[y,b,C]}),r[21]=y,r[22]=b,r[23]=C,r[24]=w):w=r[24],w}function B(e){let t=(0,E.c)(25),{repo:n,held:r,elsewhere:i,now:s,onSnapshot:c}=e,[l,u]=(0,D.useState)(!1),[ee,d]=(0,D.useState)(``),[f,p]=(0,D.useState)(``),{run:h,running:g,error:_}=m(),v;t[0]!==c||t[1]!==n||t[2]!==h?(v=(e,t,r)=>h(te(e,{repo:n,pr:t,reason:r===void 0?``:r}),{onSuccess:e=>{let{snapshot:t}=e;c?.(t),u(!1),d(``),p(``)}}),t[0]=c,t[1]=n,t[2]=h,t[3]=v):v=t[3];let y=v,b=i>0?`${i} held elsewhere`:void 0,S;t[4]!==g||t[5]!==r||t[6]!==s||t[7]!==y?(S=r.length===0?(0,O.jsx)(`p`,{className:`text-[12.5px] text-faint`,children:`Nothing is held here. A hold stops crq enqueuing or firing for one pull request until somebody lifts it; reviews already in flight finish.`}):(0,O.jsx)(`ul`,{className:`text-[13px]`,children:r.map(e=>(0,O.jsxs)(`li`,{className:`flex items-start gap-2 border-b border-[#EEF0F3] py-1.5 last:border-none`,children:[(0,O.jsxs)(`span`,{className:`min-w-0`,children:[(0,O.jsx)(a,{repo:e.repo,pr:e.pr}),e.title&&(0,O.jsx)(`span`,{className:`ml-2 text-[12.5px] text-mut`,children:e.title}),(0,O.jsxs)(`div`,{className:`text-[12.5px] text-faint`,children:[`“`,e.reason,`”`,e.by&&` — ${e.by}`,` `,e.at&&x(e.at,s)]})]}),(0,O.jsx)(`button`,{type:`button`,disabled:g,onClick:()=>void y(`unhold`,e.pr),className:`ml-auto shrink-0 rounded-lg border border-edge px-2.5 py-0.5 text-[12px] font-semibold text-mut disabled:opacity-45`,children:`Unhold`})]},e.key))}),t[4]=g,t[5]=r,t[6]=s,t[7]=y,t[8]=S):S=t[8];let C;t[9]===_?C=t[10]:(C=_&&(0,O.jsx)(`div`,{className:`mt-2.5 rounded-lg border border-bad-edge bg-bad-bg px-3 py-2 text-[12.5px] text-bad`,children:_}),t[9]=_,t[10]=C);let w;t[11]!==l||t[12]!==g||t[13]!==ee||t[14]!==f||t[15]!==y?(w=!1,t[11]=l,t[12]=g,t[13]=ee,t[14]=f,t[15]=y,t[16]=w):w=t[16];let T;t[17]!==S||t[18]!==C||t[19]!==w?(T=(0,O.jsxs)(`div`,{className:`px-[18px] pb-3.5 pt-1`,children:[S,C,w]}),t[17]=S,t[18]=C,t[19]=w,t[20]=T):T=t[20];let k;return t[21]!==r.length||t[22]!==b||t[23]!==T?(k=(0,O.jsx)(o,{title:`Held pull requests`,count:r.length,end:b,children:T}),t[21]=r.length,t[22]=b,t[23]=T,t[24]=k):k=t[24],k}function V(e){return e.status===`working`?0:e.status===`silent`?2:1}function H(e){let i=(0,E.c)(145),{repo:a,bots:l,onSnapshot:u}=e,[d,f]=(0,D.useState)(a.reviewers),[h,g]=(0,D.useState)(a.required),[_,v]=(0,D.useState)(null),[y,b]=(0,D.useState)(null),{run:x,running:S,error:C,clearError:T}=m(),[k,A]=(0,D.useState)(null),j;i[0]===a.reviewers?j=i[1]:(j=[...a.reviewers].sort().join(`\0`),i[0]=a.reviewers,i[1]=j);let M=j,N;i[2]===a.required?N=i[3]:(N=[...a.required].sort().join(`\0`),i[2]=a.required,i[3]=N);let P=N,ne,re;i[4]!==P||i[5]!==M?(ne=()=>{f(M?M.split(`\0`):[]),g(P?P.split(`\0`):[]),v(null),b(null)},re=[M,P],i[4]=P,i[5]=M,i[6]=ne,i[7]=re):(ne=i[6],re=i[7]),(0,D.useEffect)(ne,re);let F,I,L;i[8]!==l||i[9]!==a.reviewers||i[10]!==d?(F=l.find(oe),I=F?d.includes(F.name):!1,L=F?a.reviewers.includes(F.name):!1,i[8]=l,i[9]=a.reviewers,i[10]=d,i[11]=F,i[12]=I,i[13]=L):(F=i[11],I=i[12],L=i[13]);let R=L,z;i[14]!==a.required||i[15]!==a.reviewers||i[16]!==h||i[17]!==d?(z=!p(d,a.reviewers)||!p(h,a.required),i[14]=a.required,i[15]=a.reviewers,i[16]=h,i[17]=d,i[18]=z):z=i[18];let B=z,V,H,se,K,ce,q;if(i[19]!==l||i[20]!==S||i[21]!==T||i[22]!==B||i[23]!==C||i[24]!==u||i[25]!==F||i[26]!==I||i[27]!==R||i[28]!==a.override||i[29]!==a.repo||i[30]!==a.required||i[31]!==a.reviewers||i[32]!==h||i[33]!==x||i[34]!==d||i[35]!==_||i[36]!==k){let e;i[43]!==F?.name||i[44]!==a.reviewers?(e=e=>!a.reviewers.includes(e)&&e!==F?.name,i[43]=F?.name,i[44]=a.reviewers,i[45]=e):e=i[45];let p=d.filter(e),m;i[46]===l?m=i[47]:(m=new Set(l.filter(ae).map(G)),i[46]=l,i[47]=m);let y=m,E;if(i[48]!==y||i[49]!==F?.name||i[50]!==h||i[51]!==d){let e;i[53]!==y||i[54]!==F?.name||i[55]!==h?(e=e=>e!==F?.name&&!y.has(e)&&!h.includes(e),i[53]=y,i[54]=F?.name,i[55]=h,i[56]=e):e=i[56],E=new Set(d.filter(e)),i[48]=y,i[49]=F?.name,i[50]=h,i[51]=d,i[52]=E}else E=i[52];let D;i[57]===E?D=i[58]:(D=[...E],i[57]=E,i[58]=D);let j=D,M;i[59]===d?M=i[60]:(M=e=>{f(t=>t.includes(e)?t.filter(t=>t!==e):[...t,e]),g(t=>d.includes(e)?t.filter(t=>t!==e):t)},i[59]=d,i[60]=M);let N=M,P;i[61]===Symbol.for(`react.memo_cache_sentinel`)?(P=e=>{g(t=>t.includes(e)?t.filter(t=>t!==e):[...t,e]),f(t=>t.includes(e)?t:[...t,e])},i[61]=P):P=i[61];let ne=P,re;i[62]!==y||i[63]!==u||i[64]!==F||i[65]!==I||i[66]!==a.repo||i[67]!==h||i[68]!==x||i[69]!==d?(re=(e,t)=>{let n=e!==void 0&&e;x(te(`reviewers`,{repo:a.repo,cobots:d.filter(e=>e!==F?.name&&y.has(e)),required:h,primary:F?I:void 0,clear:n,expected_rev:t}),{onSuccess:e=>{let{snapshot:t,warning:n}=e;u?.(t),A(n??null),v(null),b(null)}})},i[62]=y,i[63]=u,i[64]=F,i[65]=I,i[66]=a.repo,i[67]=h,i[68]=x,i[69]=d,i[70]=re):re=i[70],H=re;let L;i[71]!==a.repo||i[72]!==x?(L=()=>x(te(`reviewers`,{repo:a.repo,clear:!0,preview:!0}),{onSuccess:e=>b(e)}),i[71]=a.repo,i[72]=x,i[73]=L):L=i[73];let z=L,U;i[74]!==y||i[75]!==F||i[76]!==I||i[77]!==a.repo||i[78]!==h||i[79]!==x||i[80]!==d?(U=()=>x(te(`reviewers`,{repo:a.repo,cobots:d.filter(e=>e!==F?.name&&y.has(e)),required:h,primary:F?I:void 0,preview:!0}),{onSuccess:e=>v(e)}),i[74]=y,i[75]=F,i[76]=I,i[77]=a.repo,i[78]=h,i[79]=x,i[80]=d,i[81]=U):U=i[81];let oe=U;V=o,K=`Reviewers`,ce=a.override?`override`:`inherited from the fleet`;let J;i[82]===Symbol.for(`react.memo_cache_sentinel`)?(J=(0,O.jsx)(`b`,{children:`Runs`}),i[82]=J):J=i[82];let Y;i[83]===Symbol.for(`react.memo_cache_sentinel`)?(Y=(0,O.jsxs)(`p`,{className:`mb-2.5 text-[12.5px] text-faint`,children:[J,` — the bot reviews this repo. `,(0,O.jsx)(`b`,{children:`Required`}),` — convergence waits for it. Requiring a bot turns Runs on; the required set cannot be empty. Turning the primary off means this repo never spends the shared review allowance. A bot crq has never seen work here is marked — enabling one is allowed, since a bot cannot prove itself until it is asked, but an unset-up one just collects trigger comments.`]}),i[83]=Y):Y=i[83];let le;i[84]===Symbol.for(`react.memo_cache_sentinel`)?(le=(0,O.jsx)(`thead`,{children:(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(n,{children:`Reviewer`}),(0,O.jsx)(n,{className:`w-20 text-center`,children:`Runs`}),(0,O.jsx)(n,{className:`w-24 text-center`,children:`Required`}),(0,O.jsx)(n,{className:`c-host`,children:`Role`})]})}),i[84]=le):le=i[84];let X;if(i[85]!==l||i[86]!==I||i[87]!==a.repo||i[88]!==h||i[89]!==d||i[90]!==N){let e;i[92]!==I||i[93]!==a.repo||i[94]!==h||i[95]!==d||i[96]!==N?(e=e=>(0,O.jsxs)(`tr`,{className:e.status===`working`?``:`opacity-75`,children:[(0,O.jsx)(c,{children:(0,O.jsxs)(`span`,{className:`flex items-center gap-2.5`,children:[(0,O.jsx)(s,{login:e.login,name:e.name,size:20}),(0,O.jsx)(`span`,{className:`font-[550]`,children:e.name}),e.status!==`working`&&(0,O.jsx)(w,{to:`/bots`,title:e.status===`silent`?`crq has asked it and never seen an answer — most likely not set up`:`crq has never seen this bot work here`,children:(0,O.jsx)(t,{tone:e.status===`silent`?`bad`:`mut`,children:e.status===`silent`?`never answered`:`not set up?`})})]})}),(0,O.jsx)(c,{className:`text-center`,children:(0,O.jsx)(r,{on:d.includes(e.name),label:`${e.name} runs for ${a.repo}`,locked:!e.primary&&!e.configurable,title:e.primary?`the metered reviewer — turning it off spends no quota here`:e.configurable?void 0:`this retired reviewer is shown for history and cannot be configured`,onClick:()=>N(e.name)})}),(0,O.jsx)(c,{className:`text-center`,children:(0,O.jsx)(r,{on:h.includes(e.name),label:`${e.name} required for ${a.repo}`,locked:!e.primary&&!e.configurable,title:!e.primary&&!e.configurable?`this retired reviewer is shown for history and cannot be configured`:void 0,onClick:()=>ne(e.name)})}),(0,O.jsx)(c,{className:`c-host text-[12.5px] text-faint`,children:e.primary?I?`primary · metered against the shared allowance`:`primary · turned off here`:`co-reviewer`})]},e.login),i[92]=I,i[93]=a.repo,i[94]=h,i[95]=d,i[96]=N,i[97]=e):e=i[97],X=[...l].sort(ie).map(e),i[85]=l,i[86]=I,i[87]=a.repo,i[88]=h,i[89]=d,i[90]=N,i[91]=X}else X=i[91];let ue;i[98]===X?ue=i[99]:(ue=(0,O.jsxs)(`table`,{className:`w-full border-collapse`,children:[le,(0,O.jsx)(`tbody`,{children:X})]}),i[98]=X,i[99]=ue);let de;i[100]===k?de=i[101]:(de=k&&(0,O.jsx)(`div`,{className:`mt-3 rounded-lg border border-warn-edge bg-warn-bg px-3 py-2 text-[12.5px] text-warn`,children:k}),i[100]=k,i[101]=de);let Z;i[102]!==C||i[103]!==_?(Z=C&&!_&&(0,O.jsx)(`div`,{className:`mt-3 rounded-lg border border-bad-edge bg-bad-bg px-3 py-2 text-[12.5px] text-bad`,children:C}),i[102]=C,i[103]=_,i[104]=Z):Z=i[104];let fe=!B||S,pe;i[105]!==oe||i[106]!==fe?(pe=(0,O.jsx)(`button`,{type:`button`,disabled:fe,onClick:oe,className:`rounded-lg bg-ink px-4 py-1.5 text-[13px] font-semibold text-white disabled:opacity-45`,children:`Save reviewers`}),i[105]=oe,i[106]=fe,i[107]=pe):pe=i[107];let me=!B||S,Q;i[108]!==a.required||i[109]!==a.reviewers?(Q=()=>{f(a.reviewers),g(a.required),v(null)},i[108]=a.required,i[109]=a.reviewers,i[110]=Q):Q=i[110];let he;i[111]!==me||i[112]!==Q?(he=(0,O.jsx)(`button`,{type:`button`,disabled:me,onClick:Q,className:`rounded-lg border border-edge px-4 py-1.5 text-[13px] font-semibold text-mut disabled:opacity-45`,children:`Discard`}),i[111]=me,i[112]=Q,i[113]=he):he=i[113];let ge;i[114]===B?ge=i[115]:(ge=B&&(0,O.jsx)(`span`,{className:`text-[12.5px] text-warn`,children:`unsaved changes`}),i[114]=B,i[115]=ge);let _e;i[116]!==S||i[117]!==B||i[118]!==z||i[119]!==a.override?(_e=a.override&&!B&&(0,O.jsx)(`button`,{type:`button`,disabled:S,onClick:z,className:`ml-auto text-[12.5px] text-acc hover:underline disabled:opacity-45`,children:`Reset to fleet default`}),i[116]=S,i[117]=B,i[118]=z,i[119]=a.override,i[120]=_e):_e=i[120];let $;i[121]!==pe||i[122]!==he||i[123]!==ge||i[124]!==_e?($=(0,O.jsxs)(`div`,{className:`mt-3.5 flex flex-wrap items-center gap-2.5`,children:[pe,he,ge,_e]}),i[121]=pe,i[122]=he,i[123]=ge,i[124]=_e,i[125]=$):$=i[125],i[126]!==ue||i[127]!==de||i[128]!==Z||i[129]!==$?(q=(0,O.jsxs)(`div`,{className:`px-[18px] pb-4`,children:[Y,ue,de,Z,$]}),i[126]=ue,i[127]=de,i[128]=Z,i[129]=$,i[130]=q):q=i[130],se=_&&(0,O.jsx)(ee,{title:`Save reviewers for ${a.repo.split(`/`).pop()}?`,danger:_.reopened>0,body:(0,O.jsxs)(O.Fragment,{children:[(0,O.jsx)(`ul`,{className:`mb-2 list-disc pl-4`,children:_.changes.map(W)}),_.summary,`. This repository will stop following the fleet default and keep its own list.`,F&&I!==R&&(0,O.jsxs)(O.Fragment,{children:[` `,(0,O.jsxs)(`b`,{children:[F.name,` will `,I?`review`:`no longer review`,` this repository.`]}),` `,I?`Its rounds go back to waiting for the fire slot and the account quota.`:`Its rounds stop taking the fire slot and stop waiting on the account quota — the co-reviewers resolve them alone.`]}),p.length>0&&(0,O.jsxs)(O.Fragment,{children:[` `,(0,O.jsx)(`b`,{children:p.join(`, `)}),` `,p.length===1?`is`:`are`,` newly enabled and may be triggered on current open heads as soon as this is saved.`]}),j.length>0&&(0,O.jsxs)(O.Fragment,{children:[` `,(0,O.jsx)(`b`,{children:j.join(`, `)}),` `,j.length===1?`is`:`are`,` retired and will be removed from this repository's saved reviewer and required sets.`]}),_.reopened>0&&(0,O.jsx)(`p`,{className:`mt-2 text-warn`,children:`Reopened rounds are reviewed again, and metered reviews spend the shared allowance.`})]}),confirmLabel:_.reopened>0?`Save and reopen ${_.reopened}`:`Save`,busy:S,error:C,onConfirm:()=>H(!1,_.rev),onCancel:()=>{v(null),T()}}),i[19]=l,i[20]=S,i[21]=T,i[22]=B,i[23]=C,i[24]=u,i[25]=F,i[26]=I,i[27]=R,i[28]=a.override,i[29]=a.repo,i[30]=a.required,i[31]=a.reviewers,i[32]=h,i[33]=x,i[34]=d,i[35]=_,i[36]=k,i[37]=V,i[38]=H,i[39]=se,i[40]=K,i[41]=ce,i[42]=q}else V=i[37],H=i[38],se=i[39],K=i[40],ce=i[41],q=i[42];let J;i[131]!==S||i[132]!==T||i[133]!==C||i[134]!==a.repo||i[135]!==y||i[136]!==H?(J=y&&(0,O.jsx)(ee,{title:`Reset reviewers for ${a.repo.split(`/`).pop()}?`,danger:y.reopened>0,confirmLabel:y.reopened>0?`Reset and reopen ${y.reopened}`:`Reset to fleet default`,busy:S,error:C,body:(0,O.jsxs)(O.Fragment,{children:[(0,O.jsx)(`ul`,{className:`mb-2 list-disc pl-4`,children:y.changes.map(U)}),y.summary,y.reopened>0&&(0,O.jsx)(`p`,{className:`mt-2 text-warn`,children:`Reopened rounds are reviewed again, and metered reviews spend the shared allowance.`})]}),onConfirm:()=>H(!0,y.rev),onCancel:()=>{b(null),T()}}),i[131]=S,i[132]=T,i[133]=C,i[134]=a.repo,i[135]=y,i[136]=H,i[137]=J):J=i[137];let Y;return i[138]!==V||i[139]!==se||i[140]!==J||i[141]!==K||i[142]!==ce||i[143]!==q?(Y=(0,O.jsxs)(V,{title:K,end:ce,children:[q,se,J]}),i[138]=V,i[139]=se,i[140]=J,i[141]=K,i[142]=ce,i[143]=q,i[144]=Y):Y=i[144],Y}function U(e){return(0,O.jsx)(`li`,{children:e},e)}function W(e){return(0,O.jsx)(`li`,{children:e},e)}function ie(e,t){return V(e)-V(t)}function G(e){return e.name}function ae(e){return e.configurable}function oe(e){return e.primary}function se(e){let t=(0,E.c)(36),{repo:n,now:r,onSnapshot:i}=e,{run:a,running:s,error:c,clearError:l}=m(),[u,d]=(0,D.useState)(!1),f;t[0]!==i||t[1]!==n.repo||t[2]!==a?(f=(e,t)=>{let r=t===void 0?``:t;return a(te(`autofix`,{repo:n.repo,enabled:e,reason:r}),{onSuccess:e=>{let{snapshot:t}=e;i?.(t),d(!1)}})},t[0]=i,t[1]=n.repo,t[2]=a,t[3]=f):f=t[3];let p=f,h=n.autofix,g=h==="default"?`fleet default`:`override`,_;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,O.jsx)(`p`,{className:`mb-2.5 text-[12.5px] text-faint`,children:`Permission only — whether an agent exists is a per-host question, on the Setup page.`}),t[4]=_):_=t[4];let v;t[5]===Symbol.for(`react.memo_cache_sentinel`)?(v=[`off`,`on`,`default`],t[5]=v):v=t[5];let y;t[6]!==p||t[7]!==s||t[8]!==h?(y=(0,O.jsx)(`span`,{className:`inline-flex overflow-hidden rounded-lg border border-edge`,children:v.map(e=>(0,O.jsx)(`button`,{type:`button`,disabled:s,onClick:()=>e===`off`?d(!0):p(e===`on`||void 0),className:`border-r border-edge px-3 py-1 text-[12.5px] last:border-r-0 ${h===e?`bg-ok-bg font-medium text-ok`:`text-mut hover:bg-bg`}`,children:e==="default"?`Fleet default`:e},e))}),t[6]=p,t[7]=s,t[8]=h,t[9]=y):y=t[9];let b;t[10]===n.autofix_reason?b=t[11]:(b=n.autofix_reason&&(0,O.jsxs)(`span`,{className:`text-[13px] text-faint`,children:[`“`,n.autofix_reason,`”`]}),t[10]=n.autofix_reason,t[11]=b);let S;t[12]!==r||t[13]!==n.autofix_at||t[14]!==n.autofix_by?(S=n.autofix_by&&(0,O.jsxs)(`span`,{className:`text-[12.5px] text-faint`,children:[`set by `,n.autofix_by,` `,x(n.autofix_at,r)]}),t[12]=r,t[13]=n.autofix_at,t[14]=n.autofix_by,t[15]=S):S=t[15];let C;t[16]!==y||t[17]!==b||t[18]!==S?(C=(0,O.jsxs)(`div`,{className:`flex flex-wrap items-center gap-3`,children:[y,b,S]}),t[16]=y,t[17]=b,t[18]=S,t[19]=C):C=t[19];let w;t[20]===c?w=t[21]:(w=c&&(0,O.jsx)(`div`,{className:`mt-3 rounded-lg border border-bad-edge bg-bad-bg px-3 py-2 text-[12.5px] text-bad`,children:c}),t[20]=c,t[21]=w);let T;t[22]!==C||t[23]!==w?(T=(0,O.jsxs)(`div`,{className:`px-[18px] pb-4`,children:[_,C,w]}),t[22]=C,t[23]=w,t[24]=T):T=t[24];let k;t[25]!==p||t[26]!==s||t[27]!==l||t[28]!==c||t[29]!==u||t[30]!==n.repo?(k=u&&(0,O.jsx)(ee,{title:`Turn autofix off for ${n.repo.split(`/`).pop()}?`,body:(0,O.jsx)(O.Fragment,{children:`crq will keep reviewing this repository but stop writing fixes to it. Any session already running finishes.`}),confirmLabel:`Turn it off`,needsReason:!0,reasonLabel:`Why (shown wherever this switch appears)`,busy:s,error:c,onConfirm:e=>p(!1,e),onCancel:()=>{d(!1),l()}}),t[25]=p,t[26]=s,t[27]=l,t[28]=c,t[29]=u,t[30]=n.repo,t[31]=k):k=t[31];let A;return t[32]!==T||t[33]!==k||t[34]!==g?(A=(0,O.jsxs)(o,{title:`Autofix`,end:g,children:[T,k]}),t[32]=T,t[33]=k,t[34]=g,t[35]=A):A=t[35],A}function K(e){return e.split(`/`).pop()??e}function ce(){let e=(0,E.c)(6),{snapshot:t,setSnapshot:n}=_();if(!t){let t;return e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,O.jsx)(b,{}),e[0]=t):t=e[0],t}let r;return e[1]!==n||e[2]!==t.bots||e[3]!==t.overview.held||e[4]!==t.repos?(r=(0,O.jsx)(z,{repos:t.repos,bots:t.bots,held:t.overview.held,onSnapshot:n}),e[1]=n,e[2]=t.bots,e[3]=t.overview.held,e[4]=t.repos,e[5]=r):r=e[5],r}export{ce as ReposRoute}; \ No newline at end of file diff --git a/internal/serve/dist/assets/SettingsRoute-zk9_WV54.js b/internal/serve/dist/assets/SettingsRoute-zk9_WV54.js new file mode 100644 index 00000000..3985361d --- /dev/null +++ b/internal/serve/dist/assets/SettingsRoute-zk9_WV54.js @@ -0,0 +1 @@ +import{c as e,d as t,f as n,r,t as i,u as a}from"./ui-Bim2uazI.js";import{n as o,s,t as c}from"./useOperation-BqNdl-Dh.js";import{S as l,b as u,c as d,d as f,g as p,i as m,n as h,p as ee,v as g,x as _}from"./index-DlC1aBiV.js";var te=p(),v=l(_(),1),y=u(),b={pacing:{title:`Pacing and limits`,note:`how fast crq spends the shared allowance`},review:{title:`Review`,note:`who reviews, how they are asked, and what is waited for`},autofix:{title:`Autofix`,note:`how fix sessions are found and bounded`},reporting:{title:`Reporting`,note:`how crq presents itself`},identity:{title:`Identity and per-host`,note:`not editable here — these say where the queue lives, or belong to one machine`}},x={fleet:{tone:`ok`,note:`recorded for the whole fleet — every host reads this`},env:{tone:`acc`,note:`this machine's env file; other hosts may have something else`},default:{tone:`mut`,note:`nothing sets it, so crq's built-in default applies`}};function S(t){let n=(0,te.c)(28),{env:i,onSnapshot:a}=t,[s,l]=(0,v.useState)(null),[u,d]=(0,v.useState)(``),[f,p]=(0,v.useState)(null),{run:m,running:h,error:g}=c(),_;n[0]===i?_=n[1]:(_=new Set(i.map(w)),n[0]=i,n[1]=_);let S;n[2]===_?S=n[3]:(S=[..._],n[2]=_,n[3]=S);let ne=S,re;n[4]!==a||n[5]!==m?(re=(e,t,n,r)=>m(ee(`env`,{key:e.key,value:t,clear:n,expected_rev:r}),{onSuccess:e=>{let{snapshot:t}=e;a?.(t),l(null),p(null)}}),n[4]=a,n[5]=m,n[6]=re):re=n[6];let T=re,E;n[7]!==T||n[8]!==m?(E=(e,t,n)=>{let r=n!==void 0&&n;if(!e.review_impact){T(e,t,r);return}m(ee(`env`,{key:e.key,value:t,clear:r,preview:!0}),{onSuccess:n=>p({setting:e,value:t,clear:r,impact:n})})},n[7]=T,n[8]=m,n[9]=E):E=n[9];let D=E,O;n[10]===g?O=n[11]:(O=g&&(0,y.jsx)(`div`,{className:`mt-3 rounded-lg border border-bad-edge bg-bad-bg px-3 py-2 text-[12.5px] text-bad`,children:g}),n[10]=g,n[11]=O);let k;n[12]!==h||n[13]!==u||n[14]!==s||n[15]!==i||n[16]!==ne||n[17]!==D?(k=ne.map(t=>{let n=b[t]??{title:t,note:``},a=i.filter(e=>e.group===t);return(0,y.jsx)(r,{title:n.title,end:n.note,children:(0,y.jsx)(`table`,{className:`w-full border-collapse`,children:(0,y.jsx)(`tbody`,{children:a.map(t=>{let n=x[t.source]??x.default,r=t.per_host||t.identity,i=s===t.key;return(0,y.jsxs)(`tr`,{className:`border-b border-[#EEF0F3] align-top last:border-none`,children:[(0,y.jsxs)(`td`,{className:`w-[230px] py-2 pr-3 pl-[18px]`,children:[(0,y.jsx)(`div`,{className:`text-[13px] font-[550]`,children:t.label}),(0,y.jsx)(`div`,{className:`font-mono text-[11px] text-faint`,children:t.key})]}),(0,y.jsx)(`td`,{className:`py-2 pr-3`,children:i?(0,y.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[t.kind===`bool`?(0,y.jsxs)(`select`,{"aria-label":`${t.key} value`,value:u,onChange:e=>d(e.target.value),className:`rounded-lg border border-edge bg-[#FBFBFC] px-2 py-1 text-[12.5px]`,children:[(0,y.jsx)(`option`,{value:`1`,children:`on`}),(0,y.jsx)(`option`,{value:`0`,children:`off`})]}):(0,y.jsx)(`input`,{"aria-label":`${t.key} value`,value:u,onChange:e=>d(e.target.value),className:`w-64 rounded-lg border border-edge bg-[#FBFBFC] px-2 py-1 font-mono text-[12.5px]`}),(0,y.jsx)(`button`,{type:`button`,disabled:h,onClick:()=>D(t,u),className:`rounded-lg bg-ink px-3 py-1 text-[12.5px] font-semibold text-white disabled:opacity-45`,children:`Save for the fleet`}),(0,y.jsx)(`button`,{type:`button`,disabled:h,onClick:()=>l(null),className:`rounded-lg border border-edge px-3 py-1 text-[12.5px] font-semibold text-mut`,children:`Cancel`})]}):(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(`span`,{className:`font-mono text-[12.5px]`,children:t.value||(0,y.jsx)(`span`,{className:`text-faint`,children:`—`})}),t.host_value&&t.host_value!==t.value&&(0,y.jsxs)(`span`,{className:`ml-2 text-[11.5px] text-faint`,children:[`(this host's env says`,` `,(0,y.jsx)(`span`,{className:`font-mono`,children:t.host_value}),`, overridden)`]}),(0,y.jsx)(`div`,{className:`text-[11.5px] text-faint`,children:t.help})]})}),(0,y.jsxs)(`td`,{className:`w-[150px] py-2 pr-[18px] text-right`,children:[(0,y.jsx)(`span`,{title:r?void 0:n.note,children:(0,y.jsx)(e,{tone:r?`mut`:n.tone,children:t.identity?`identity`:t.per_host?`this host`:t.source})}),!r&&!i&&(0,y.jsxs)(`div`,{className:`mt-1 flex justify-end gap-2 text-[12px]`,children:[(0,y.jsx)(`button`,{type:`button`,onClick:()=>{l(t.key),p(null),d(t.kind===`bool`?[`1`,`true`,`yes`,`on`].includes(t.value.toLowerCase())?`1`:`0`:t.value)},className:`text-acc hover:underline`,children:`Edit`}),t.source===`fleet`&&(0,y.jsx)(`button`,{type:`button`,disabled:h,onClick:()=>D(t,``,!0),className:`text-mut hover:underline disabled:opacity-45`,children:`Unset`})]})]})]},t.key)})})})},t)}),n[12]=h,n[13]=u,n[14]=s,n[15]=i,n[16]=ne,n[17]=D,n[18]=k):k=n[18];let A;n[19]!==h||n[20]!==T||n[21]!==g||n[22]!==f?(A=f&&(0,y.jsx)(o,{title:f.clear?`Unset ${f.setting.label}?`:`Save ${f.setting.label}?`,danger:f.impact.reopened>0,confirmLabel:f.impact.reopened>0?`${f.clear?`Unset`:`Save`} and reopen ${f.impact.reopened}`:f.clear?`Unset`:`Save`,busy:h,error:g,body:(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(`ul`,{className:`mb-2 list-disc pl-4`,children:f.impact.changes.map(C)}),f.impact.summary,f.impact.reopened>0&&(0,y.jsx)(`p`,{className:`mt-2 text-warn`,children:`Reopened rounds are reviewed again, and metered reviews spend the shared allowance.`})]}),onConfirm:()=>T(f.setting,f.value,f.clear,f.impact.rev),onCancel:()=>p(null)}),n[19]=h,n[20]=T,n[21]=g,n[22]=f,n[23]=A):A=n[23];let j;return n[24]!==O||n[25]!==k||n[26]!==A?(j=(0,y.jsxs)(y.Fragment,{children:[O,k,A]}),n[24]=O,n[25]=k,n[26]=A,n[27]=j):j=n[27],j}function C(e){return(0,y.jsx)(`li`,{children:e},e)}function w(e){return e.group}function ne(e){let t=(0,te.c)(119),{fleet:a,bots:l,onSnapshot:u}=e,d;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(d=[],t[0]=d):d=t[0];let[p,m]=(0,v.useState)(d),h;t[1]===Symbol.for(`react.memo_cache_sentinel`)?(h=[],t[1]=h):h=t[1];let[g,_]=(0,v.useState)(h),[b,x]=(0,v.useState)(a.min_interval),[S,C]=(0,v.useState)(String(a.weekly_limit)),[w,ne]=(0,v.useState)(a.autofix_default),[N,ae]=(0,v.useState)(null),[oe,P]=(0,v.useState)(!1),{run:F,running:I,error:L}=c(),R=a.reviewers.filter(A).map(k),z=a.reviewers.filter(O).map(D),se=[...R].sort().join(`\0`),ce=[...z].sort().join(`\0`);(0,v.useEffect)(()=>{m(se===``?[]:se.split(`\0`)),_(ce===``?[]:ce.split(`\0`)),x(a.min_interval),C(String(a.weekly_limit)),ne(a.autofix_default),ae(null),P(!1)},[se,ce,a.min_interval,a.weekly_limit,a.autofix_default]);let B=!s(p,R)||!s(g,z)||b!==a.min_interval||S!==String(a.weekly_limit)||w!==a.autofix_default,V;t[2]!==w||t[3]!==a||t[4]!==z||t[5]!==R||t[6]!==b||t[7]!==g||t[8]!==p||t[9]!==S?(V=()=>j(a,R,z,{runs:p,required:g,minInterval:b,weekly:S,autofix:w}),t[2]=w,t[3]=a,t[4]=z,t[5]=R,t[6]=b,t[7]=g,t[8]=p,t[9]=S,t[10]=V):V=t[10];let H=V,U;t[11]!==H||t[12]!==F?(U=e=>{let t=e!==void 0&&e;return F(f(t?{clear:!0}:H()),{onSuccess:e=>{P(t),ae(e)}})},t[11]=H,t[12]=F,t[13]=U):U=t[13];let W=U,G;t[14]!==H||t[15]!==oe||t[16]!==N?.rev||t[17]!==u||t[18]!==F?(G=()=>F(ee(`fleet`,{fleet:oe?{clear:!0,expected_rev:N?.rev}:{...H(),expected_rev:N?.rev}}),{onSuccess:e=>{let{snapshot:t}=e;u?.(t),ae(null),P(!1)}}),t[14]=H,t[15]=oe,t[16]=N?.rev,t[17]=u,t[18]=F,t[19]=G):G=t[19];let le=G,K;t[20]===a.sources?K=t[21]:(K=e=>a.sources?.[e]??`env`,t[20]=a.sources,t[21]=K);let q=K,ue=a.recorded?`recorded${a.by?` by ${a.by}`:``}`:`not recorded — this host's env`,J;t[22]===Symbol.for(`react.memo_cache_sentinel`)?(J=(0,y.jsxs)(`p`,{className:`text-[12.5px] text-faint`,children:[`Three layers, least specific first: this host's env, then this record, then a repository's own override. A setting still reading `,(0,y.jsx)(`b`,{children:`env`}),` below is this host's file alone — changing it here records it for every host.`]}),t[22]=J):J=t[22];let de;t[23]===a.overriding?de=t[24]:(de=(a.overriding?.length??0)>0&&(0,y.jsxs)(`p`,{className:`mt-2 text-[12.5px] text-faint`,children:[a.overriding?.length,` repositor`,a.overriding?.length===1?`y has`:`ies have`,` their own reviewers and are not reached by the default below:`,` `,a.overriding?.map(E),`.`]}),t[23]=a.overriding,t[24]=de);let Y;t[25]===a.lagging_hosts?Y=t[26]:(Y=a.lagging_hosts&&a.lagging_hosts.length>0&&(0,y.jsxs)(`div`,{className:`mt-2.5 rounded-lg border border-warn-edge bg-warn-bg px-3 py-2 text-[12.5px] text-warn`,children:[`These hosts run a binary that predates fleet defaults and will keep deciding from their own env: `,a.lagging_hosts.join(`, `)]}),t[25]=a.lagging_hosts,t[26]=Y);let X;t[27]===q?X=t[28]:(X=q(`reviewers`),t[27]=q,t[28]=X);let Z;if(t[29]!==l||t[30]!==g||t[31]!==p){let e;t[33]!==g||t[34]!==p?(e=e=>{let t=ie(e.login);return(0,y.jsxs)(`div`,{className:`flex items-center gap-2.5 rounded-lg border border-edge px-2.5 py-1.5`,children:[(0,y.jsx)(i,{login:e.login,name:e.name,size:18}),(0,y.jsx)(`span`,{className:`text-[12.5px] font-[550]`,children:e.name}),(0,y.jsxs)(`span`,{className:`ml-1 flex items-center gap-1.5`,children:[(0,y.jsx)(n,{on:e.primary||p.includes(t),label:`${e.name} runs by default`,locked:e.primary,title:e.primary?`the primary runs everywhere by default — turn it off for one project on that project's page`:`runs on every repository that has not overridden its reviewers`,onClick:()=>m(e=>{let n=e.includes(t);return n&&_(e=>e.filter(e=>e!==t)),n?e.filter(e=>e!==t):[...e,t]})}),(0,y.jsx)(`span`,{className:`text-[12px] text-faint`,children:`runs`})]}),(0,y.jsxs)(`span`,{className:`flex items-center gap-1.5`,children:[(0,y.jsx)(n,{on:g.includes(t),label:`${e.name} required by default`,title:`convergence waits for it`,onClick:()=>_(n=>{let r=n.includes(t);return!r&&!e.primary&&m(e=>e.includes(t)?e:[...e,t]),r?n.filter(e=>e!==t):[...n,t]})}),(0,y.jsx)(`span`,{className:`text-[12px] text-faint`,children:`required`})]})]},e.login)},t[33]=g,t[34]=p,t[35]=e):e=t[35],Z=l.map(e),t[29]=l,t[30]=g,t[31]=p,t[32]=Z}else Z=t[32];let Q;t[36]===Z?Q=t[37]:(Q=(0,y.jsx)(`div`,{className:`flex flex-wrap items-center gap-3`,children:Z}),t[36]=Z,t[37]=Q);let $;t[38]!==X||t[39]!==Q?($=(0,y.jsx)(M,{label:`Reviewers`,source:X,children:Q}),t[38]=X,t[39]=Q,t[40]=$):$=t[40];let fe;t[41]===q?fe=t[42]:(fe=q(`min_interval`),t[41]=q,t[42]=fe);let pe;t[43]===Symbol.for(`react.memo_cache_sentinel`)?(pe=e=>x(e.target.value),t[43]=pe):pe=t[43];let me;t[44]===b?me=t[45]:(me=(0,y.jsx)(`input`,{"aria-label":`Minimum interval between review fires`,value:b,onChange:pe,className:`w-28 rounded-lg border border-edge bg-[#FBFBFC] px-2 py-1 font-mono text-[12.5px]`}),t[44]=b,t[45]=me);let he;t[46]===Symbol.for(`react.memo_cache_sentinel`)?(he=(0,y.jsx)(`span`,{className:`ml-2 text-[12px] text-faint`,children:`minimum gap between metered fires, fleet-wide (e.g. 90s, 2m)`}),t[46]=he):he=t[46];let ge;t[47]!==fe||t[48]!==me?(ge=(0,y.jsxs)(M,{label:`Pacing`,source:fe,children:[me,he]}),t[47]=fe,t[48]=me,t[49]=ge):ge=t[49];let _e;t[50]===q?_e=t[51]:(_e=q(`weekly_limit`),t[50]=q,t[51]=_e);let ve;t[52]===Symbol.for(`react.memo_cache_sentinel`)?(ve=e=>C(e.target.value.replace(/[^0-9]/g,``)),t[52]=ve):ve=t[52];let ye;t[53]===S?ye=t[54]:(ye=(0,y.jsx)(`input`,{"aria-label":`Weekly CodeRabbit review limit`,value:S,inputMode:`numeric`,onChange:ve,className:`w-20 rounded-lg border border-edge bg-[#FBFBFC] px-2 py-1 font-mono text-[12.5px]`}),t[53]=S,t[54]=ye);let be;t[55]===Symbol.for(`react.memo_cache_sentinel`)?(be=(0,y.jsx)(`span`,{className:`ml-2 text-[12px] text-faint`,children:`the vendor's fair-use threshold — 60 on Pro, 90 on Pro+; 0 counts without forecasting`}),t[55]=be):be=t[55];let xe;t[56]!==_e||t[57]!==ye?(xe=(0,y.jsxs)(M,{label:`Weekly limit`,source:_e,children:[ye,be]}),t[56]=_e,t[57]=ye,t[58]=xe):xe=t[58];let Se;t[59]===q?Se=t[60]:(Se=q(`autofix_default`),t[59]=q,t[60]=Se);let Ce;t[61]===Symbol.for(`react.memo_cache_sentinel`)?(Ce=()=>ne(T),t[61]=Ce):Ce=t[61];let we=`rounded-lg border px-3 py-1 text-[12.5px] font-semibold ${w?`border-ok-edge bg-ok-bg text-ok`:`border-edge text-mut`}`,Te=w?`On`:`Off`,Ee;t[62]!==we||t[63]!==Te?(Ee=(0,y.jsx)(`button`,{type:`button`,onClick:Ce,className:we,children:Te}),t[62]=we,t[63]=Te,t[64]=Ee):Ee=t[64];let De;t[65]===Symbol.for(`react.memo_cache_sentinel`)?(De=(0,y.jsx)(`span`,{className:`ml-2 text-[12px] text-faint`,children:`whether a repository with no explicit switch may be fixed`}),t[65]=De):De=t[65];let Oe;t[66]!==Se||t[67]!==Ee?(Oe=(0,y.jsxs)(M,{label:`Autofix default`,source:Se,children:[Ee,De]}),t[66]=Se,t[67]=Ee,t[68]=Oe):Oe=t[68];let ke;t[69]!==$||t[70]!==ge||t[71]!==xe||t[72]!==Oe?(ke=(0,y.jsx)(`table`,{className:`mt-2.5 w-full border-collapse`,children:(0,y.jsxs)(`tbody`,{children:[$,ge,xe,Oe]})}),t[69]=$,t[70]=ge,t[71]=xe,t[72]=Oe,t[73]=ke):ke=t[73];let Ae;t[74]===L?Ae=t[75]:(Ae=L&&(0,y.jsx)(`div`,{className:`mt-3 rounded-lg border border-bad-edge bg-bad-bg px-3 py-2 text-[12.5px] text-bad`,children:L}),t[74]=L,t[75]=Ae);let je=!B||I||S===``,Me;t[76]===W?Me=t[77]:(Me=()=>void W(),t[76]=W,t[77]=Me);let Ne=I?`Working…`:`Review changes`,Pe;t[78]!==je||t[79]!==Me||t[80]!==Ne?(Pe=(0,y.jsx)(`button`,{type:`button`,disabled:je,onClick:Me,className:`rounded-lg bg-ink px-4 py-1.5 text-[13px] font-semibold text-white disabled:opacity-45`,children:Ne}),t[78]=je,t[79]=Me,t[80]=Ne,t[81]=Pe):Pe=t[81];let Fe=!B||I,Ie;t[82]!==a.autofix_default||t[83]!==a.min_interval||t[84]!==a.weekly_limit||t[85]!==z||t[86]!==R?(Ie=()=>{m(R),_(z),x(a.min_interval),C(String(a.weekly_limit)),ne(a.autofix_default)},t[82]=a.autofix_default,t[83]=a.min_interval,t[84]=a.weekly_limit,t[85]=z,t[86]=R,t[87]=Ie):Ie=t[87];let Le;t[88]!==Fe||t[89]!==Ie?(Le=(0,y.jsx)(`button`,{type:`button`,disabled:Fe,onClick:Ie,className:`rounded-lg border border-edge px-4 py-1.5 text-[13px] font-semibold text-mut disabled:opacity-45`,children:`Discard`}),t[88]=Fe,t[89]=Ie,t[90]=Le):Le=t[90];let Re;t[91]===B?Re=t[92]:(Re=B&&(0,y.jsx)(`span`,{className:`text-[12.5px] text-warn`,children:`unsaved changes`}),t[91]=B,t[92]=Re);let ze;t[93]!==I||t[94]!==B||t[95]!==a.recorded||t[96]!==W?(ze=a.recorded&&!B&&(0,y.jsx)(`button`,{type:`button`,disabled:I,onClick:()=>void W(!0),className:`ml-auto text-[12.5px] text-acc hover:underline disabled:opacity-45`,children:`Drop the record — follow this host's env again`}),t[93]=I,t[94]=B,t[95]=a.recorded,t[96]=W,t[97]=ze):ze=t[97];let Be;t[98]!==Pe||t[99]!==Le||t[100]!==Re||t[101]!==ze?(Be=(0,y.jsxs)(`div`,{className:`mt-3.5 flex flex-wrap items-center gap-2.5`,children:[Pe,Le,Re,ze]}),t[98]=Pe,t[99]=Le,t[100]=Re,t[101]=ze,t[102]=Be):Be=t[102];let Ve;t[103]!==Y||t[104]!==ke||t[105]!==Ae||t[106]!==Be||t[107]!==de?(Ve=(0,y.jsxs)(`div`,{className:`px-[18px] pb-4 pt-1`,children:[J,de,Y,ke,Ae,Be]}),t[103]=Y,t[104]=ke,t[105]=Ae,t[106]=Be,t[107]=de,t[108]=Ve):Ve=t[108];let He;t[109]!==I||t[110]!==oe||t[111]!==L||t[112]!==N||t[113]!==le?(He=N&&(0,y.jsx)(o,{title:oe?`Drop fleet defaults?`:`Save fleet defaults?`,danger:N.reopened>0,confirmLabel:oe?N.reopened>0?`Drop and reopen ${N.reopened}`:`Drop`:N.reopened>0?`Save and reopen ${N.reopened}`:`Save`,busy:I,error:L,body:(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(`ul`,{className:`mb-2 list-disc pl-4`,children:N.changes.map(re)}),N.summary,N.reopened>0&&(0,y.jsx)(`p`,{className:`mt-2 text-warn`,children:`Reopened rounds are reviewed again, and the metered ones spend the shared allowance.`})]}),onConfirm:()=>void le(),onCancel:()=>{ae(null),P(!1)}}),t[109]=I,t[110]=oe,t[111]=L,t[112]=N,t[113]=le,t[114]=He):He=t[114];let Ue;return t[115]!==Ve||t[116]!==He||t[117]!==ue?(Ue=(0,y.jsxs)(r,{title:`Fleet defaults`,end:ue,children:[Ve,He]}),t[115]=Ve,t[116]=He,t[117]=ue,t[118]=Ue):Ue=t[118],Ue}function re(e){return(0,y.jsx)(`li`,{children:e},e)}function T(e){return!e}function E(e,t){return(0,y.jsxs)(`span`,{children:[t>0&&`, `,(0,y.jsx)(g,{to:`/repos`,className:`text-acc hover:underline`,children:e.split(`/`).pop()})]},e)}function D(e){return ie(e.login)}function O(e){return e.required}function k(e){return ie(e.login)}function A(e){return e.budget!==`account`}function j(e,t,n,r){let i={};return s(r.runs,t)||(i.cobots=r.runs),s(r.required,n)||(i.required=r.required),r.minInterval!==e.min_interval&&(i.min_interval=r.minInterval),r.weekly!==``&&r.weekly!==String(e.weekly_limit)&&(i.weekly_limit=Number(r.weekly)),r.autofix!==e.autofix_default&&(i.autofix_default=r.autofix),i}function M(t){let n=(0,te.c)(13),{label:r,source:i,children:a}=t,o;n[0]===r?o=n[1]:(o=(0,y.jsx)(`div`,{className:`text-[13px] font-[550]`,children:r}),n[0]=r,n[1]=o);let s=i===`fleet`?`ok`:`mut`,c;n[2]!==i||n[3]!==s?(c=(0,y.jsx)(e,{tone:s,children:i}),n[2]=i,n[3]=s,n[4]=c):c=n[4];let l;n[5]!==o||n[6]!==c?(l=(0,y.jsxs)(`td`,{className:`w-40 py-2.5 pr-3 align-top`,children:[o,c]}),n[5]=o,n[6]=c,n[7]=l):l=n[7];let u;n[8]===a?u=n[9]:(u=(0,y.jsx)(`td`,{className:`py-2.5 align-middle`,children:a}),n[8]=a,n[9]=u);let d;return n[10]!==l||n[11]!==u?(d=(0,y.jsxs)(`tr`,{className:`border-b border-[#EEF0F3] last:border-none`,children:[l,u]}),n[10]=l,n[11]=u,n[12]=d):d=n[12],d}function ie(e){return e.replace(/\[bot\]$/,``).toLowerCase()}function N(e){let n=(0,te.c)(102),{settings:i,bots:a,onSnapshot:o}=e,s=i.config,c,l;n[0]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,y.jsx)(`h1`,{className:`text-xl font-[650] tracking-tight`,children:`Fleet settings`}),l=(0,y.jsx)(`p`,{className:`mt-1 max-w-[840px] text-[13.5px] text-mut`,children:`The defaults every repository inherits. The editable ones live in shared state, so one host's env file is no longer the fleet's source of truth; everything below the first card is this server's own environment, shown for reference.`}),n[0]=c,n[1]=l):(c=n[0],l=n[1]);let u;n[2]!==a||n[3]!==o||n[4]!==i.fleet?(u=i.fleet&&(0,y.jsx)(ne,{fleet:i.fleet,bots:a,onSnapshot:o}),n[2]=a,n[3]=o,n[4]=i.fleet,n[5]=u):u=n[5];let d;n[6]!==o||n[7]!==i.env?(d=i.env&&(0,y.jsx)(S,{env:i.env,onSnapshot:o}),n[6]=o,n[7]=i.env,n[8]=d):d=n[8];let f=i.quota.scope||`—`,p;n[9]===f?p=n[10]:(p=(0,y.jsx)(F,{k:`Scope`,v:f}),n[9]=f,n[10]=p);let h=i.quota.remaining===null||i.quota.remaining===void 0?`unknown`:String(i.quota.remaining),ee;n[11]===h?ee=n[12]:(ee=(0,y.jsx)(F,{k:`Remaining`,v:h}),n[11]=h,n[12]=ee);let g;n[13]===i.quota.blocked_until?g=n[14]:(g=i.quota.blocked_until?m(i.quota.blocked_until):`not blocked`,n[13]=i.quota.blocked_until,n[14]=g);let _;n[15]===g?_=n[16]:(_=(0,y.jsx)(F,{k:`Blocked until`,v:g}),n[15]=g,n[16]=_);let v=i.quota.source||`—`,b;n[17]===v?b=n[18]:(b=(0,y.jsx)(F,{k:`Source`,v}),n[17]=v,n[18]=b);let x;n[19]===i.quota.checked_at?x=n[20]:(x=m(i.quota.checked_at),n[19]=i.quota.checked_at,n[20]=x);let C;n[21]===x?C=n[22]:(C=(0,y.jsx)(F,{k:`Checked`,v:x}),n[21]=x,n[22]=C);let w;n[23]!==_||n[24]!==b||n[25]!==C||n[26]!==p||n[27]!==ee?(w=(0,y.jsx)(r,{title:`CodeRabbit account`,end:`the metered lane's shared quota`,children:(0,y.jsxs)(`div`,{className:`flex flex-wrap gap-4 px-[18px] py-3`,children:[p,ee,_,b,C]})}),n[23]=_,n[24]=b,n[25]=C,n[26]=p,n[27]=ee,n[28]=w):w=n[28];let re=s.reviewers.length,T;n[29]===Symbol.for(`react.memo_cache_sentinel`)?(T=(0,y.jsx)(`thead`,{children:(0,y.jsxs)(`tr`,{children:[(0,y.jsx)(t,{children:`Reviewer`}),(0,y.jsx)(t,{children:`Role`}),(0,y.jsx)(t,{children:`Required`}),(0,y.jsx)(t,{children:`Trigger`}),(0,y.jsx)(t,{className:`c-host`,children:`Command`})]})}),n[29]=T):T=n[29];let E;n[30]===s.reviewers?E=n[31]:(E=s.reviewers.map(oe),n[30]=s.reviewers,n[31]=E);let D;n[32]===E?D=n[33]:(D=(0,y.jsxs)(`table`,{className:`mt-1.5 w-full border-collapse`,children:[T,(0,y.jsx)(`tbody`,{children:E})]}),n[32]=E,n[33]=D);let O;n[34]!==s.reviewers.length||n[35]!==D?(O=(0,y.jsx)(r,{title:`Reviewers`,count:re,children:D}),n[34]=s.reviewers.length,n[35]=D,n[36]=O):O=n[36];let k;n[37]===s.min_interval?k=n[38]:(k=(0,y.jsx)(P,{k:`Minimum interval between fires`,v:s.min_interval,d:`the queue's main throttle`}),n[37]=s.min_interval,n[38]=k);let A;n[39]===s.inflight_timeout?A=n[40]:(A=(0,y.jsx)(P,{k:`In-flight timeout`,v:s.inflight_timeout,d:`release a round whose bot never answered`}),n[39]=s.inflight_timeout,n[40]=A);let j;n[41]===s.watch_interval?j=n[42]:(j=(0,y.jsx)(P,{k:`Watch interval`,v:s.watch_interval,d:`how often open PRs are driven forward`}),n[41]=s.watch_interval,n[42]=j);let M;n[43]!==k||n[44]!==A||n[45]!==j?(M=(0,y.jsx)(r,{title:`Pacing & limits`,end:`protects the shared quota and GitHub's REST budget`,children:(0,y.jsxs)(`div`,{className:`px-[18px] pb-3`,children:[k,A,j]})}),n[43]=k,n[44]=A,n[45]=j,n[46]=M):M=n[46];let ie;n[47]===s.autofix_command?ie=n[48]:(ie=s.autofix_command?.join(` `)||`not configured`,n[47]=s.autofix_command,n[48]=ie);let N;n[49]===ie?N=n[50]:(N=(0,y.jsx)(P,{k:`Agent command`,v:ie,d:`one argv for the fleet`}),n[49]=ie,n[50]=N);let I=String(s.autofix_max_attempts??`—`),L;n[51]===I?L=n[52]:(L=(0,y.jsx)(P,{k:`Max attempts per head`,v:I}),n[51]=I,n[52]=L);let R=s.autofix_concurrency?String(s.autofix_concurrency):`uncapped`,z;n[53]===R?z=n[54]:(z=(0,y.jsx)(P,{k:`Concurrency`,v:R}),n[53]=R,n[54]=z);let se=s.autofix_forks?`yes`:`no`,ce;n[55]===se?ce=n[56]:(ce=(0,y.jsx)(P,{k:`Fix fork PRs`,v:se}),n[55]=se,n[56]=ce);let B=s.workspace_root||`—`,V;n[57]===B?V=n[58]:(V=(0,y.jsx)(P,{k:`Workspace`,v:B}),n[57]=B,n[58]=V);let H;n[59]!==N||n[60]!==L||n[61]!==z||n[62]!==ce||n[63]!==V?(H=(0,y.jsx)(r,{title:`Autofix defaults`,children:(0,y.jsxs)(`div`,{className:`px-[18px] pb-3`,children:[N,L,z,ce,V]})}),n[59]=N,n[60]=L,n[61]=z,n[62]=ce,n[63]=V,n[64]=H):H=n[64];let U;n[65]===s.scope?U=n[66]:(U=s.scope?.join(`, `)||`—`,n[65]=s.scope,n[66]=U);let W;n[67]===U?W=n[68]:(W=(0,y.jsx)(P,{k:`Scope`,v:U,d:`owners searched for open PRs`}),n[67]=U,n[68]=W);let G;n[69]===s.allow_repos?G=n[70]:(G=s.allow_repos?.join(`, `)||`everything in scope`,n[69]=s.allow_repos,n[70]=G);let le;n[71]===G?le=n[72]:(le=(0,y.jsx)(P,{k:`Allowlist`,v:G,d:`CRQ_REPOS`}),n[71]=G,n[72]=le);let K;n[73]===s.exclude_repos?K=n[74]:(K=s.exclude_repos?.join(`, `)||`none`,n[73]=s.exclude_repos,n[74]=K);let q;n[75]===K?q=n[76]:(q=(0,y.jsx)(P,{k:`Excluded`,v:K,d:`CRQ_EXCLUDE`}),n[75]=K,n[76]=q);let ue;n[77]===s.skip_authors?ue=n[78]:(ue=s.skip_authors?.join(`, `)||`none`,n[77]=s.skip_authors,n[78]=ue);let J;n[79]===ue?J=n[80]:(J=(0,y.jsx)(P,{k:`Skip authors`,v:ue}),n[79]=ue,n[80]=J);let de=s.skip_marker||`—`,Y;n[81]===de?Y=n[82]:(Y=(0,y.jsx)(P,{k:`Skip marker`,v:de,d:`put this in a PR body to keep the fleet off it`}),n[81]=de,n[82]=Y);let X;n[83]!==W||n[84]!==le||n[85]!==q||n[86]!==J||n[87]!==Y?(X=(0,y.jsx)(r,{title:`Automatic review`,children:(0,y.jsxs)(`div`,{className:`px-[18px] pb-3`,children:[W,le,q,J,Y]})}),n[83]=W,n[84]=le,n[85]=q,n[86]=J,n[87]=Y,n[88]=X):X=n[88];let Z;n[89]===i.plumbing?Z=n[90]:(Z=i.plumbing.map(ae),n[89]=i.plumbing,n[90]=Z);let Q;n[91]===Z?Q=n[92]:(Q=(0,y.jsx)(r,{title:`Plumbing`,end:`read-only — crq init owns these`,children:(0,y.jsx)(`div`,{className:`px-[18px] pb-3`,children:Z})}),n[91]=Z,n[92]=Q);let $;return n[93]!==w||n[94]!==O||n[95]!==M||n[96]!==u||n[97]!==H||n[98]!==d||n[99]!==X||n[100]!==Q?($=(0,y.jsxs)(`main`,{className:`mx-auto max-w-[1120px] px-6 pt-5 pb-16`,children:[c,l,u,d,w,O,M,H,X,Q]}),n[93]=w,n[94]=O,n[95]=M,n[96]=u,n[97]=H,n[98]=d,n[99]=X,n[100]=Q,n[101]=$):$=n[101],$}function ae(e){return(0,y.jsx)(P,{k:e.key,v:e.value,d:e.detail},e.key)}function oe(t){return(0,y.jsxs)(`tr`,{className:`hover:bg-[#F7F8FA]`,children:[(0,y.jsx)(a,{className:`font-[550]`,children:t.name}),(0,y.jsx)(a,{children:t.primary?(0,y.jsx)(e,{tone:`warn`,children:`primary · metered`}):(0,y.jsx)(e,{tone:`ok`,children:`co-reviewer · free`})}),(0,y.jsx)(a,{children:t.required?`waits for it`:(0,y.jsx)(`span`,{className:`text-faint`,children:`no`})}),(0,y.jsxs)(a,{children:[t.trigger||`—`,t.trigger===`selfheal`&&t.grace?` · ${t.grace}`:``]}),(0,y.jsx)(a,{className:`c-host font-mono text-[12.5px] text-mut`,children:t.command||`—`})]},t.login)}function P(e){let t=(0,te.c)(10),{k:n,v:r,d:i}=e,a;t[0]===i?a=t[1]:(a=i&&(0,y.jsx)(`span`,{className:`block text-xs font-normal text-faint`,children:i}),t[0]=i,t[1]=a);let o;t[2]!==n||t[3]!==a?(o=(0,y.jsxs)(`span`,{className:`font-medium`,children:[n,a]}),t[2]=n,t[3]=a,t[4]=o):o=t[4];let s;t[5]===r?s=t[6]:(s=(0,y.jsx)(`span`,{className:`font-mono text-[12.5px] break-words text-mut`,children:r}),t[5]=r,t[6]=s);let c;return t[7]!==o||t[8]!==s?(c=(0,y.jsxs)(`div`,{className:`grid grid-cols-[260px_1fr] gap-3 border-b border-[#EEF0F3] py-2 text-[13.5px] last:border-none max-[1150px]:grid-cols-[minmax(0,1fr)] max-[1150px]:gap-1`,children:[o,s]}),t[7]=o,t[8]=s,t[9]=c):c=t[9],c}function F(e){let t=(0,te.c)(7),{k:n,v:r}=e,i;t[0]===n?i=t[1]:(i=(0,y.jsx)(`div`,{className:`text-[11px] font-medium tracking-[0.06em] text-faint uppercase`,children:n}),t[0]=n,t[1]=i);let a;t[2]===r?a=t[3]:(a=(0,y.jsx)(`div`,{className:`mt-0.5 text-[15px] font-[650]`,children:r}),t[2]=r,t[3]=a);let o;return t[4]!==i||t[5]!==a?(o=(0,y.jsxs)(`div`,{className:`min-w-[120px] rounded-lg border border-edge px-3.5 py-2`,children:[i,a]}),t[4]=i,t[5]=a,t[6]=o):o=t[6],o}function I(){let e=(0,te.c)(5),{snapshot:t,setSnapshot:n}=d();if(!t){let t;return e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,y.jsx)(h,{}),e[0]=t):t=e[0],t}let r;return e[1]!==n||e[2]!==t.bots||e[3]!==t.settings?(r=(0,y.jsx)(N,{settings:t.settings,bots:t.bots,onSnapshot:n}),e[1]=n,e[2]=t.bots,e[3]=t.settings,e[4]=r):r=e[4],r}export{I as SettingsRoute}; \ No newline at end of file diff --git a/internal/serve/dist/assets/SetupRoute-6djY24Gk.js b/internal/serve/dist/assets/SetupRoute-6djY24Gk.js new file mode 100644 index 00000000..d694b208 --- /dev/null +++ b/internal/serve/dist/assets/SetupRoute-6djY24Gk.js @@ -0,0 +1,2 @@ +import{a as e,c as t,d as n,l as r,r as i,t as a,u as o}from"./ui-Bim2uazI.js";import{b as s,c,g as l,n as u,r as ee,s as d,v as te}from"./index-DlC1aBiV.js";var ne=l(),f=s(),p={ok:`ok`,warn:`warn`,bad:`bad`,unknown:`mut`};function m(r){let a=(0,ne.c)(86),{setup:o,bots:s,repos:c}=r,l=d(5e3),u;a[0]===o.checks?u=a[1]:(u=o.checks.filter(ae),a[0]=o.checks,a[1]=u);let p=u.length,m,T;a[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,f.jsx)(`h1`,{className:`text-xl font-[650] tracking-tight`,children:`Setup`}),T=(0,f.jsx)(`p`,{className:`mt-1 max-w-[820px] text-[13.5px] text-mut`,children:`What crq needs, and whether this fleet has it. Useful after the first run too — it is where to look when a host stops fixing or a lease goes missing.`}),a[2]=m,a[3]=T):(m=a[2],T=a[3]);let se=p===0?`Everything crq checks is in place`:`${p} thing(s) need attention`,E;a[4]===se?E=a[5]:(E=(0,f.jsx)(`h2`,{className:`text-[15px] font-[650]`,children:se}),a[4]=se,a[5]=E);let D;a[6]===o.ready?D=a[7]:(D=(0,f.jsxs)(t,{tone:`ok`,children:[o.ready,` ready`]}),a[6]=o.ready,a[7]=D);let O;a[8]===o.attention?O=a[9]:(O=o.attention>0&&(0,f.jsxs)(t,{tone:`warn`,children:[o.attention,` need attention`]}),a[8]=o.attention,a[9]=O);let k;a[10]===o.optional?k=a[11]:(k=o.optional>0&&(0,f.jsxs)(t,{tone:`mut`,children:[o.optional,` optional missing`]}),a[10]=o.optional,a[11]=k);let A;a[12]!==D||a[13]!==O||a[14]!==k?(A=(0,f.jsxs)(`span`,{className:`flex flex-wrap items-center gap-2 text-[12.5px]`,children:[D,O,k]}),a[12]=D,a[13]=O,a[14]=k,a[15]=A):A=a[15];let ce=o.fleet?.length??0,j;a[16]===ce?j=a[17]:(j=(0,f.jsxs)(`span`,{className:`ml-auto text-[12.5px] text-faint`,children:[ce,` host(s) reporting · updates as they do`]}),a[16]=ce,a[17]=j);let M;a[18]!==j||a[19]!==E||a[20]!==A?(M=(0,f.jsxs)(`div`,{className:`my-3.5 flex flex-wrap items-center gap-3 rounded-[10px] border border-edge bg-card px-5 py-4 shadow-card`,children:[E,A,j]}),a[18]=j,a[19]=E,a[20]=A,a[21]=M):M=a[21];let N;a[22]!==l||a[23]!==o.fleet?(N=(o.fleet?.length??0)>0&&(0,f.jsx)(oe,{fleet:o.fleet??[],now:l}),a[22]=l,a[23]=o.fleet,a[24]=N):N=a[24];let P;a[25]===s?P=a[26]:(P=s.filter(ie),a[25]=s,a[26]=P);let F;a[27]===s?F=a[28]:(F=s.filter(w).length===0?(0,f.jsx)(e,{children:`No reviewer is enabled.`}):(0,f.jsx)(`ul`,{children:s.filter(C).map(S)}),a[27]=s,a[28]=F);let le;a[29]===Symbol.for(`react.memo_cache_sentinel`)?(le=(0,f.jsx)(te,{to:`/bots`,className:`mt-1.5 inline-block text-[12.5px] text-acc hover:underline`,children:`Compare and set them up →`}),a[29]=le):le=a[29];let I;a[30]===F?I=a[31]:(I=(0,f.jsxs)(`div`,{className:`px-[18px] pb-3.5 pt-1 text-[13px]`,children:[F,le]}),a[30]=F,a[31]=I);let L;a[32]!==P.length||a[33]!==I?(L=(0,f.jsx)(i,{title:`Review bots`,count:P.length,children:I}),a[32]=P.length,a[33]=I,a[34]=L):L=a[34];let R;a[35]===c?R=a[36]:(R=c.filter(x),a[35]=c,a[36]=R);let z,B;a[37]===c?(z=a[38],B=a[39]):(z=c.filter(b).length===0?(0,f.jsx)(e,{children:`Nothing is enrolled.`}):(0,f.jsx)(`ul`,{children:c.filter(y).slice(0,8).map(v)}),B=c.filter(_).length>0&&(0,f.jsxs)(`p`,{className:`mt-1.5 rounded-lg border border-warn-edge bg-warn-bg px-2.5 py-1.5 text-[12px] text-warn`,children:[c.filter(re).length,` still enrolled by this host's env file — the other hosts decide for themselves.`,` `,(0,f.jsx)(`code`,{children:`crq fleet adopt`}),` records them for everyone.`]}),a[37]=c,a[38]=z,a[39]=B);let ue;a[40]===Symbol.for(`react.memo_cache_sentinel`)?(ue=(0,f.jsx)(te,{to:`/repos`,className:`mt-1.5 inline-block text-[12.5px] text-acc hover:underline`,children:`Manage repositories →`}),a[40]=ue):ue=a[40];let V;a[41]!==z||a[42]!==B?(V=(0,f.jsxs)(`div`,{className:`px-[18px] pb-3.5 pt-1 text-[13px]`,children:[z,B,ue]}),a[41]=z,a[42]=B,a[43]=V):V=a[43];let H;a[44]!==R.length||a[45]!==V?(H=(0,f.jsx)(i,{title:`Repositories`,count:R.length,children:V}),a[44]=R.length,a[45]=V,a[46]=H):H=a[46];let U;a[47]!==L||a[48]!==H?(U=(0,f.jsxs)(`div`,{className:`grid grid-cols-2 gap-4 max-[860px]:grid-cols-1`,children:[L,H]}),a[47]=L,a[48]=H,a[49]=U):U=a[49];let de=o.checks.length,W;a[50]===o.checks?W=a[51]:(W=o.checks.map(g),a[50]=o.checks,a[51]=W);let G;a[52]===W?G=a[53]:(G=(0,f.jsx)(`div`,{className:`px-[18px] pb-3`,children:W}),a[52]=W,a[53]=G);let K;a[54]!==o.checks.length||a[55]!==G?(K=(0,f.jsx)(i,{title:`Checks`,count:de,children:G}),a[54]=o.checks.length,a[55]=G,a[56]=K):K=a[56];let fe=`on ${o.tools_host}`,pe;a[57]===Symbol.for(`react.memo_cache_sentinel`)?(pe=(0,f.jsxs)(`p`,{className:`px-[18px] pt-1 text-[12.5px] text-faint`,children:[`This machine, probed from this server's own PATH. The per-host table above is the reported one — a tool has to be visible to the `,(0,f.jsx)(`i`,{children:`service`}),`, not just to your shell, and only the host itself can say whether it is.`]}),a[57]=pe):pe=a[57];let me;a[58]===Symbol.for(`react.memo_cache_sentinel`)?(me=(0,f.jsx)(`thead`,{children:(0,f.jsxs)(`tr`,{children:[(0,f.jsx)(n,{children:`Tool`}),(0,f.jsx)(n,{children:`Purpose`}),(0,f.jsx)(n,{children:`Status`}),(0,f.jsx)(n,{className:`c-host`,children:`Path`})]})}),a[58]=me):me=a[58];let q;a[59]===o.tools?q=a[60]:(q=o.tools.map(h),a[59]=o.tools,a[60]=q);let J;a[61]===q?J=a[62]:(J=(0,f.jsxs)(`table`,{className:`mt-1.5 w-full border-collapse`,children:[me,(0,f.jsx)(`tbody`,{children:q})]}),a[61]=q,a[62]=J);let Y;a[63]!==fe||a[64]!==J?(Y=(0,f.jsxs)(i,{title:`Command-line tools`,end:fe,children:[pe,J]}),a[63]=fe,a[64]=J,a[65]=Y):Y=a[65];let he=o.hosts.length,X;a[66]===o.hosts.length?X=a[67]:(X=o.hosts.length===0&&(0,f.jsx)(e,{children:`No host has written state in the last day.`}),a[66]=o.hosts.length,a[67]=X);let Z;if(a[68]!==l||a[69]!==o.hosts){let e;a[71]===l?e=a[72]:(e=e=>(0,f.jsxs)(`div`,{className:`min-w-[220px] flex-1 rounded-lg border px-3.5 py-2.5 text-[12.5px] text-mut ${e.health===`unhealthy`?`border-bad-edge bg-bad-bg`:`border-edge`}`,children:[(0,f.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,f.jsx)(`span`,{className:`font-mono text-[13.5px] font-[650] text-ink`,children:e.name}),e.health&&(0,f.jsx)(t,{tone:e.health===`healthy`?`ok`:e.health===`unhealthy`?`bad`:`mut`,children:e.health===`unknown`?`no recent signal`:e.health})]}),(0,f.jsx)(`div`,{className:`mt-1`,children:e.roles?.length?e.roles.join(` · `):`writes state`}),e.last_seen&&(0,f.jsxs)(`div`,{className:`mt-0.5`,children:[`last write `,ee(e.last_seen,l)]}),e.last_error&&(0,f.jsx)(`div`,{className:`mt-1 font-mono text-[11.5px] break-words`,children:e.last_error})]},e.name),a[71]=l,a[72]=e),Z=o.hosts.map(e),a[68]=l,a[69]=o.hosts,a[70]=Z}else Z=a[70];let Q;a[73]!==X||a[74]!==Z?(Q=(0,f.jsxs)(`div`,{className:`flex flex-wrap gap-3 px-[18px] py-3`,children:[X,Z]}),a[73]=X,a[74]=Z,a[75]=Q):Q=a[75];let $;a[76]!==o.hosts.length||a[77]!==Q?($=(0,f.jsx)(i,{title:`Hosts`,count:he,children:Q}),a[76]=o.hosts.length,a[77]=Q,a[78]=$):$=a[78];let ge;return a[79]!==M||a[80]!==N||a[81]!==U||a[82]!==K||a[83]!==Y||a[84]!==$?(ge=(0,f.jsxs)(`main`,{className:`mx-auto max-w-[1120px] px-6 pt-5 pb-16`,children:[m,T,M,N,U,K,Y,$]}),a[79]=M,a[80]=N,a[81]=U,a[82]=K,a[83]=Y,a[84]=$,a[85]=ge):ge=a[85],ge}function h(e){return(0,f.jsxs)(`tr`,{className:`hover:bg-[#F7F8FA]`,children:[(0,f.jsx)(o,{className:`font-mono text-[13px] font-semibold`,children:e.name}),(0,f.jsxs)(o,{className:`text-[12.5px] text-mut`,children:[e.purpose,!e.found&&(e.fix?.length??0)>0&&(0,f.jsxs)(`details`,{className:`mt-1 rounded-lg border border-edge bg-[#FBFBFC] px-2 py-1`,children:[(0,f.jsx)(`summary`,{className:`cursor-pointer text-[12px] font-[550]`,children:`How to fix it`}),(0,f.jsx)(`pre`,{className:`mt-1 overflow-x-auto text-[11.5px] whitespace-pre-wrap`,children:e.fix?.join(` +`)}),(0,f.jsxs)(`p`,{className:`mt-1 text-[11.5px] text-faint`,children:[`Installing it for your shell is not enough: the service gets its own PATH. Add the directory with `,(0,f.jsx)(`code`,{children:`systemctl --user edit crq-autofix`}),` and an`,` `,(0,f.jsx)(`code`,{children:`Environment=PATH=…`}),` line, or reinstall so crq writes it for you.`]})]})]}),(0,f.jsxs)(o,{children:[e.found?(0,f.jsx)(t,{tone:`ok`,children:`found`}):e.required?(0,f.jsx)(t,{tone:`bad`,children:`missing`}):(0,f.jsx)(t,{tone:`mut`,children:`not installed`}),e.required&&(0,f.jsx)(`span`,{className:`ml-2 text-[11.5px] text-faint`,children:`required`})]}),(0,f.jsx)(o,{className:`c-host font-mono text-[12px] text-faint`,children:e.path??`—`})]},e.name)}function g(e){return(0,f.jsxs)(`div`,{className:`flex items-baseline gap-3 border-b border-[#EEF0F3] py-2 text-[13.5px] last:border-none`,children:[(0,f.jsx)(t,{tone:p[e.status]??`mut`,children:e.status}),(0,f.jsx)(`span`,{className:`font-[550]`,children:e.label}),(0,f.jsx)(`span`,{className:`text-faint`,children:e.detail})]},e.key)}function re(e){return e.reviewed&&e.enrollment===`env`}function _(e){return e.reviewed&&e.enrollment===`env`}function v(e){return(0,f.jsxs)(`li`,{className:`flex items-center gap-2 py-1`,children:[(0,f.jsx)(r,{repo:e.repo}),(0,f.jsx)(`span`,{children:D(e.repo)}),(0,f.jsx)(`span`,{className:`ml-auto text-[12px] text-faint`,children:e.enrollment})]},e.repo)}function y(e){return e.reviewed}function b(e){return e.reviewed}function x(e){return e.reviewed}function S(e){return(0,f.jsxs)(`li`,{className:`flex items-center gap-2 py-1`,children:[(0,f.jsx)(a,{login:e.login,name:e.name,size:18}),(0,f.jsx)(`span`,{className:`font-[550]`,children:e.name}),(0,f.jsx)(`span`,{className:`ml-auto`,children:(0,f.jsx)(t,{tone:e.status===`working`?`ok`:e.status===`silent`?`bad`:`mut`,children:e.status===`working`?`working`:e.status===`silent`?`never answered`:`not verified`})})]},e.login)}function C(e){return e.enabled}function w(e){return e.enabled}function ie(e){return e.enabled}function ae(e){return e.status===`bad`||e.status===`warn`}function oe(e){let t=(0,ne.c)(31),{fleet:r,now:a}=e,s;t[0]===r?s=t[1]:(s=new Set(r.flatMap(se)),t[0]=r,t[1]=s);let c;t[2]===s?c=t[3]:(c=[...s],t[2]=s,t[3]=c);let l=c,u=`${r.length} host(s)`,d;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,f.jsx)(n,{children:`Host`}),t[4]=d):d=t[4];let p;t[5]===l?p=t[6]:(p=(0,f.jsx)(`thead`,{children:(0,f.jsxs)(`tr`,{children:[d,l.map(T)]})}),t[5]=l,t[6]=p);let m;if(t[7]!==r||t[8]!==l||t[9]!==a){let e;t[11]!==l||t[12]!==a?(e=e=>(0,f.jsxs)(`tr`,{className:`border-t border-[#EEF0F3]`,children:[(0,f.jsxs)(o,{children:[(0,f.jsx)(`div`,{className:`font-[550]`,children:e.host}),(0,f.jsxs)(`div`,{className:`text-[11.5px] text-faint`,children:[e.version&&(0,f.jsxs)(`span`,{className:e.behind?`text-warn`:``,children:[`crq `,e.version,e.behind?` · behind`:``]}),(e.roles?.length??0)>0&&` · ${e.roles?.join(`, `)}`,e.stale&&e.at&&(0,f.jsxs)(`span`,{className:`text-warn`,children:[` · last heard `,ee(e.at,a)]})]})]}),l.map(t=>{let n=e.tools.find(e=>e.name===t);return(0,f.jsx)(o,{className:`text-center`,children:n?.path?(0,f.jsx)(`span`,{title:`${n.path}${n.version?` · ${n.version}`:``}`,className:`text-ok`,children:`✓`}):(0,f.jsx)(`span`,{className:`text-faint`,title:`not on the PATH this host's service runs with`,children:`—`})},t)})]},e.host),t[11]=l,t[12]=a,t[13]=e):e=t[13],m=r.map(e),t[7]=r,t[8]=l,t[9]=a,t[10]=m}else m=t[10];let h;t[14]===m?h=t[15]:(h=(0,f.jsx)(`tbody`,{children:m}),t[14]=m,t[15]=h);let g;t[16]!==p||t[17]!==h?(g=(0,f.jsxs)(`table`,{className:`w-full border-collapse text-[12.5px]`,children:[p,h]}),t[16]=p,t[17]=h,t[18]=g):g=t[18];let re;t[19]===Symbol.for(`react.memo_cache_sentinel`)?(re=(0,f.jsx)(`b`,{children:`These are command-line tools, not the review bots.`}),t[19]=re):re=t[19];let _;t[20]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,f.jsx)(`code`,{children:`codex`}),t[20]=_):_=t[20];let v;t[21]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,f.jsx)(te,{to:`/bots`,className:`text-acc hover:underline`,children:`Bots`}),t[21]=v):v=t[21];let y;t[22]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,f.jsx)(`br`,{}),t[22]=y):y=t[22];let b;t[23]===Symbol.for(`react.memo_cache_sentinel`)?(b=(0,f.jsx)(`code`,{children:`systemctl --user edit crq-autofix`}),t[23]=b):b=t[23];let x;t[24]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,f.jsx)(`code`,{children:`Environment=PATH=…`}),t[24]=x):x=t[24];let S;t[25]===Symbol.for(`react.memo_cache_sentinel`)?(S=(0,f.jsxs)(`p`,{className:`pt-2 text-[11.5px] text-faint`,children:[re,` The Codex and CodeRabbit reviewers are GitHub apps and need nothing installed here — a host missing the`,` `,_,` CLI still gets Codex reviews. Whether a REVIEWER works is on the`,` `,v,` `,`page. What these decide is whether this host can run a fix session or a local preflight.`,y,`Each host probes the PATH its own crq process runs with. A daemon reports the SERVICE's PATH, which is the one that decides whether a fix session can start — a tool installed for your shell and missing here is exactly the failure that looks fine until it is needed. Fix it by adding the directory to the unit: `,b,`, then `,x,`, then reinstall with `,(0,f.jsx)(`code`,{children:`crq autofix install`}),`.`]}),t[25]=S):S=t[25];let C;t[26]===g?C=t[27]:(C=(0,f.jsxs)(`div`,{className:`overflow-x-auto px-[18px] pb-3`,children:[g,S]}),t[26]=g,t[27]=C);let w;return t[28]!==C||t[29]!==u?(w=(0,f.jsx)(i,{title:`Tools, per host`,count:u,children:C}),t[28]=C,t[29]=u,t[30]=w):w=t[30],w}function T(e){return(0,f.jsx)(n,{className:`text-center`,children:e},e)}function se(e){return e.tools.map(E)}function E(e){return e.name}function D(e){return e.split(`/`).pop()??e}function O(){let e=(0,ne.c)(5),{snapshot:t}=c();if(!t){let t;return e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,f.jsx)(u,{}),e[0]=t):t=e[0],t}let n;return e[1]!==t.bots||e[2]!==t.repos||e[3]!==t.setup?(n=(0,f.jsx)(m,{setup:t.setup,bots:t.bots,repos:t.repos}),e[1]=t.bots,e[2]=t.repos,e[3]=t.setup,e[4]=n):n=e[4],n}export{O as SetupRoute}; \ No newline at end of file diff --git a/internal/serve/dist/assets/index-D4a4DCxu.css b/internal/serve/dist/assets/index-D4a4DCxu.css new file mode 100644 index 00000000..9aafad93 --- /dev/null +++ b/internal/serve/dist/assets/index-D4a4DCxu.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:"IBM Plex Sans", system-ui, sans-serif;--font-mono:"IBM Plex Mono", ui-monospace, monospace;--color-white:#fff;--spacing:.25rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-bg:#f1f3f5;--color-card:#fff;--color-edge:#e3e6eb;--color-edge2:#c9cfd8;--color-ink:#17212d;--color-mut:#5b6472;--color-faint:#8b93a0;--color-ok:#1f7a4d;--color-ok-bg:#eaf4ee;--color-ok-edge:#c7e2d2;--color-warn:#8a5300;--color-warn-fg:#b45d10;--color-warn-bg:#fbf1e2;--color-warn-edge:#ead7b4;--color-bad:#b3362b;--color-bad-bg:#f9ecea;--color-bad-edge:#efcfcb;--color-acc:#2b5faa;--color-acc-bg:#ebf1fa;--color-acc-edge:#c9d8ef}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{inset-block:0}.top-0{top:0}.top-0\.5{top:calc(var(--spacing) * .5)}.top-3{top:calc(var(--spacing) * 3)}.top-16{top:calc(var(--spacing) * 16)}.top-\[8vh\]{top:8vh}.top-\[12vh\]{top:12vh}.-right-1{right:calc(var(--spacing) * -1)}.right-0{right:0}.right-3{right:calc(var(--spacing) * 3)}.-bottom-1{bottom:calc(var(--spacing) * -1)}.left-0{left:0}.left-0\.5{left:calc(var(--spacing) * .5)}.left-1\/2{left:50%}.left-\[17px\]{left:17px}.z-40{z-index:40}.z-50{z-index:50}.order-3{order:3}.mx-auto{margin-inline:auto}.my-3\.5{margin-block:calc(var(--spacing) * 3.5)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-2\.5{margin-top:calc(var(--spacing) * 2.5)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-3\.5{margin-top:calc(var(--spacing) * 3.5)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-3\.5{margin-bottom:calc(var(--spacing) * 3.5)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-\[7px\]{width:7px;height:7px}.size-\[13px\]{width:13px;height:13px}.size-\[15px\]{width:15px;height:15px}.size-\[22px\]{width:22px;height:22px}.h-7{height:calc(var(--spacing) * 7)}.h-\[19px\]{height:19px}.h-full{height:100%}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-\[84vh\]{max-height:84vh}.min-h-0{min-height:0}.min-h-28{min-height:calc(var(--spacing) * 28)}.min-h-screen{min-height:100vh}.w-6{width:calc(var(--spacing) * 6)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-40{width:calc(var(--spacing) * 40)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-\[34px\]{width:34px}.w-\[52px\]{width:52px}.w-\[150px\]{width:150px}.w-\[230px\]{width:230px}.w-\[300px\]{width:300px}.w-\[420px\]{width:420px}.w-\[calc\(100\%-2rem\)\]{width:calc(100% - 2rem)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-\[46ch\]{max-width:46ch}.max-w-\[520px\]{max-width:520px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.max-w-\[820px\]{max-width:820px}.max-w-\[840px\]{max-width:840px}.max-w-\[860px\]{max-width:860px}.max-w-\[900px\]{max-width:900px}.max-w-\[1120px\]{max-width:1120px}.max-w-\[1180px\]{max-width:1180px}.max-w-\[1400px\]{max-width:1400px}.max-w-\[1600px\]{max-width:1600px}.max-w-full{max-width:100%}.min-w-0{min-width:0}.min-w-\[104px\]{min-width:104px}.min-w-\[120px\]{min-width:120px}.min-w-\[160px\]{min-width:160px}.min-w-\[170px\]{min-width:170px}.min-w-\[220px\]{min-width:220px}.min-w-\[260px\]{min-width:260px}.min-w-\[280px\]{min-width:280px}.min-w-fit{min-width:fit-content}.flex-1{flex:1}.shrink-0{flex-shrink:0}.basis-full{flex-basis:100%}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.animate-pulse{animation:var(--animate-pulse)}.cursor-pointer{cursor:pointer}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-\[1fr_auto_auto_auto\]{grid-template-columns:1fr auto auto auto}.grid-cols-\[260px_1fr\]{grid-template-columns:260px 1fr}.grid-cols-\[320px_minmax\(0\,1fr\)\]{grid-template-columns:320px minmax(0,1fr)}.grid-cols-\[minmax\(0\,1fr\)_360px\]{grid-template-columns:minmax(0,1fr) 360px}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-3\.5{gap:calc(var(--spacing) * 3.5)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-4\.5{gap:calc(var(--spacing) * 4.5)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded-\[5px\]{border-radius:5px}.rounded-\[10px\]{border-radius:10px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-\[1\.5px\]{border-style:var(--tw-border-style);border-width:1.5px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-l-\[3px\]{border-left-style:var(--tw-border-style);border-left-width:3px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-\[\#68D99B\]\/30{border-color:oklab(80.2773% -.126598 .0531802/.3)}.border-\[\#263447\]{border-color:#263447}.border-\[\#EEF0F3\]{border-color:#eef0f3}.border-acc{border-color:var(--color-acc)}.border-acc-edge{border-color:var(--color-acc-edge)}.border-bad-edge{border-color:var(--color-bad-edge)}.border-card{border-color:var(--color-card)}.border-edge{border-color:var(--color-edge)}.border-edge\/70{border-color:#e3e6ebb3}@supports (color:color-mix(in lab, red, red)){.border-edge\/70{border-color:color-mix(in oklab, var(--color-edge) 70%, transparent)}}.border-ink{border-color:var(--color-ink)}.border-ok-edge{border-color:var(--color-ok-edge)}.border-warn-edge{border-color:var(--color-warn-edge)}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.border-white\/15{border-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.border-white\/15{border-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.border-l-bad{border-left-color:var(--color-bad)}.border-l-warn-fg{border-left-color:var(--color-warn-fg)}.bg-\[\#5DDB9D\]{background-color:#5ddb9d}.bg-\[\#7A8496\]{background-color:#7a8496}.bg-\[\#68D99B\]{background-color:#68d99b}.bg-\[\#68D99B\]\/10{background-color:oklab(80.2773% -.126598 .0531802/.1)}.bg-\[\#111827\]{background-color:#111827}.bg-\[\#B9F4D2\]{background-color:#b9f4d2}.bg-\[\#D6DAE0\]{background-color:#d6dae0}.bg-\[\#EEF0F3\]{background-color:#eef0f3}.bg-\[\#F3B66A\]{background-color:#f3b66a}.bg-\[\#FBFBFC\]{background-color:#fbfbfc}.bg-\[rgb\(23_33_45\/0\.34\)\]{background-color:#17212d57}.bg-acc-bg{background-color:var(--color-acc-bg)}.bg-bad{background-color:var(--color-bad)}.bg-bad-bg{background-color:var(--color-bad-bg)}.bg-bg{background-color:var(--color-bg)}.bg-card{background-color:var(--color-card)}.bg-current{background-color:currentColor}.bg-faint{background-color:var(--color-faint)}.bg-ink{background-color:var(--color-ink)}.bg-ok{background-color:var(--color-ok)}.bg-ok-bg{background-color:var(--color-ok-bg)}.bg-warn-bg{background-color:var(--color-warn-bg)}.bg-warn-fg{background-color:var(--color-warn-fg)}.bg-white{background-color:var(--color-white)}.bg-white\/5{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.bg-white\/5{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.bg-white\/35{background-color:#ffffff59}@supports (color:color-mix(in lab, red, red)){.bg-white\/35{background-color:color-mix(in oklab, var(--color-white) 35%, transparent)}}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-\[\#FDF7EA\]{--tw-gradient-from:#fdf7ea;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-warn-bg{--tw-gradient-to:var(--color-warn-bg);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.object-cover{object-fit:cover}.p-0{padding:0}.p-3{padding:calc(var(--spacing) * 3)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-\[18px\]{padding-inline:18px}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-px{padding-block:1px}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-2\.5{padding-top:calc(var(--spacing) * 2.5)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-4\.5{padding-top:calc(var(--spacing) * 4.5)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pr-\[18px\]{padding-right:18px}.pb-1{padding-bottom:var(--spacing)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-3\.5{padding-bottom:calc(var(--spacing) * 3.5)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-\[13px\]{padding-left:13px}.pl-\[18px\]{padding-left:18px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-\[-3px\]{vertical-align:-3px}.align-middle{vertical-align:middle}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-\[10\.5px\]{font-size:10.5px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13\.5px\]{font-size:13.5px}.text-\[13px\]{font-size:13px}.text-\[14\.5px\]{font-size:14.5px}.text-\[14px\]{font-size:14px}.text-\[15px\]{font-size:15px}.text-\[17px\]{font-size:17px}.text-\[18px\]{font-size:18px}.text-\[26px\]{font-size:26px}.text-\[27px\]{font-size:27px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-\[550\]{--tw-font-weight:550;font-weight:550}.font-\[650\]{--tw-font-weight:650;font-weight:650}.font-\[680\]{--tw-font-weight:680;font-weight:680}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[-0\.01em\]{--tw-tracking:-.01em;letter-spacing:-.01em}.tracking-\[-0\.03em\]{--tw-tracking:-.03em;letter-spacing:-.03em}.tracking-\[-0\.015em\]{--tw-tracking:-.015em;letter-spacing:-.015em}.tracking-\[0\.04em\]{--tw-tracking:.04em;letter-spacing:.04em}.tracking-\[0\.05em\]{--tw-tracking:.05em;letter-spacing:.05em}.tracking-\[0\.06em\]{--tw-tracking:.06em;letter-spacing:.06em}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#93A4B8\]{color:#93a4b8}.text-\[\#10251A\]{color:#10251a}.text-\[\#A7EDC5\]{color:#a7edc5}.text-\[\#D8E2F0\]{color:#d8e2f0}.text-\[\#FFB4B4\]{color:#ffb4b4}.text-acc{color:var(--color-acc)}.text-bad{color:var(--color-bad)}.text-faint{color:var(--color-faint)}.text-ink{color:var(--color-ink)}.text-mut{color:var(--color-mut)}.text-ok{color:var(--color-ok)}.text-warn{color:var(--color-warn)}.text-warn-fg{color:var(--color-warn-fg)}.text-white{color:var(--color-white)}.text-white\/45{color:#ffffff73}@supports (color:color-mix(in lab, red, red)){.text-white\/45{color:color-mix(in oklab, var(--color-white) 45%, transparent)}}.text-white\/60{color:#fff9}@supports (color:color-mix(in lab, red, red)){.text-white\/60{color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.text-white\/65{color:#ffffffa6}@supports (color:color-mix(in lab, red, red)){.text-white\/65{color:color-mix(in oklab, var(--color-white) 65%, transparent)}}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab, red, red)){.text-white\/80{color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.uppercase{text-transform:uppercase}.not-italic{font-style:normal}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.opacity-45{opacity:.45}.opacity-55{opacity:.55}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_0_0_3px_rgb\(104_217_155\/0\.12\)\]{--tw-shadow:0 0 0 3px var(--tw-shadow-color,#68d99b1f);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_0_48px_rgb\(23_33_45\/0\.18\)\]{--tw-shadow:0 0 48px var(--tw-shadow-color,#17212d2e);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_8px_24px_rgb\(14_24_36\/0\.16\)\]{--tw-shadow:0 8px 24px var(--tw-shadow-color,#0e182429);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_16px_48px_rgb\(23_33_45\/0\.24\)\]{--tw-shadow:0 16px 48px var(--tw-shadow-color,#17212d3d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[inset_0_0_0_1px_rgb\(255_255_255\/0\.3\)\]{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#ffffff4d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-card{--tw-shadow:0 1px 2px var(--tw-shadow-color,#17212d0d), 0 7px 22px var(--tw-shadow-color,#17212d09);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-\[1px\]{--tw-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.running{animation-play-state:running}.group-focus-within\:opacity-100:is(:where(.group):focus-within *){opacity:1}@media (hover:hover){.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.last\:border-r-0:last-child{border-right-style:var(--tw-border-style);border-right-width:0}.last\:border-none:last-child{--tw-border-style:none;border-style:none}@media (hover:hover){.hover\:border-edge2:hover{border-color:var(--color-edge2)}.hover\:bg-\[\#2E3C4E\]:hover{background-color:#2e3c4e}.hover\:bg-\[\#F7F8FA\]:hover{background-color:#f7f8fa}.hover\:bg-acc-bg:hover{background-color:var(--color-acc-bg)}.hover\:bg-bg:hover{background-color:var(--color-bg)}.hover\:bg-white\/8:hover{background-color:#ffffff14}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/8:hover{background-color:color-mix(in oklab, var(--color-white) 8%, transparent)}}.hover\:text-acc:hover{color:var(--color-acc)}.hover\:text-ink:hover{color:var(--color-ink)}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:brightness-110:hover{--tw-brightness:brightness(110%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-45:disabled{opacity:.45}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=closed\]\:slide-out-to-right[data-state=closed]{--tw-exit-translate-x:100%}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[state\=open\]\:slide-in-from-right[data-state=open]{--tw-enter-translate-x:100%}@media not all and (width>=1400px){.max-\[1400px\]\:grid-cols-\[minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr)}}@media not all and (width>=1150px){.max-\[1150px\]\:flex{display:flex}.max-\[1150px\]\:basis-full{flex-basis:100%}.max-\[1150px\]\:grid-cols-\[minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr)}.max-\[1150px\]\:flex-wrap{flex-wrap:wrap}.max-\[1150px\]\:gap-1{gap:var(--spacing)}}@media not all and (width>=940px){.max-\[940px\]\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}}@media not all and (width>=860px){.max-\[860px\]\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}}@media not all and (width>=760px){.max-\[760px\]\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}}@media not all and (width>=40rem){.max-sm\:order-last{order:9999}.max-sm\:basis-full{flex-basis:100%}.max-sm\:pl-\[26px\]{padding-left:26px}}@media (width>=40rem){.sm\:order-none{order:0}.sm\:w-auto{width:auto}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:var(--color-bg)}body{background-color:var(--color-bg);font-family:var(--font-sans);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-ink);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-image:linear-gradient(#17212d06 1px,#0000 1px),linear-gradient(90deg,#17212d06 1px,#0000 1px);background-size:32px 32px;min-width:320px;min-height:100vh}::selection{color:#10251a;background:#b9f4d2}a,button,input,select,textarea{transition:color .12s,background-color .12s,border-color .12s,box-shadow .12s,opacity .12s}:where(a,button,input,select,textarea):focus-visible{outline:2px solid var(--color-acc);outline-offset:2px}button:not(:disabled){cursor:pointer}.data-card table{min-width:max(100%,680px)}.data-card tbody tr:last-child td{border-bottom:0}@media (width<=1150px){.c-host{display:none}}@media (width<=940px){.c-att{display:none}}@media (width<=640px){.data-card table{min-width:560px}.data-card :is(th,td).c-actions{z-index:1;background:#fffffff0;position:sticky;right:0}@supports (color:color-mix(in lab, red, red)){.data-card :is(th,td).c-actions{background:color-mix(in srgb, var(--color-card) 94%, transparent)}}.data-card :is(th,td).c-actions{box-shadow:-8px 0 14px #17212d0d}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0));filter:blur(var(--tw-exit-blur,0))}} diff --git a/internal/serve/dist/assets/index-DlC1aBiV.js b/internal/serve/dist/assets/index-DlC1aBiV.js new file mode 100644 index 00000000..4807111a --- /dev/null +++ b/internal/serve/dist/assets/index-DlC1aBiV.js @@ -0,0 +1,16 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/OverviewRoute-DRxoPlyS.js","assets/ui-Bim2uazI.js","assets/useOperation-BqNdl-Dh.js","assets/ReposRoute-Du6RfsHV.js","assets/BotsRoute-Coq2BPzC.js","assets/SetupRoute-6djY24Gk.js","assets/SettingsRoute-zk9_WV54.js","assets/PRRoute-B5ErkSzr.js"])))=>i.map(i=>d[i]); +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var S=Array.isArray;function C(){}var w={H:null,A:null,T:null,S:null},ee=Object.prototype.hasOwnProperty;function te(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function ne(e,t){return te(e.type,t,e.props)}function re(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function ie(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var ae=/\/+/g;function oe(e,t){return typeof e==`object`&&e&&e.key!=null?ie(``+e.key):t.toString(36)}function se(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(C,C):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function ce(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,ce(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+oe(e,0):a,S(o)?(i=``,c!=null&&(i=c.replace(ae,`$&/`)+`/`),ce(o,r,i,``,function(e){return e})):o!=null&&(re(o)&&(o=ne(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(ae,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(S(e))for(var u=0;u{t.exports=l()})),d=c(u(),1),f=d.use,p=typeof window<`u`?d.useLayoutEffect:d.useEffect;function m(e){let t=d.useRef({value:e,prev:null}),n=t.current.value;return e!==n&&(t.current={value:e,prev:n}),t.current.prev}function h(e,t,n={},r={}){d.useEffect(()=>{if(!e.current||r.disabled||typeof IntersectionObserver!=`function`)return;let i=new IntersectionObserver(([e])=>{t(e)},n);return i.observe(e.current),()=>{i.disconnect()}},[t,n,r.disabled,e])}function g(e){let t=d.useRef(null);return d.useImperativeHandle(e,()=>t.current,[]),t}function _(e){return e[e.length-1]}function v(e){return typeof e==`function`}function y(e,t){return v(e)?e(t):e}var b=Object.prototype.hasOwnProperty,x=Object.prototype.propertyIsEnumerable;function S(e){for(let t in e)if(b.call(e,t))return!0;return!1}var C=()=>Object.create(null),w=(e,t)=>ee(e,t,C);function ee(e,t,n=()=>({}),r=0){if(e===t)return e;if(r>500)return t;let i=t,a=ie(e)&&ie(i);if(!a&&!(ne(e)&&ne(i)))return i;let o=a?e:te(e);if(!o)return i;let s=a?i:te(i);if(!s)return i;let c=o.length,l=s.length,u=a?Array(l):n(),d=0;for(let t=0;ti||!ae(e[o],t[o],n)))return!1;return i===a}return!1}function oe(e){let t,n,r=new Promise((e,r)=>{t=e,n=r});return r.status=`pending`,r.resolve=n=>{r.status=`resolved`,r.value=n,t(n),e?.(n)},r.reject=e=>{r.status=`rejected`,n(e)},r}function se(e){return typeof e?.message==`string`?e.message.startsWith(`Failed to fetch dynamically imported module`)||e.message.startsWith(`error loading dynamically imported module`)||e.message.startsWith(`Importing a module script failed`):!1}function ce(e){return!!(e&&typeof e==`object`&&typeof e.then==`function`)}var T=/[\x00-\x1f\x7f"<>`{}]/g;function E(e){return e.replace(T,e=>`%`+e.charCodeAt(0).toString(16).toUpperCase().padStart(2,`0`))}function le(e){let t;try{t=decodeURI(e)}catch{t=e.replaceAll(/%[0-9A-F]{2}/gi,e=>{try{return decodeURI(e)}catch{return e}})}return E(t)}var ue=[`http:`,`https:`,`mailto:`,`tel:`];function de(e,t){if(!e)return!1;try{let n=new URL(e);return!t.has(n.protocol)}catch{return!1}}function fe(e){if(!e||!/[%\\\x00-\x1f\x7f]/.test(e)&&!e.startsWith(`//`))return{path:e,handledProtocolRelativeURL:!1};let t=/%25|%5C/gi,n=0,r=``,i;for(;(i=t.exec(e))!==null;)r+=le(e.slice(n,i.index))+i[0],n=t.lastIndex;r+=le(n?e.slice(n):e);let a=!1;return r.startsWith(`//`)&&(a=!0,r=`/`+r.replace(/^\/+/,``)),{path:r,handledProtocolRelativeURL:a}}function pe(e){return/\s|[^\u0000-\u007F]/.test(e)?e.replace(/\s|[^\u0000-\u007F]/gu,encodeURIComponent):e}function D(e,t){if(e===t)return!0;if(e.length!==t.length)return!1;for(let n=0;n{e.next&&(e.prev?(e.prev.next=e.next,e.next.prev=e.prev,e.next=void 0,r&&(r.next=e,e.prev=r)):(e.next.prev=void 0,n=e.next,e.next=void 0,r&&(e.prev=r,r.next=e)),r=e)};return{get(e){let n=t.get(e);if(n)return i(n),n.value},set(a,o){if(t.size>=e&&n){let e=n;t.delete(e.key),e.next&&(n=e.next,e.next.prev=void 0),e===r&&(r=void 0)}let s=t.get(a);if(s)s.value=o,i(s);else{let e={key:a,value:o,prev:r};r&&(r.next=e),r=e,n||=e,t.set(a,e)}},clear(){t.clear(),n=void 0,r=void 0}}}var ge=4,_e=5;function ve(e){let t=e.indexOf(`{`);if(t===-1)return null;let n=e.indexOf(`}`,t);return n===-1||t+1>=e.length?null:[t,n]}function ye(e,t,n=new Uint16Array(6)){let r=e.indexOf(`/`,t),i=r===-1?e.length:r,a=e.substring(t,i);if(!a||!a.includes(`$`))return n[0]=0,n[1]=t,n[2]=t,n[3]=i,n[4]=i,n[5]=i,n;if(a===`$`){let r=e.length;return n[0]=2,n[1]=t,n[2]=t,n[3]=r,n[4]=r,n[5]=r,n}if(a.charCodeAt(0)===36)return n[0]=1,n[1]=t,n[2]=t+1,n[3]=i,n[4]=i,n[5]=i,n;let o=ve(a);if(o){let[r,s]=o,c=a.charCodeAt(r+1);if(c===45){if(r+2!e.parse&&e.caseSensitive===f&&e.prefix===p&&e.suffix===m);if(h)o=h;else{let e=we(1,n.fullPath??n.from,f,p,m);o=e,e.depth=a,e.parent=i,i.dynamic??=[],i.dynamic.push(e)}break}case 3:{let t=r.substring(u,e[1]),s=r.substring(e[4],d),f=c&&!!(t||s),p=t?f?t:t.toLowerCase():void 0,m=s?f?s:s.toLowerCase():void 0,h=!l&&i.optional?.find(e=>!e.parse&&e.caseSensitive===f&&e.prefix===p&&e.suffix===m);if(h)o=h;else{let e=we(3,n.fullPath??n.from,f,p,m);o=e,e.parent=i,e.depth=a,i.optional??=[],i.optional.push(e)}break}case 2:{let t=r.substring(u,e[1]),s=r.substring(e[4],d),l=c&&!!(t||s),f=t?l?t:t.toLowerCase():void 0,p=s?l?s:s.toLowerCase():void 0,m=we(2,n.fullPath??n.from,l,f,p);o=m,m.parent=i,m.depth=a,i.wildcard??=[],i.wildcard.push(m)}}i=o}if(l&&n.children&&!n.isRoot&&n.id&&n.id.charCodeAt(n.id.lastIndexOf(`/`)+1)===95){let e=Ce(n.fullPath??n.from);e.kind=_e,e.parent=i,a++,e.depth=a,i.pathless??=[],i.pathless.push(e),i=e}let u=(n.path||!n.children)&&!n.isRoot;if(u&&r.endsWith(`/`)){let e=Ce(n.fullPath??n.from);e.kind=ge,e.parent=i,a++,e.depth=a,i.index=e,i=e}i.parse=l??null,i.priority=n.options?.params?.priority??0,u&&!i.route&&(i.route=n,i.fullPath=n.fullPath??n.from)}if(n.children)for(let r of n.children)be(e,t,r,s,i,a,o)}function xe(e,t){if(e.parse&&!t.parse)return-1;if(!e.parse&&t.parse)return 1;if(e.parse&&t.parse&&(e.priority||t.priority))return t.priority-e.priority;if(e.prefix&&t.prefix&&e.prefix!==t.prefix){if(e.prefix.startsWith(t.prefix))return-1;if(t.prefix.startsWith(e.prefix))return 1}if(e.suffix&&t.suffix&&e.suffix!==t.suffix){if(e.suffix.endsWith(t.suffix))return-1;if(t.suffix.endsWith(e.suffix))return 1}return e.prefix&&!t.prefix?-1:!e.prefix&&t.prefix?1:e.suffix&&!t.suffix?-1:!e.suffix&&t.suffix?1:e.caseSensitive&&!t.caseSensitive?-1:!e.caseSensitive&&t.caseSensitive?1:0}function Se(e){if(e.pathless)for(let t of e.pathless)Se(t);if(e.static)for(let t of e.static.values())Se(t);if(e.staticInsensitive)for(let t of e.staticInsensitive.values())Se(t);if(e.dynamic?.length){e.dynamic.sort(xe);for(let t of e.dynamic)Se(t)}if(e.optional?.length){e.optional.sort(xe);for(let t of e.optional)Se(t)}if(e.wildcard?.length){e.wildcard.sort(xe);for(let t of e.wildcard)Se(t)}}function Ce(e){return{kind:0,depth:0,pathless:null,index:null,static:null,staticInsensitive:null,dynamic:null,optional:null,wildcard:null,route:null,fullPath:e,parent:null,parse:null,priority:0}}function we(e,t,n,r,i){return{kind:e,depth:0,pathless:null,index:null,static:null,staticInsensitive:null,dynamic:null,optional:null,wildcard:null,route:null,fullPath:t,parent:null,parse:null,priority:0,caseSensitive:n,prefix:r,suffix:i}}function Te(e,t){let n=Ce(`/`),r=new Uint16Array(6);for(let t of e)be(!1,r,t,1,n,0);Se(n),t.masksTree=n,t.flatCache=he(1e3)}function Ee(e,t){e||=`/`;let n=t.flatCache.get(e);if(n)return n;let r=je(e,t.masksTree);return t.flatCache.set(e,r),r}function De(e,t,n,r,i){e||=`/`,r||=`/`;let a=t?`case\0${e}`:e,o=i.singleCache.get(a);return o||(o=Ce(`/`),be(t,new Uint16Array(6),{from:e},1,o,0),i.singleCache.set(a,o)),je(r,o,n)}function Oe(e,t,n=!1){let r=n?e:`nofuzz\0${e}`,i=t.matchCache.get(r);if(i!==void 0)return i;e||=`/`;let a;try{a=je(e,t.segmentTree,n)}catch(e){if(e instanceof URIError)a=null;else throw e}return a&&(a.branch=Ne(a.route)),t.matchCache.set(r,a),a}function ke(e){return e===`/`?e:e.replace(/\/{1,}$/,``)}function Ae(e,t=!1,n){let r=Ce(e.fullPath),i=new Uint16Array(6),a={},o={},s=0;return be(t,i,e,1,r,0,e=>{if(n?.(e,s),e.id in a&&me(),a[e.id]=e,s!==0&&e.path){let t=ke(e.fullPath);(!o[t]||e.fullPath.endsWith(`/`))&&(o[t]=e)}s++}),Se(r),{processedTree:{segmentTree:r,singleCache:he(1e3),matchCache:he(1e3),flatCache:null,masksTree:null},routesById:a,routesByPath:o}}function je(e,t,n=!1){let r=e.split(`/`),i=Fe(e,r,t,n);if(!i)return null;let[a]=Me(e,r,i);return{route:i.node.route,rawParams:a}}function Me(e,t,n){let r=Pe(n.node),i=null,a=Object.create(null),o=n.extract?.part??0,s=n.extract?.node??0,c=n.extract?.path??0,l=n.extract?.segment??0;for(;s=0;e--){let n=i.wildcard[e],{prefix:r,suffix:a}=n;if(!(r&&(v||!(n.caseSensitive?y:b??=y.toLowerCase()).startsWith(r)))){if(a){if(v)continue;let e=t.slice(u).join(`/`).slice(-a.length);if((n.caseSensitive?e:e.toLowerCase())!==a)continue}s.push({node:n,index:o,skipped:d,depth:f+1,statics:p,dynamics:m,optionals:h,extract:g,rawParams:_})}}if(i.optional){let e=d|1<=0;n--){let r=i.optional[n];s.push({node:r,index:u,skipped:e,depth:t,statics:p,dynamics:m,optionals:h,extract:g,rawParams:_})}if(!v)for(let e=i.optional.length-1;e>=0;e--){let n=i.optional[e],{prefix:r,suffix:a}=n;if(r||a){let e=n.caseSensitive?y:b??=y.toLowerCase();if(r&&!e.startsWith(r)||a&&!e.endsWith(a))continue}s.push({node:n,index:u+1,skipped:d,depth:t,statics:p,dynamics:m,optionals:h+Ie(o,u),extract:g,rawParams:_})}}if(!v&&i.dynamic&&y)for(let e=i.dynamic.length-1;e>=0;e--){let t=i.dynamic[e],{prefix:n,suffix:r}=t;if(n||r){let e=t.caseSensitive?y:b??=y.toLowerCase();if(n&&!e.startsWith(n)||r&&!e.endsWith(r))continue}s.push({node:t,index:u+1,skipped:d,depth:f+1,statics:p,dynamics:m+Ie(o,u),optionals:h,extract:g,rawParams:_})}if(!v&&i.staticInsensitive){let e=i.staticInsensitive.get(b??=y.toLowerCase());e&&s.push({node:e,index:u+1,skipped:d,depth:f+1,statics:p+Ie(o,u),dynamics:m,optionals:h,extract:g,rawParams:_})}if(!v&&i.static){let e=i.static.get(y);e&&s.push({node:e,index:u+1,skipped:d,depth:f+1,statics:p+Ie(o,u),dynamics:m,optionals:h,extract:g,rawParams:_})}if(i.pathless){let e=f+1;for(let t=i.pathless.length-1;t>=0;t--){let n=i.pathless[t];s.push({node:n,index:u,skipped:d,depth:e,statics:p,dynamics:m,optionals:h,extract:g,rawParams:_})}}}if(l)return l;if(r&&c){let n=c.index;for(let e=0;ee.statics||t.statics===e.statics&&(t.dynamics>e.dynamics||t.dynamics===e.dynamics&&(t.optionals>e.optionals||t.optionals===e.optionals&&((t.node.kind===ge)>(e.node.kind===ge)||t.node.kind===ge==(e.node.kind===ge)&&t.depth>e.depth)))}function Be(e){return Ve(e.filter(e=>e!==void 0).join(`/`))}function Ve(e){return e.replace(/\/{2,}/g,`/`)}function He(e){return e===`/`?e:e.replace(/^\/{1,}/,``)}function Ue(e){let t=e.length;return t>1&&e[t-1]===`/`?e.replace(/\/{1,}$/,``):e}function We(e){return Ue(He(e))}function Ge(e,t){return e?.endsWith(`/`)&&e!==`/`&&e!==`${t}/`?e.slice(0,-1):e}function Ke(e,t,n){return Ge(e,n)===Ge(t,n)}function qe({base:e,to:t,trailingSlash:n=`never`,cache:r}){let i=t.startsWith(`/`),a=!i&&t===`.`,o;if(r){o=i?t:a?e:e+`\0`+t;let n=r.get(o);if(n)return n}let s;if(a)s=e.split(`/`);else if(i)s=t.split(`/`);else{for(s=e.split(`/`);s.length>1&&_(s)===``;)s.pop();let n=t.split(`/`);for(let e=0,t=n.length;e1&&(_(s)===``?n===`never`&&s.pop():n===`always`&&s.push(``));let c=Ve(s.join(`/`))||`/`;return o&&r&&r.set(o,c),c}function Je(e){let t=new Map(e.map(e=>[encodeURIComponent(e),e])),n=Array.from(t.keys()).map(e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`)).join(`|`),r=new RegExp(n,`g`);return e=>e.replace(r,e=>t.get(e)??e)}function Ye(e,t,n){let r=t[e];return typeof r==`string`?e===`_splat`?/^[a-zA-Z0-9\-._~!/]*$/.test(r)?r:r.split(`/`).map(e=>Ze(e,n)).join(`/`):Ze(r,n):r}function Xe({path:e,params:t,decoder:n,...r}){let i=!1,a=Object.create(null);if(!e||e===`/`)return{interpolatedPath:`/`,usedParams:a,isMissingParams:i};if(!e.includes(`$`))return{interpolatedPath:e,usedParams:a,isMissingParams:i};let o=e.length,s=0,c,l=``;for(;se.state.__TSR_key||e.href;function ct(e){let t=e.getAttribute(ot);if(t)return`[${ot}="${t}"]`;let n=``,r=e,i;for(;i=r.parentNode;){let e=1,t=r;for(;t=t.previousElementSibling;)e++;let a=`${r.localName}:nth-child(${e})`;n=n?`${a} > ${n}`:a,r=i}return n}var lt=!1,ut=`window`;function dt(e){try{return typeof e==`function`?e():document.querySelector(e)}catch{}}function ft(e){let t=new Set;for(let n of e){if(n===ut)continue;let e=dt(n);e&&t.add(e)}return t}function pt(e,t){let n=t??e.options.scrollRestoration,r=e._scroll;n&&(r.restoring=!0);let i=e.options.getScrollRestorationKey||st,a=new Set,o=e=>{let t=at[e]||={};for(let e of a)e===document?t[ut]={scrollX,scrollY}:e.isConnected&&(t[ct(e)]={scrollX:e.scrollLeft,scrollY:e.scrollTop})};n&&!r.restoration&&(r.restoration=!0,lt=!1,history.scrollRestoration=`manual`,document.addEventListener(`scroll`,e=>{lt||a.add(e.target)},!0),e.subscribe(`onBeforeLoad`,e=>{e.fromLocation&&o(i(e.fromLocation)),a.clear()}),addEventListener(`pagehide`,()=>{o(i(e.stores.resolvedLocation.get()??e.stores.location.get())),it()})),!r.reset&&(r.reset=!0,e.subscribe(`onRendered`,t=>{let n=e.options.scrollRestorationBehavior,o=e.options.scrollToTopSelectors,s=r.next,c=r.hash,l;if(a.clear(),r.next=!0,r.hash=!1,typeof e.options.scrollRestoration==`function`&&!e.options.scrollRestoration({location:e.latestLocation}))return;let u=i(t.toLocation),d=t.fromLocation&&i(t.fromLocation);if(r.restoring&&d&&d!==u){let e=at[d];if(e){let t=at[u];for(let n in e){if(n===ut){if(s)continue}else{let e=dt(n);if(!e||s&&o&&(l??=ft(o),l.has(e)))continue}t||=at[u]={},t[n]??=e[n]}}}lt=!0;try{let e=t.toLocation.hash,i=t.toLocation.state.__hashScrollIntoViewOptions??!0,a=!1;if(s){!e&&o&&(l??=ft(o));let t=e&&i&&c,s=r.restoring?at[u]:void 0;if(s)for(let e in s){let{scrollX:r,scrollY:i}=s[e];if(e===ut){if(t)continue;scrollTo({top:i,left:r,behavior:n}),a=!0}else{let t=dt(e);t&&(t.scrollLeft=r,t.scrollTop=i,l?.delete(t))}}if(!e){let e={top:0,left:0,behavior:n};if(a||scrollTo(e),l)for(let t of l)t.scrollTo(e)}}!a&&e&&i&&document.getElementById(e)?.scrollIntoView(i)}finally{lt=!1}}))}function mt(e,t=String){let n=new URLSearchParams;for(let r in e){let i=e[r];i!==void 0&&n.set(r,t(i))}return n.toString()}function ht(e){return e?e===`false`?!1:e===`true`?!0:e*0==0&&+e+``===e?+e:e:``}function gt(e){let t=new URLSearchParams(e),n=Object.create(null);for(let[e,r]of t.entries()){let t=n[e];t==null?n[e]=ht(r):Array.isArray(t)?t.push(ht(r)):n[e]=[t,ht(r)]}return n}var _t=yt(JSON.parse),vt=bt(JSON.stringify,JSON.parse);function yt(e){return t=>{t[0]===`?`&&(t=t.substring(1));let n=gt(t);for(let t in n){let r=n[t];if(typeof r==`string`)try{n[t]=e(r)}catch{}}return n}}function bt(e,t){let n=typeof t==`function`;function r(r){if(typeof r==`object`&&r)try{return e(r)}catch{}else if(n&&typeof r==`string`)try{return t(r),e(r)}catch{}return r}return e=>{let t=mt(e,r);return t?`?${t}`:``}}var xt=`__root__`;function St(e){if(e.statusCode=e.statusCode||e.code||307,!e._builtLocation&&!e.reloadDocument&&typeof e.href==`string`)try{new URL(e.href),e.reloadDocument=!0}catch{}let t=new Headers(e.headers);e.href&&t.get(`Location`)===null&&t.set(`Location`,e.href);let n=new Response(null,{status:e.statusCode,headers:t});if(n.options=e,e.throw)throw n;return n}function Ct(e){return e instanceof Response&&!!e.options}function wt(e){return{input:({url:t})=>{for(let n of e)t=Et(n,t);return t},output:({url:t})=>{for(let n=e.length-1;n>=0;n--)t=Dt(e[n],t);return t}}}function Tt(e){let t=We(e.basepath),n=`/${t}`,r=e.caseSensitive?n:n.toLowerCase(),i=`${r}/`;return{input:({url:t})=>{let a=e.caseSensitive?t.pathname:t.pathname.toLowerCase();return a===r?t.pathname=`/`:a.startsWith(i)&&(t.pathname=t.pathname.slice(n.length)),t},output:({url:e})=>(e.pathname=Be([`/`,t,e.pathname]),e)}}function Et(e,t){let n=e?.input?.({url:t});if(n){if(typeof n==`string`)return new URL(n);if(n instanceof URL)return n}return t}function Dt(e,t){let n=e?.output?.({url:t});if(n){if(typeof n==`string`)return new URL(n);if(n instanceof URL)return n}return t}function Ot(e,t){let{createMutableStore:n,createReadonlyStore:r,batch:i,init:a}=t,o=new Map,s=new Map,c=new Map,l=n(e.status),u=n(e.loadedAt),d=n(e.isLoading),f=n(e.isTransitioning),p=n(e.location),m=n(e.resolvedLocation),h=n(e.statusCode),g=n(e.redirect),_=n([]),v=n([]),y=n([]),b=r(()=>kt(o,_.get())),x=r(()=>kt(s,v.get())),S=r(()=>kt(c,y.get())),C=r(()=>_.get()[0]),w=r(()=>_.get().some(e=>o.get(e)?.get().status===`pending`)),ee=r(()=>({locationHref:p.get().href,resolvedLocationHref:m.get()?.href,status:l.get()})),te=r(()=>({status:l.get(),loadedAt:u.get(),isLoading:d.get(),isTransitioning:f.get(),matches:b.get(),location:p.get(),resolvedLocation:m.get(),statusCode:h.get(),redirect:g.get()})),ne=he(64);function re(e){let t=ne.get(e);return t||(t=r(()=>{let t=_.get();for(let n of t){let t=o.get(n);if(t&&t.routeId===e)return t.get()}}),ne.set(e,t)),t}let ie={status:l,loadedAt:u,isLoading:d,isTransitioning:f,location:p,resolvedLocation:m,statusCode:h,redirect:g,matchesId:_,pendingIds:v,cachedIds:y,matches:b,pendingMatches:x,cachedMatches:S,firstId:C,hasPending:w,matchRouteDeps:ee,matchStores:o,pendingMatchStores:s,cachedMatchStores:c,__store:te,getRouteMatchStore:re,setMatches:ae,setPending:oe,setCached:se};ae(e.matches),a?.(ie);function ae(e){At(e,o,_,n,i)}function oe(e){At(e,s,v,n,i)}function se(e){At(e,c,y,n,i)}return ie}function kt(e,t){let n=[];for(let r of t){let t=e.get(r);t&&n.push(t.get())}return n}function At(e,t,n,r,i){let a=e.map(e=>e.id),o=new Set(a);i(()=>{for(let e of t.keys())o.has(e)||t.delete(e);for(let n of e){let e=t.get(n.id);if(!e){let e=r(n);e.routeId=n.routeId,t.set(n.id,e);continue}e.routeId=n.routeId,e.get()!==n&&e.set(n)}D(n.get(),a)||n.set(a)})}var jt=e=>{if(!e.rendered)return e.rendered=!0,e.onReady?.()},Mt=e=>e.stores.matchesId.get().some(t=>e.stores.matchStores.get(t)?.get()._forcePending),Nt=(e,t)=>!!(e.preload&&!e.router.stores.matchStores.has(t)),Pt=(e,t,n=!0)=>{let r={...e.router.options.context??{}},i=n?t:t-1;for(let t=0;t<=i;t++){let n=e.matches[t];if(!n)continue;let i=e.router.getMatch(n.id);i&&Object.assign(r,i.__routeContext,i.__beforeLoadContext)}return r},Ft=(e,t)=>{if(!e.matches.length)return;let n=t.routeId,r=e.matches.findIndex(t=>t.routeId===e.router.routeTree.id),i=r>=0?r:0,a=n?e.matches.findIndex(e=>e.routeId===n):e.firstBadMatchIndex??e.matches.length-1;a<0&&(a=i);for(let t=a;t>=0;t--){let n=e.matches[t];if(e.router.looseRoutesById[n.routeId].options.notFoundComponent)return t}return n?a:i},It=(e,t,n)=>{if(!(!Ct(n)&&!$e(n)))throw Ct(n)&&n.redirectHandled&&!n.options.reloadDocument?n:(t&&(t._nonReactive.beforeLoadPromise?.resolve(),t._nonReactive.loaderPromise?.resolve(),t._nonReactive.beforeLoadPromise=void 0,t._nonReactive.loaderPromise=void 0,t._nonReactive.error=n,e.updateMatch(t.id,r=>({...r,status:Ct(n)?`redirected`:$e(n)?`notFound`:r.status===`pending`?`success`:r.status,context:Pt(e,t.index),isFetching:!1,error:n})),$e(n)&&!n.routeId&&(n.routeId=t.routeId),t._nonReactive.loadPromise?.resolve()),Ct(n)&&(e.rendered=!0,n.options._fromLocation=e.location,n.redirectHandled=!0,n=e.router.resolveRedirect(n)),n)},Lt=(e,t)=>{let n=e.router.getMatch(t);return!!(!n||n._nonReactive.dehydrated)},Rt=(e,t,n)=>{let r=Pt(e,n);e.updateMatch(t,e=>({...e,context:r}))},zt=(e,t,n)=>{let{id:r,routeId:i}=e.matches[t],a=e.router.looseRoutesById[i];if(n instanceof Promise)throw n;e.firstBadMatchIndex??=t,It(e,e.router.getMatch(r),n);try{a.options.onError?.(n)}catch(t){n=t,It(e,e.router.getMatch(r),n)}e.updateMatch(r,e=>(e._nonReactive.beforeLoadPromise?.resolve(),e._nonReactive.beforeLoadPromise=void 0,e._nonReactive.loadPromise?.resolve(),{...e,error:n,status:`error`,isFetching:!1,updatedAt:Date.now(),abortController:new AbortController})),!e.preload&&!Ct(n)&&!$e(n)&&(e.serialError??=n)},Bt=(e,t,n,r)=>{if(r._nonReactive.pendingTimeout!==void 0)return;let i=n.options.pendingMs??e.router.options.defaultPendingMs;if(e.onReady&&!Nt(e,t)&&(n.options.loader||n.options.beforeLoad||Zt(n))&&typeof i==`number`&&i!==1/0&&(n.options.pendingComponent??e.router.options?.defaultPendingComponent)){let t=setTimeout(()=>{jt(e)},i);r._nonReactive.pendingTimeout=t}},Vt=(e,t,n)=>{let r=e.router.getMatch(t);if(!r._nonReactive.beforeLoadPromise&&!r._nonReactive.loaderPromise)return;Bt(e,t,n,r);let i=()=>{let n=e.router.getMatch(t);n.preload&&(n.status===`redirected`||n.status===`notFound`)&&It(e,n,n.error)};return r._nonReactive.beforeLoadPromise?r._nonReactive.beforeLoadPromise.then(i):i()},Ht=(e,t,n,r)=>{let i=e.router.getMatch(t),a=i._nonReactive.loadPromise;i._nonReactive.loadPromise=oe(()=>{a?.resolve(),a=void 0});let{paramsError:o,searchError:s}=i;o&&zt(e,n,o),s&&zt(e,n,s),Bt(e,t,r,i);let c=new AbortController,l=!1,u=()=>{l||(l=!0,e.updateMatch(t,e=>({...e,isFetching:`beforeLoad`,fetchCount:e.fetchCount+1,abortController:c})))},d=()=>{i._nonReactive.beforeLoadPromise?.resolve(),i._nonReactive.beforeLoadPromise=void 0,e.updateMatch(t,e=>({...e,isFetching:!1}))};if(!r.options.beforeLoad){e.router.batch(()=>{u(),d()});return}i._nonReactive.beforeLoadPromise=oe();let f={...Pt(e,n,!1),...i.__routeContext},{search:p,params:m,cause:h}=i,g=Nt(e,t),_={search:p,abortController:c,params:m,preload:g,context:f,location:e.location,navigate:t=>e.router.navigate({...t,_fromLocation:e.location}),buildLocation:e.router.buildLocation,cause:g?`preload`:h,matches:e.matches,routeId:r.id,...e.router.options.additionalContext},v=r=>{if(r===void 0){e.router.batch(()=>{u(),d()});return}(Ct(r)||$e(r))&&(u(),zt(e,n,r)),e.router.batch(()=>{u(),e.updateMatch(t,e=>({...e,__beforeLoadContext:r})),d()})},y;try{if(y=r.options.beforeLoad(_),ce(y))return u(),y.catch(t=>{zt(e,n,t)}).then(v)}catch(t){u(),zt(e,n,t)}v(y)},Ut=(e,t)=>{let{id:n,routeId:r}=e.matches[t],i=e.router.looseRoutesById[r],a=()=>s(),o=()=>Ht(e,n,t,i),s=()=>{if(Lt(e,n))return;let t=Vt(e,n,i);return ce(t)?t.then(o):o()};return a()},Wt=(e,t,n)=>{let r=e.router.getMatch(t);if(!r||!n.options.head&&!n.options.scripts&&!n.options.headers)return;let i={ssr:e.router.options.ssr,matches:e.matches,match:r,params:r.params,loaderData:r.loaderData};return Promise.all([n.options.head?.(i),n.options.scripts?.(i),n.options.headers?.(i)]).then(([e,t,n])=>({meta:e?.meta,links:e?.links,headScripts:e?.scripts,headers:n,scripts:t,styles:e?.styles}))},Gt=(e,t,n,r,i)=>{let a=t[r-1],{params:o,loaderDeps:s,abortController:c,cause:l}=e.router.getMatch(n),u=Pt(e,r),d=Nt(e,n);return{params:o,deps:s,preload:!!d,parentMatchPromise:a,abortController:c,context:u,location:e.location,navigate:t=>e.router.navigate({...t,_fromLocation:e.location}),cause:d?`preload`:l,route:i,...e.router.options.additionalContext}},Kt=async(e,t,n,r,i)=>{try{let a=e.router.getMatch(n);try{Xt(i);let o=i.options.loader,s=typeof o==`function`?o:o?.handler,c=s?.(Gt(e,t,n,r,i)),l=!!s&&ce(c);if((l||i._lazyPromise||i._componentsPromise||i.options.head||i.options.scripts||i.options.headers||a._nonReactive.minPendingPromise)&&e.updateMatch(n,e=>({...e,isFetching:`loader`})),s){let t=l?await c:c;It(e,e.router.getMatch(n),t),t!==void 0&&e.updateMatch(n,e=>({...e,loaderData:t}))}i._lazyPromise&&await i._lazyPromise;let u=a._nonReactive.minPendingPromise;u&&await u,i._componentsPromise&&await i._componentsPromise,e.updateMatch(n,t=>({...t,error:void 0,context:Pt(e,r),status:`success`,isFetching:!1,updatedAt:Date.now()}))}catch(t){let o=t;if(o?.name===`AbortError`){if(a.abortController.signal.aborted){a._nonReactive.loaderPromise?.resolve(),a._nonReactive.loaderPromise=void 0;return}e.updateMatch(n,t=>({...t,status:t.status===`pending`?`success`:t.status,isFetching:!1,context:Pt(e,r)}));return}let s=a._nonReactive.minPendingPromise;s&&await s,$e(t)&&await i.options.notFoundComponent?.preload?.(),It(e,e.router.getMatch(n),t);try{i.options.onError?.(t)}catch(t){o=t,It(e,e.router.getMatch(n),t)}!Ct(o)&&!$e(o)&&await Xt(i,[`errorComponent`]),e.updateMatch(n,t=>({...t,error:o,context:Pt(e,r),status:`error`,isFetching:!1}))}}catch(t){let r=e.router.getMatch(n);r&&(r._nonReactive.loaderPromise=void 0),It(e,r,t)}},qt=async(e,t,n)=>{async function r(r,a,c,l,d){let f=Date.now()-a.updatedAt,p=r?d.options.preloadStaleTime??e.router.options.defaultPreloadStaleTime??3e4:d.options.staleTime??e.router.options.defaultStaleTime??0,m=d.options.shouldReload,h=typeof m==`function`?m(Gt(e,t,i,n,d)):m,{status:g,invalid:_}=l,v=f>=p&&(!!e.forceStaleReload||l.cause===`enter`||c!==void 0&&c!==l.id);o=g===`success`&&(_||(h??v)),r&&d.options.preload===!1||(o&&!e.sync&&u?(s=!0,(async()=>{try{await Kt(e,t,i,n,d);let r=e.router.getMatch(i);r._nonReactive.loaderPromise?.resolve(),r._nonReactive.loadPromise?.resolve(),r._nonReactive.loaderPromise=void 0,r._nonReactive.loadPromise=void 0}catch(t){Ct(t)&&await e.router.navigate(t.options)}})()):g!==`success`||o?await Kt(e,t,i,n,d):Rt(e,i,n))}let{id:i,routeId:a}=e.matches[n],o=!1,s=!1,c=e.router.looseRoutesById[a],l=c.options.loader,u=((typeof l==`function`?void 0:l?.staleReloadMode)??e.router.options.defaultStaleReloadMode)!==`blocking`;if(Lt(e,i)){if(!e.router.getMatch(i))return e.matches[n];Rt(e,i,n)}else{let t=e.router.getMatch(i),o=e.router.stores.matchesId.get()[n],s=(o&&e.router.stores.matchStores.get(o)||null)?.routeId===a?o:e.router.stores.matches.get().find(e=>e.routeId===a)?.id,l=Nt(e,i);if(t._nonReactive.loaderPromise){if(t.status===`success`&&!e.sync&&!t.preload&&u)return t;await t._nonReactive.loaderPromise;let n=e.router.getMatch(i),a=n._nonReactive.error||n.error;a&&It(e,n,a),n.status===`pending`&&await r(l,t,s,n,c)}else{let n=l&&!e.router.stores.matchStores.has(i),a=e.router.getMatch(i);a._nonReactive.loaderPromise=oe(),n!==a.preload&&e.updateMatch(i,e=>({...e,preload:n})),await r(l,t,s,a,c)}}let d=e.router.getMatch(i);s||(d._nonReactive.loaderPromise?.resolve(),d._nonReactive.loadPromise?.resolve(),d._nonReactive.loadPromise=void 0),clearTimeout(d._nonReactive.pendingTimeout),d._nonReactive.pendingTimeout=void 0,s||(d._nonReactive.loaderPromise=void 0),d._nonReactive.dehydrated=void 0;let f=s?d.isFetching:!1;return f!==d.isFetching||d.invalid!==!1?(e.updateMatch(i,e=>({...e,isFetching:f,invalid:!1})),e.router.getMatch(i)):d};async function Jt(e){let t=e,n=[];Mt(t.router)&&jt(t);let r;for(let e=0;e({...e,...a?{status:`success`,globalNotFound:!0,error:void 0}:{status:`notFound`,error:l},isFetching:!1})),u=e,await Xt(r,[`notFoundComponent`])}else if(!t.preload){let e=t.matches[0];e.globalNotFound||t.router.getMatch(e.id)?.globalNotFound&&t.updateMatch(e.id,e=>({...e,globalNotFound:!1,error:void 0}))}if(t.serialError&&t.firstBadMatchIndex!==void 0){let e=t.router.looseRoutesById[t.matches[t.firstBadMatchIndex].routeId];await Xt(e,[`errorComponent`])}for(let e=0;e<=u;e++){let{id:n,routeId:r}=t.matches[e],i=t.router.looseRoutesById[r];try{let e=Wt(t,n,i);if(e){let r=await e;t.updateMatch(n,e=>({...e,...r}))}}catch(e){console.error(`Error executing head for route ${r}:`,e)}}let d=jt(t);if(ce(d)&&await d,l)throw l;if(t.serialError&&!t.preload&&!t.onReady)throw t.serialError;return t.matches}function Yt(e,t){let n=t.map(t=>e.options[t]?.preload?.()).filter(Boolean);if(n.length!==0)return Promise.all(n)}function Xt(e,t=Qt){!e._lazyLoaded&&e._lazyPromise===void 0&&(e.lazyFn?e._lazyPromise=e.lazyFn().then(t=>{let{id:n,...r}=t.options;Object.assign(e.options,r),e._lazyLoaded=!0,e._lazyPromise=void 0}):e._lazyLoaded=!0);let n=()=>e._componentsLoaded?void 0:t===Qt?(()=>{if(e._componentsPromise===void 0){let t=Yt(e,Qt);t?e._componentsPromise=t.then(()=>{e._componentsLoaded=!0,e._componentsPromise=void 0}):e._componentsLoaded=!0}return e._componentsPromise})():Yt(e,t);return e._lazyPromise?e._lazyPromise.then(n):n()}function Zt(e){for(let t of Qt)if(e.options[t]?.preload)return!0;return!1}var Qt=[`component`,`errorComponent`,`pendingComponent`,`notFoundComponent`],$t=`__TSR_index`,en=`popstate`,tn=`beforeunload`;function nn(e){let t=e.getLocation(),n=new Set,r=r=>{t=e.getLocation(),n.forEach(e=>e({location:t,action:r}))},i=n=>{e.notifyOnIndexChange??!0?r(n):t=e.getLocation()},a=async({task:n,navigateOpts:r,...i})=>{if(r?.ignoreBlocker??!1){n();return}let a=e.getBlockers?.()??[],o=i.type===`PUSH`||i.type===`REPLACE`;if(typeof document<`u`&&a.length&&o)for(let n of a){let r=cn(i.path,i.state);if(await n.blockerFn({currentLocation:t,nextLocation:r,action:i.type})){e.onBlocked?.();return}}n()};return{get location(){return t},get length(){return e.getLength()},subscribers:n,subscribe:e=>(n.add(e),()=>{n.delete(e)}),push:(n,i,o)=>{let s=t.state[$t];i=rn(s+1,i),a({task:()=>{e.pushState(n,i),r({type:`PUSH`})},navigateOpts:o,type:`PUSH`,path:n,state:i})},replace:(n,i,o)=>{let s=t.state[$t];i=rn(s,i),a({task:()=>{e.replaceState(n,i),r({type:`REPLACE`})},navigateOpts:o,type:`REPLACE`,path:n,state:i})},go:(t,n)=>{a({task:()=>{e.go(t),i({type:`GO`,index:t})},navigateOpts:n,type:`GO`})},back:t=>{a({task:()=>{e.back(t?.ignoreBlocker??!1),i({type:`BACK`})},navigateOpts:t,type:`BACK`})},forward:t=>{a({task:()=>{e.forward(t?.ignoreBlocker??!1),i({type:`FORWARD`})},navigateOpts:t,type:`FORWARD`})},canGoBack:()=>t.state[$t]!==0,createHref:t=>e.createHref(t),block:t=>{if(!e.setBlockers)return()=>{};let n=e.getBlockers?.()??[];return e.setBlockers([...n,t]),()=>{let n=e.getBlockers?.()??[];e.setBlockers?.(n.filter(e=>e!==t))}},flush:()=>e.flush?.(),destroy:()=>e.destroy?.(),notify:r}}function rn(e,t){t||={};let n=ln();return{...t,key:n,__TSR_key:n,[$t]:e}}function an(e){let t=e?.window??(typeof document<`u`?window:void 0),n=t.history.pushState,r=t.history.replaceState,i=[],a=()=>i,o=e=>i=e,s=e?.createHref??(e=>e),c=e?.parseLocation??(()=>cn(`${t.location.pathname}${t.location.search}${t.location.hash}`,t.history.state));if(!t.history.state?.__TSR_key&&!t.history.state?.key){let e=ln();t.history.replaceState({[$t]:0,key:e,__TSR_key:e},``)}let l=c(),u,d=!1,f=!1,p=!1,m=!1,h=()=>l,g,_,v=()=>{g&&(C._ignoreSubscribers=!0,(g.isPush?t.history.pushState:t.history.replaceState)(g.state,``,g.href),C._ignoreSubscribers=!1,g=void 0,_=void 0,u=void 0)},y=(e,t,n)=>{let r=s(t);_||(u=l),l=cn(t,n),g={href:r,state:n,isPush:g?.isPush||e===`push`},_||=Promise.resolve().then(()=>v())},b=e=>{l=c(),C.notify({type:e})},x=async()=>{if(f){f=!1;return}let e=c(),n=e.state[$t]-l.state[$t],r=n===1,i=n===-1,o=!r&&!i||d;d=!1;let s=o?`GO`:i?`BACK`:`FORWARD`,u=o?{type:`GO`,index:n}:{type:i?`BACK`:`FORWARD`};if(p)p=!1;else{let n=a();if(typeof document<`u`&&n.length){for(let r of n)if(await r.blockerFn({currentLocation:l,nextLocation:e,action:s})){f=!0,t.history.go(1),C.notify(u);return}}}l=c(),C.notify(u)},S=e=>{if(m){m=!1;return}let t=!1,n=a();if(typeof document<`u`&&n.length)for(let e of n){let n=e.enableBeforeUnload??!0;if(n===!0){t=!0;break}if(typeof n==`function`&&n()===!0){t=!0;break}}if(t)return e.preventDefault(),e.returnValue=``},C=nn({getLocation:h,getLength:()=>t.history.length,pushState:(e,t)=>y(`push`,e,t),replaceState:(e,t)=>y(`replace`,e,t),back:e=>(e&&(p=!0),m=!0,t.history.back()),forward:e=>{e&&(p=!0),m=!0,t.history.forward()},go:e=>{d=!0,t.history.go(e)},createHref:e=>s(e),flush:v,destroy:()=>{t.history.pushState=n,t.history.replaceState=r,t.removeEventListener(tn,S,{capture:!0}),t.removeEventListener(en,x)},onBlocked:()=>{u&&l!==u&&(l=u)},getBlockers:a,setBlockers:o,notifyOnIndexChange:!1});return t.addEventListener(tn,S,{capture:!0}),t.addEventListener(en,x),t.history.pushState=function(...e){let r=n.apply(t.history,e);return C._ignoreSubscribers||b(`PUSH`),r},t.history.replaceState=function(...e){let n=r.apply(t.history,e);return C._ignoreSubscribers||b(`REPLACE`),n},C}function on(e){let t=e?.window??(typeof document<`u`?window:void 0);return an({window:t,parseLocation:()=>{let e=t.location.hash.split(`#`).slice(1),n=e[0]??`/`,r=t.location.search,i=e.slice(1);return cn(`${n}${r}${i.length===0?``:`#${i.join(`#`)}`}`,t.history.state)},createHref:e=>`${t.location.pathname}${t.location.search}#${e}`})}function sn(e){let t=e.replace(/[\x00-\x1f\x7f]/g,``);return t.startsWith(`//`)&&(t=`/`+t.replace(/^\/+/,``)),t}function cn(e,t){let n=sn(e),r=n.indexOf(`#`),i=n.indexOf(`?`),a=ln();return{href:n,pathname:n.substring(0,r>0?i>0?Math.min(r,i):r:i>0?i:n.length),hash:r>-1?n.substring(r):``,search:i>-1?n.slice(i,r===-1?void 0:r):``,state:t||{[$t]:0,key:a,__TSR_key:a}}}function ln(){return(Math.random()+1).toString(36).substring(7)}function un(e,t){let n=t,r=e;return{fromLocation:n,toLocation:r,pathChanged:n?.pathname!==r.pathname,hrefChanged:n?.href!==r.href,hashChanged:n?.hash!==r.hash}}var dn=class{constructor(e,t){this.tempLocationKey=`${Math.round(Math.random()*1e7)}`,this._scroll={next:!0},this.shouldViewTransition=void 0,this.isViewTransitionTypesSupported=void 0,this.subscribers=new Set,this.routeBranchCache=new WeakMap,this.lightweightCache=new WeakMap,this.startTransition=e=>e(),this.update=e=>{let t=this.options,n=this.basepath??t?.basepath??`/`,r=this.basepath===void 0,i=t?.rewrite;if(this.options={...t,...e},this.isServer=this.options.isServer??typeof document>`u`,this.protocolAllowlist=new Set(this.options.protocolAllowlist),this.options.pathParamsAllowedCharacters&&(this.pathParamsDecoder=Je(this.options.pathParamsAllowedCharacters)),(!this.history||this.options.history&&this.options.history!==this.history)&&(this.options.history?this.history=this.options.history:this.history=an()),this.origin=this.options.origin,this.origin||(window?.origin&&window.origin!==`null`?this.origin=window.origin:this.origin=`http://localhost`),this.history&&this.updateLatestLocation(),this.options.routeTree!==this.routeTree){this.routeTree=this.options.routeTree;let e;this.resolvePathCache=he(1e3),e=this.buildRouteTree(),this.setRoutes(e)}if(!this.stores&&this.latestLocation){let e=this.getStoreConfig(this);this.batch=e.batch,this.stores=Ot(mn(this.latestLocation),e),pt(this)}let a=!1,o=this.options.basepath??`/`,s=this.options.rewrite;if(r||n!==o||i!==s){this.basepath=o;let e=[],t=We(o);t&&t!==`/`&&e.push(Tt({basepath:o})),s&&e.push(s),this.rewrite=e.length===0?void 0:e.length===1?e[0]:wt(e),this.history&&this.updateLatestLocation(),a=!0}a&&this.stores&&this.stores.location.set(this.latestLocation),typeof window<`u`&&`CSS`in window&&typeof window.CSS?.supports==`function`&&(this.isViewTransitionTypesSupported=window.CSS.supports(`selector(:active-view-transition-type(a))`))},this.updateLatestLocation=()=>{this.latestLocation=this.parseLocation(this.history.location,this.latestLocation)},this.buildRouteTree=()=>{let e=Ae(this.routeTree,this.options.caseSensitive,(e,t)=>{e.init({originalIndex:t})});return this.options.routeMasks&&Te(this.options.routeMasks,e.processedTree),e},this.subscribe=(e,t)=>{let n={eventType:e,fn:t};return this.subscribers.add(n),()=>{this.subscribers.delete(n)}},this.emit=e=>{this.subscribers.forEach(t=>{t.eventType===e.type&&t.fn(e)})},this.parseLocation=(e,t)=>{let n=({pathname:e,search:n,hash:r,href:i,state:a})=>{if(!this.rewrite&&!/[ \x00-\x1f\x7f\u0080-\uffff]/.test(e)){let i=this.options.parseSearch(n),o=this.options.stringifySearch(i);return{href:e+o+r,publicHref:e+o+r,pathname:fe(e).path,external:!1,searchStr:o,search:w(t?.search,i),hash:fe(r.slice(1)).path,state:ee(t?.state,a)}}let o=new URL(i,this.origin),s=Et(this.rewrite,o),c=this.options.parseSearch(s.search),l=this.options.stringifySearch(c);return s.search=l,{href:s.href.replace(s.origin,``),publicHref:i,pathname:fe(s.pathname).path,external:!!this.rewrite&&s.origin!==this.origin,searchStr:l,search:w(t?.search,c),hash:fe(s.hash.slice(1)).path,state:ee(t?.state,a)}},r=n(e),{__tempLocation:i,__tempKey:a}=r.state;if(i&&(!a||a===this.tempLocationKey)){let e=n(i);return e.state.key=r.state.key,e.state.__TSR_key=r.state.__TSR_key,delete e.state.__tempLocation,{...e,maskedLocation:r}}return r},this.resolvePathWithBase=(e,t)=>qe({base:e,to:t.includes(`//`)?Ve(t):t,trailingSlash:this.options.trailingSlash,cache:this.resolvePathCache}),this.matchRoutes=(e,t,n)=>typeof e==`string`?this.matchRoutesInternal({pathname:e,search:t},n):this.matchRoutesInternal(e,t),this.getMatchedRoutes=e=>gn({pathname:e,routesById:this.routesById,processedTree:this.processedTree}),this.cancelMatch=e=>{let t=this.getMatch(e);t&&(t.abortController.abort(),clearTimeout(t._nonReactive.pendingTimeout),t._nonReactive.pendingTimeout=void 0)},this.cancelMatches=()=>{this.stores.pendingIds.get().forEach(e=>{this.cancelMatch(e)}),this.stores.matchesId.get().forEach(e=>{if(this.stores.pendingMatchStores.has(e))return;let t=this.stores.matchStores.get(e)?.get();t&&(t.status===`pending`||t.isFetching===`loader`)&&this.cancelMatch(e)})},this.buildLocation=e=>{let t=(t={})=>{let n=t._fromLocation||this.pendingBuiltLocation||this.latestLocation,r=this.matchRoutesLightweight(n);t.from;let i=t.unsafeRelative===`path`?n.pathname:t.from??r.fullPath,a=t.to?`${t.to}`:void 0,o=r.search,s=Object.assign(Object.create(null),r.params),c=a?.charCodeAt(0)===47?`/`:this.resolvePathWithBase(i,`.`),l=a?this.resolvePathWithBase(c,a):c,u=t.params===!1||t.params===null?Object.create(null):(t.params??!0)===!0?s:Object.assign(s,y(t.params,s)),d=this.routesByPath[Ue(l)],f;if(d)f=this.getRouteBranch(d);else if(l.includes(`$`))f=[];else{let e=this.getMatchedRoutes(l);f=e.matchedRoutes,this.options.notFoundRoute&&(!e.foundRoute||e.foundRoute.path!==`/`&&e.routeParams[`**`])&&(f=[...f,this.options.notFoundRoute])}if(f.length&&S(u))for(let e of f){let t=e.options.params?.stringify??e.options.stringifyParams;if(t)try{Object.assign(u,t(u))}catch{}}let p=e.leaveParams?l:fe(Xe({path:l,params:u,decoder:this.pathParamsDecoder,server:this.isServer}).interpolatedPath).path,m=o;if(e._includeValidateSearch&&this.options.search?.strict){let e={};f.forEach(t=>{if(t.options.validateSearch)try{Object.assign(e,hn(t.options.validateSearch,{...e,...m}))}catch{}}),m=e}m=_n({search:m,dest:t,destRoutes:f,_includeValidateSearch:e._includeValidateSearch}),m=w(o,m);let h=this.options.stringifySearch(m),g=t.hash===!0?n.hash:t.hash?y(t.hash,n.hash):void 0,_=g?`#${g}`:``,v=t.state===!0?n.state:t.state?y(t.state,n.state):{};v=ee(n.state,v);let b=`${p}${h}${_}`,x,C,te=!1;if(this.rewrite){let e=new URL(b,this.origin),t=Dt(this.rewrite,e);x=e.href.replace(e.origin,``),t.origin===this.origin?C=t.pathname+t.search+t.hash:(C=t.href,te=!0)}else x=pe(b),C=x;return{publicHref:C,href:x,pathname:p,search:m,searchStr:h,state:v,hash:g??``,external:te,unmaskOnReload:t.unmaskOnReload}},n=(n={},r)=>{let i=t(n),a=r?t(r):void 0;if(!a){let n=Object.create(null);if(this.options.routeMasks){let o=Ee(i.pathname,this.processedTree);if(o){Object.assign(n,o.rawParams);let{from:i,params:s,...c}=o.route,l=s===!1||s===null?Object.create(null):(s??!0)===!0?n:Object.assign(n,y(s,n));r={from:e.from,...c,params:l},a=t(r)}}}return a&&(i.maskedLocation=a),i};return e.mask?n(e,{from:e.from,...e.mask}):n(e)},this.commitLocation=async({viewTransition:e,ignoreBlocker:t,...n})=>{let r,i=()=>{let e=[`key`,`__TSR_key`,`__TSR_index`,`__hashScrollIntoViewOptions`];e.forEach(e=>{n.state[e]=this.latestLocation.state[e]});let t=ae(n.state,this.latestLocation.state);return e.forEach(e=>{delete n.state[e]}),t},a=Ue(this.latestLocation.href)===Ue(n.href),o=this.commitLocationPromise;if(this.commitLocationPromise=oe(()=>{o?.resolve(),o=void 0}),a&&i())this.load();else{let{maskedLocation:i,hashScrollIntoView:a,...o}=n;i&&(o={...i,state:{...i.state,__tempKey:void 0,__tempLocation:{...o,search:o.searchStr,state:{...o.state,__tempKey:void 0,__tempLocation:void 0,__TSR_key:void 0,key:void 0}}}},(o.unmaskOnReload??this.options.unmaskOnReload??!1)&&(o.state.__tempKey=this.tempLocationKey)),o.state.__hashScrollIntoViewOptions=a??this.options.defaultHashScrollIntoView??!0,this.shouldViewTransition=e,r=n.replace?`REPLACE`:`PUSH`,this.history[r===`REPLACE`?`replace`:`push`](o.publicHref,o.state,{ignoreBlocker:t})}return this._scroll.next=n.resetScroll??!0,this.history.subscribers.size||this.load(r?{action:{type:r}}:void 0),this.commitLocationPromise},this.buildAndCommitLocation=({replace:e,resetScroll:t,hashScrollIntoView:n,viewTransition:r,ignoreBlocker:i,href:a,...o}={})=>{if(a){let t=this.history.location.state.__TSR_index,n=cn(a,{__TSR_index:e?t:t+1}),r=new URL(n.pathname,this.origin);o.to=Et(this.rewrite,r).pathname,o.search=this.options.parseSearch(n.search),o.hash=n.hash.slice(1)}let s=this.buildLocation({...o,_includeValidateSearch:!0});this.pendingBuiltLocation=s;let c=this.commitLocation({...s,viewTransition:r,replace:e,resetScroll:t,hashScrollIntoView:n,ignoreBlocker:i});return queueMicrotask(()=>{this.pendingBuiltLocation===s&&(this.pendingBuiltLocation=void 0)}),c},this.navigate=async({to:e,reloadDocument:t,href:n,publicHref:r,...i})=>{let a=!1;if(n)try{new URL(`${n}`),a=!0}catch{}if(a&&!t&&(t=!0),t){if(e!==void 0||!n){let t=this.buildLocation({to:e,...i});n??=t.publicHref,r??=t.publicHref}let t=!a&&r?r:n;if(de(t,this.protocolAllowlist))return;if(!i.ignoreBlocker){let e=this.history.getBlockers?.()??[];for(let t of e)if(t?.blockerFn&&await t.blockerFn({currentLocation:this.latestLocation,nextLocation:this.latestLocation,action:`PUSH`}))return}i.replace?window.location.replace(t):window.location.href=t;return}return this.buildAndCommitLocation({...i,href:n,to:e,_isNavigate:!0})},this.beforeLoad=()=>{this.cancelMatches(),this.updateLatestLocation();let e=this.matchRoutes(this.latestLocation),t=this.stores.cachedMatches.get().filter(t=>!e.some(e=>e.id===t.id));this.batch(()=>{this.stores.status.set(`pending`),this.stores.statusCode.set(200),this.stores.isLoading.set(!0),this.stores.location.set(this.latestLocation),this.stores.setPending(e),this.stores.setCached(t)})},this.load=async e=>{let t=e?.action?.type,n,r,i,a=this.stores.resolvedLocation.get()??this.stores.location.get();for(i=new Promise(o=>{this.startTransition(async()=>{try{this.beforeLoad(),t&&(this._scroll.hash=t===`PUSH`||t===`REPLACE`);let n=this.latestLocation,r=un(n,this.stores.resolvedLocation.get());this.stores.redirect.get()||this.emit({type:`onBeforeNavigate`,...r}),this.emit({type:`onBeforeLoad`,...r}),await Jt({router:this,sync:e?.sync,forceStaleReload:a.href===n.href,matches:this.stores.pendingMatches.get(),location:n,updateMatch:this.updateMatch,onReady:async()=>{this.startTransition(()=>{this.startViewTransition(async()=>{let e=null,t=null,n=null,r=null;this.batch(()=>{let i=this.stores.pendingMatches.get(),a=i.length,o=this.stores.matches.get();e=a?o.filter(e=>!this.stores.pendingMatchStores.has(e.id)):null;let s=new Set;for(let e of this.stores.pendingMatchStores.values())e.routeId&&s.add(e.routeId);let c=new Set;for(let e of this.stores.matchStores.values())e.routeId&&c.add(e.routeId);t=a?o.filter(e=>!s.has(e.routeId)):null,n=a?i.filter(e=>!c.has(e.routeId)):null,r=a?i.filter(e=>c.has(e.routeId)):o,this.stores.isLoading.set(!1),this.stores.loadedAt.set(Date.now()),a&&(this.stores.setMatches(i),this.stores.setPending([]),this.stores.setCached([...this.stores.cachedMatches.get(),...e.filter(e=>e.status!==`error`&&e.status!==`notFound`&&e.status!==`redirected`)]),this.clearExpiredCache())});for(let[e,i]of[[t,`onLeave`],[n,`onEnter`],[r,`onStay`]])if(e)for(let t of e)this.looseRoutesById[t.routeId].options[i]?.(t)})})}})}catch(e){Ct(e)?(n=e,this.navigate({...n.options,replace:!0,ignoreBlocker:!0})):$e(e)&&(r=e);let t=n?n.status:r?404:this.stores.matches.get().some(e=>e.status===`error`)?500:200;this.batch(()=>{this.stores.statusCode.set(t),this.stores.redirect.set(n)})}this.latestLoadPromise===i&&(this.commitLocationPromise?.resolve(),this.latestLoadPromise=void 0,this.commitLocationPromise=void 0),o()})}),this.latestLoadPromise=i,await i;this.latestLoadPromise&&i!==this.latestLoadPromise;)await this.latestLoadPromise;let o;this.hasNotFoundMatch()?o=404:this.stores.matches.get().some(e=>e.status===`error`)&&(o=500),o!==void 0&&this.stores.statusCode.set(o)},this.startViewTransition=e=>{let t=this.shouldViewTransition??this.options.defaultViewTransition;if(this.shouldViewTransition=void 0,t&&typeof document<`u`&&`startViewTransition`in document&&typeof document.startViewTransition==`function`){let n;if(typeof t==`object`&&this.isViewTransitionTypesSupported){let r=this.latestLocation,i=this.stores.resolvedLocation.get(),a=typeof t.types==`function`?t.types(un(r,i)):t.types;if(a===!1){e();return}n={update:e,types:a}}else n=e;document.startViewTransition(n)}else e()},this.updateMatch=(e,t)=>{this.startTransition(()=>{let n=this.stores.pendingMatchStores.get(e);if(n){n.set(t);return}let r=this.stores.matchStores.get(e);if(r){r.set(t);return}let i=this.stores.cachedMatchStores.get(e);if(i){let n=t(i.get());n.status===`redirected`?this.stores.cachedMatchStores.delete(e)&&this.stores.cachedIds.set(t=>t.filter(t=>t!==e)):i.set(n)}})},this.getMatch=e=>this.stores.cachedMatchStores.get(e)?.get()??this.stores.pendingMatchStores.get(e)?.get()??this.stores.matchStores.get(e)?.get(),this.invalidate=e=>{let t=t=>e?.filter?.(t)??!0?{...t,invalid:!0,...e?.forcePending||t.status===`error`||t.status===`notFound`?{status:`pending`,error:void 0}:void 0}:t;return this.batch(()=>{this.stores.setMatches(this.stores.matches.get().map(t)),this.stores.setCached(this.stores.cachedMatches.get().map(t)),this.stores.setPending(this.stores.pendingMatches.get().map(t))}),this.shouldViewTransition=!1,this.load({sync:e?.sync})},this.getParsedLocationHref=e=>e.publicHref||`/`,this.resolveRedirect=e=>{let t=e.headers.get(`Location`);if(!e.options.href||e.options._builtLocation){let t=e.options._builtLocation??this.buildLocation(e.options),n=this.getParsedLocationHref(t);e.options.href=n,e.headers.set(`Location`,n)}else if(t)try{let n=new URL(t);if(this.origin&&n.origin===this.origin){let t=n.pathname+n.search+n.hash;e.options.href=t,e.headers.set(`Location`,t)}}catch{}if(e.options.href&&!e.options._builtLocation&&de(e.options.href,this.protocolAllowlist))throw Error(`Redirect blocked: unsafe protocol`);return e.headers.get(`Location`)||e.headers.set(`Location`,e.options.href),e},this.clearCache=e=>{let t=e?.filter;t===void 0?this.stores.setCached([]):this.stores.setCached(this.stores.cachedMatches.get().filter(e=>!t(e)))},this.clearExpiredCache=()=>{let e=Date.now();this.clearCache({filter:t=>{let n=this.looseRoutesById[t.routeId];if(!n.options.loader)return!0;let r=(t.preload?n.options.preloadGcTime??this.options.defaultPreloadGcTime:n.options.gcTime??this.options.defaultGcTime)??300*1e3;return t.status===`error`||e-t.updatedAt>=r}})},this.loadRouteChunk=Xt,this.preloadRoute=async e=>{let t=e._builtLocation??this.buildLocation(e),n=this.matchRoutes(t,{throwOnError:!0,preload:!0,dest:e}),r=new Set([...this.stores.matchesId.get(),...this.stores.pendingIds.get()]),i=new Set([...r,...this.stores.cachedIds.get()]),a=n.filter(e=>!i.has(e.id));if(a.length){let e=this.stores.cachedMatches.get();this.stores.setCached([...e,...a])}try{return n=await Jt({router:this,matches:n,location:t,preload:!0,updateMatch:(e,t)=>{r.has(e)?n=n.map(n=>n.id===e?t(n):n):this.updateMatch(e,t)}}),n}catch(e){if(Ct(e))return e.options.reloadDocument?void 0:await this.preloadRoute({...e.options,_fromLocation:t});$e(e)||console.error(e);return}},this.matchRoute=(e,t)=>{let n={...e,to:e.to?this.resolvePathWithBase(e.from||``,e.to):void 0,params:e.params||{},leaveParams:!0},r=this.buildLocation(n);if(t?.pending&&this.stores.status.get()!==`pending`)return!1;let i=(t?.pending===void 0?!this.stores.isLoading.get():t.pending)?this.latestLocation:this.stores.resolvedLocation.get()||this.stores.location.get(),a=De(r.pathname,t?.caseSensitive??!1,t?.fuzzy??!1,i.pathname,this.processedTree);return!a||e.params&&!ae(a.rawParams,e.params,{partial:!0})?!1:t?.includeSearch??!0?ae(i.search,r.search,{partial:!0})?a.rawParams:!1:a.rawParams},this.hasNotFoundMatch=()=>this.stores.matches.get().some(e=>e.status===`notFound`||e.globalNotFound),this.getStoreConfig=t,this.update({defaultPreloadDelay:50,defaultPendingMs:1e3,defaultPendingMinMs:500,context:void 0,...e,caseSensitive:e.caseSensitive??!1,notFoundMode:e.notFoundMode??`fuzzy`,stringifySearch:e.stringifySearch??vt,parseSearch:e.parseSearch??_t,protocolAllowlist:e.protocolAllowlist??ue}),typeof document<`u`&&(self.__TSR_ROUTER__=this)}isShell(){return!!this.options.isShell}isPrerendering(){return!!this.options.isPrerendering}get state(){return this.stores.__store.get()}setRoutes({routesById:e,routesByPath:t,processedTree:n}){this.routesById=e,this.routesByPath=t,this.processedTree=n;let r=this.options.notFoundRoute;r&&(r.init({originalIndex:99999999999}),this.routesById[r.id]=r)}getRouteBranch(e){let t=this.routeBranchCache.get(e);return t||(t=Ne(e),this.routeBranchCache.set(e,t)),t}get looseRoutesById(){return this.routesById}getParentContext(e){return e?.id?e.context??this.options.context??void 0:this.options.context??void 0}matchRoutesInternal(e,t){let n=this.getMatchedRoutes(e.pathname),{foundRoute:r,routeParams:i}=n,{matchedRoutes:a}=n,o=!1;(r?r.path!==`/`&&i[`**`]:Ue(e.pathname))&&(this.options.notFoundRoute?a=[...a,this.options.notFoundRoute]:o=!0);let s=o?yn(this.options.notFoundMode,a):void 0,c=Array(a.length),l=new Map;for(let e of this.stores.matchStores.values())e.routeId&&l.set(e.routeId,e.get());for(let n=0;nthis.navigate({...t,_fromLocation:e}),buildLocation:this.buildLocation,cause:n.cause,abortController:n.abortController,preload:!!n.preload,matches:c,routeId:r.id};n.__routeContext=r.options.context(t)??void 0}n.context={...a,...n.__routeContext,...n.__beforeLoadContext}}}return c}matchRoutesLightweight(e){let t=_(this.stores.matchesId.get()),n=this.lightweightCache.get(e);if(n&&n[0]===t)return n[1];let{matchedRoutes:r,routeParams:i}=this.getMatchedRoutes(e.pathname),a=_(r),o={...e.search};for(let e of r)try{Object.assign(o,hn(e.options.validateSearch,o))}catch{}let s=t&&this.stores.matchStores.get(t)?.get(),c=s&&s.routeId===a.id&&s.pathname===e.pathname,l;if(c)l=s.params;else{let e=Object.assign(Object.create(null),i);for(let t of r)try{bn(t,e)}catch{}l=e}let u={matchedRoutes:r,fullPath:a.fullPath,search:o,params:l};return this.lightweightCache.set(e,[t,u]),u}},fn=class extends Error{},pn=class extends Error{};function mn(e){return{loadedAt:0,isLoading:!1,isTransitioning:!1,status:`idle`,resolvedLocation:void 0,location:e,matches:[],statusCode:200}}function hn(e,t){if(e==null)return{};if(`~standard`in e){let n=e[`~standard`].validate(t);if(n instanceof Promise)throw new fn(`Async validation not supported`);if(n.issues)throw new fn(JSON.stringify(n.issues,void 0,2),{cause:n});return n.value}return`parse`in e?e.parse(t):typeof e==`function`?e(t):{}}function gn({pathname:e,routesById:t,processedTree:n}){let r=Object.create(null),i=Ue(e),a,o=Oe(i,n,!0);return o&&(a=o.route,Object.assign(r,o.rawParams)),{matchedRoutes:o?.branch||[t.__root__],routeParams:r,foundRoute:a}}function _n({search:e,dest:t,destRoutes:n,_includeValidateSearch:r}){return vn(n)(e,t,r??!1)}function vn(e){let t,n,r=[];for(let t of e){let e=t.options;`search`in e?e.search?.middlewares&&r.push(...e.search.middlewares):(e.preSearchFilters||e.postSearchFilters)&&r.push(({search:t,next:n})=>{let r=n(e.preSearchFilters?e.preSearchFilters.reduce((e,t)=>t(e),t):t);return e.postSearchFilters?e.postSearchFilters.reduce((e,t)=>t(e),r):r});let i=e.validateSearch;i&&r.push(({search:e,next:t,meta:r})=>{let a=t(e);if(n)try{let e=hn(i,a);if(r&&e)for(let t in e)t in a||(r.defaulted||=new Map).set(t,e[t]);return{...a,...e}}catch{}return a})}let i=(e,n,a)=>{if(e>=r.length){if(!t.search)return{};if(t.search===!0)return n;let e=y(t.search,n);return a&&(a.explicit=e),e}return r[e]({search:n,next:(t,n)=>{if(n){let n=a||{};return{search:i(e+1,t,n),meta:n}}return i(e+1,t,a)},meta:a})};return function(e,r,a){return t=r,n=a,i(0,e)}}function yn(e,t){if(e!==`root`)for(let e=t.length-1;e>=0;e--){let n=t[e];if(n.children)return n.id}return xt}function bn(e,t){let n=e.options.params?.parse??e.options.parseParams;if(n){let e=n(t);if(e===!1)throw Error(`Route params.parse returned false for a matched route`);Object.assign(t,e)}}var xn=`Error preloading route! ☝️`,Sn=class{get to(){return this._to}get id(){return this._id}get path(){return this._path}get fullPath(){return this._fullPath}constructor(e){if(this.init=e=>{this.originalIndex=e.originalIndex;let t=this.options,n=!t?.path&&!t?.id;this.parentRoute=this.options.getParentRoute?.(),n?this._path=xt:this.parentRoute||me();let r=n?xt:t?.path;r&&r!==`/`&&(r=He(r));let i=t?.id||r,a=n?xt:Be([this.parentRoute.id===`__root__`?``:this.parentRoute.id,i]);r===`__root__`&&(r=`/`),a!==`__root__`&&(a=Be([`/`,a]));let o=a===`__root__`?`/`:Be([this.parentRoute.fullPath,r]);this._path=r,this._id=a,this._fullPath=o,this._to=Ue(o)},this.addChildren=e=>this._addFileChildren(e),this._addFileChildren=e=>(Array.isArray(e)&&(this.children=e),typeof e==`object`&&e&&(this.children=Object.values(e)),this),this._addFileTypes=()=>this,this.updateLoader=e=>(Object.assign(this.options,e),this),this.update=e=>(Object.assign(this.options,e),this),this.lazy=e=>(this.lazyFn=e,this),this.redirect=e=>St({from:this.fullPath,...e}),this.options=e||{},this.isRoot=!e?.getParentRoute,e?.id&&e?.path)throw Error(`Route cannot have both an 'id' and a 'path' option.`)}},Cn=class extends Sn{constructor(e){super(e)}},wn=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),Tn=o(((e,t)=>{t.exports=wn()})),O=Tn();function En(e){let t=e.errorComponent??On;return(0,O.jsx)(Dn,{getResetKey:e.getResetKey,onCatch:e.onCatch,children:({error:n,reset:r})=>n?d.createElement(t,{error:n,reset:r}):e.children})}var Dn=class extends d.Component{constructor(...e){super(...e),this.state={error:null}}static getDerivedStateFromProps(e,t){let n=e.getResetKey();return t.error&&t.resetKey!==n?{resetKey:n,error:null}:{resetKey:n}}static getDerivedStateFromError(e){return{error:e}}reset(){this.setState({error:null})}componentDidCatch(e,t){this.props.onCatch&&this.props.onCatch(e,t)}render(){return this.props.children({error:this.state.error,reset:()=>{this.reset()}})}};function On({error:e}){let[t,n]=d.useState(!1);return(0,O.jsxs)(`div`,{style:{padding:`.5rem`,maxWidth:`100%`},children:[(0,O.jsxs)(`div`,{style:{display:`flex`,alignItems:`center`,gap:`.5rem`},children:[(0,O.jsx)(`strong`,{style:{fontSize:`1rem`},children:`Something went wrong!`}),(0,O.jsx)(`button`,{style:{appearance:`none`,fontSize:`.6em`,border:`1px solid currentColor`,padding:`.1rem .2rem`,fontWeight:`bold`,borderRadius:`.25rem`},onClick:()=>n(e=>!e),children:t?`Hide Error`:`Show Error`})]}),(0,O.jsx)(`div`,{style:{height:`.25rem`}}),t?(0,O.jsx)(`div`,{children:(0,O.jsx)(`pre`,{style:{fontSize:`.7em`,border:`1px solid red`,borderRadius:`.25rem`,padding:`.3rem`,color:`red`,overflow:`auto`},children:e.message?(0,O.jsx)(`code`,{children:e.message}):null})}):null]})}function kn({children:e,fallback:t=null}){return An()?(0,O.jsx)(d.Fragment,{children:e}):(0,O.jsx)(d.Fragment,{children:t})}function An(){return d.useSyncExternalStore(jn,()=>!0,()=>!1)}function jn(){return()=>{}}var Mn=d.createContext(null);function Nn(e){return d.useContext(Mn)}var Pn=d.createContext(void 0),Fn=d.createContext(void 0),k=(e=>(e[e.None=0]=`None`,e[e.Mutable=1]=`Mutable`,e[e.Watching=2]=`Watching`,e[e.RecursedCheck=4]=`RecursedCheck`,e[e.Recursed=8]=`Recursed`,e[e.Dirty=16]=`Dirty`,e[e.Pending=32]=`Pending`,e))(k||{});function In({update:e,notify:t,unwatched:n}){return{link:r,unlink:i,propagate:a,checkDirty:o,shallowPropagate:s};function r(e,t,n){let r=t.depsTail;if(r!==void 0&&r.dep===e)return;let i=r===void 0?t.deps:r.nextDep;if(i!==void 0&&i.dep===e){i.version=n,t.depsTail=i;return}let a=e.subsTail;if(a!==void 0&&a.version===n&&a.sub===t)return;let o=t.depsTail=e.subsTail={version:n,dep:e,sub:t,prevDep:r,nextDep:i,prevSub:a,nextSub:void 0};i!==void 0&&(i.prevDep=o),r===void 0?t.deps=o:r.nextDep=o,a===void 0?e.subs=o:a.nextSub=o}function i(e,t=e.sub){let r=e.dep,i=e.prevDep,a=e.nextDep,o=e.nextSub,s=e.prevSub;return a===void 0?t.depsTail=i:a.prevDep=i,i===void 0?t.deps=a:i.nextDep=a,o===void 0?r.subsTail=s:o.prevSub=s,s===void 0?(r.subs=o)===void 0&&n(r):s.nextSub=o,a}function a(e){let n=e.nextSub,r;top:do{let i=e.sub,a=i.flags;if(a&60?a&12?a&4?!(a&48)&&c(e,i)?(i.flags=a|40,a&=1):a=0:i.flags=a&-9|32:a=0:i.flags=a|32,a&2&&t(i),a&1){let t=i.subs;if(t!==void 0){let i=(e=t).nextSub;i!==void 0&&(r={value:n,prev:r},n=i);continue}}if((e=n)!==void 0){n=e.nextSub;continue}for(;r!==void 0;)if(e=r.value,r=r.prev,e!==void 0){n=e.nextSub;continue top}break}while(!0)}function o(t,n){let r,i=0,a=!1;top:do{let o=t.dep,c=o.flags;if(n.flags&16)a=!0;else if((c&17)==17){if(e(o)){let e=o.subs;e.nextSub!==void 0&&s(e),a=!0}}else if((c&33)==33){(t.nextSub!==void 0||t.prevSub!==void 0)&&(r={value:t,prev:r}),t=o.deps,n=o,++i;continue}if(!a){let e=t.nextDep;if(e!==void 0){t=e;continue}}for(;i--;){let i=n.subs,o=i.nextSub!==void 0;if(o?(t=r.value,r=r.prev):t=i,a){if(e(n)){o&&s(i),n=t.sub;continue}a=!1}else n.flags&=-33;n=t.sub;let c=t.nextDep;if(c!==void 0){t=c;continue top}}return a}while(!0)}function s(e){do{let n=e.sub,r=n.flags;(r&48)==32&&(n.flags=r|16,(r&6)==2&&t(n))}while((e=e.nextSub)!==void 0)}function c(e,t){let n=t.depsTail;for(;n!==void 0;){if(n===e)return!0;n=n.prevDep}return!1}}function Ln(e,t,n){let r=typeof e==`object`,i=r?e:void 0;return{next:(r?e.next:e)?.bind(i),error:(r?e.error:t)?.bind(i),complete:(r?e.complete:n)?.bind(i)}}var Rn=[],zn=0,{link:Bn,unlink:Vn,propagate:Hn,checkDirty:Un,shallowPropagate:Wn}=In({update(e){return e._update()},notify(e){Rn[Kn++]=e,e.flags&=~k.Watching},unwatched(e){e.depsTail!==void 0&&(e.depsTail=void 0,e.flags=k.Mutable|k.Dirty,Xn(e))}}),Gn=0,Kn=0,qn,Jn=0;function Yn(e){try{++Jn,e()}finally{--Jn||Zn()}}function Xn(e){let t=e.depsTail,n=t===void 0?e.deps:t.nextDep;for(;n!==void 0;)n=Vn(n,e)}function Zn(){if(!(Jn>0)){for(;Gn{i.get(),n.current?t.next?.(i._snapshot):n.current=!0});return{unsubscribe:()=>{r.stop()}}},_update(e){let a=qn,o=t?.compare??Object.is;if(n)qn=i,++zn,i.depsTail=void 0;else if(e===void 0)return!1;n&&(i.flags=k.Mutable|k.RecursedCheck);try{let t=i._snapshot,a=typeof e==`function`?e(t):e===void 0&&n?r(t):e;return t===void 0||!o(t,a)?(i._snapshot=a,!0):!1}finally{qn=a,n&&(i.flags&=~k.RecursedCheck),Xn(i)}}};return n?(i.flags=k.Mutable|k.Dirty,i.get=function(){let e=i.flags;if(e&k.Dirty||e&k.Pending&&Un(i.deps,i)){if(i._update()){let e=i.subs;e!==void 0&&Wn(e)}}else e&k.Pending&&(i.flags=e&~k.Pending);return qn!==void 0&&Bn(i,qn,zn),i._snapshot}):i.set=function(e){if(i._update(e)){let e=i.subs;e!==void 0&&(Hn(e),Wn(e),Zn())}},i}function $n(e){let t=()=>{let t=qn;qn=n,++zn,n.depsTail=void 0,n.flags=k.Watching|k.RecursedCheck;try{return e()}finally{qn=t,n.flags&=~k.RecursedCheck,Xn(n)}},n={deps:void 0,depsTail:void 0,subs:void 0,subsTail:void 0,flags:k.Watching|k.RecursedCheck,notify(){let e=this.flags;e&k.Dirty||e&k.Pending&&Un(this.deps,this)?t():this.flags=k.Watching},stop(){this.flags=k.None,this.depsTail=void 0,Xn(this)}};return t(),n}var er=o((e=>{var t=u();function n(e,t){return e===t&&(e!==0||1/e==1/t)||e!==e&&t!==t}var r=typeof Object.is==`function`?Object.is:n,i=t.useState,a=t.useEffect,o=t.useLayoutEffect,s=t.useDebugValue;function c(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),c=r[0].inst,u=r[1];return o(function(){c.value=n,c.getSnapshot=t,l(c)&&u({inst:c})},[e,n,t]),a(function(){return l(c)&&u({inst:c}),e(function(){l(c)&&u({inst:c})})},[e]),s(n),n}function l(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!r(e,n)}catch{return!0}}function d(e,t){return t()}var f=typeof window>`u`||window.document===void 0||window.document.createElement===void 0?d:c;e.useSyncExternalStore=t.useSyncExternalStore===void 0?f:t.useSyncExternalStore})),tr=o(((e,t)=>{t.exports=er()})),nr=o((e=>{var t=u(),n=tr();function r(e,t){return e===t&&(e!==0||1/e==1/t)||e!==e&&t!==t}var i=typeof Object.is==`function`?Object.is:r,a=n.useSyncExternalStore,o=t.useRef,s=t.useEffect,c=t.useMemo,l=t.useDebugValue;e.useSyncExternalStoreWithSelector=function(e,t,n,r,u){var d=o(null);if(d.current===null){var f={hasValue:!1,value:null};d.current=f}else f=d.current;d=c(function(){function e(e){if(!a){if(a=!0,o=e,e=r(e),u!==void 0&&f.hasValue){var t=f.value;if(u(t,e))return s=t}return s=e}if(t=s,i(o,e))return t;var n=r(e);return u!==void 0&&u(t,n)?(o=e,t):(o=e,s=n)}var a=!1,o,s,c=n===void 0?null:n;return[function(){return e(t())},c===null?void 0:function(){return e(c())}]},[t,n,r,u]);var p=a(e,d[0],d[1]);return s(function(){f.hasValue=!0,f.value=p},[p]),l(p),p}})),rr=o(((e,t)=>{t.exports=nr()}))();function ir(e,t){return e===t}function ar(e,t,n=ir){let r=(0,d.useCallback)(t=>{if(!e)return()=>{};let{unsubscribe:n}=e.subscribe(t);return n},[e]),i=(0,d.useCallback)(()=>e?.get(),[e]);return(0,rr.useSyncExternalStoreWithSelector)(r,i,i,t,n)}var or={get(){},subscribe(){return{unsubscribe(){}}}};function sr(e,t){let n=d.useRef();return r=>{let i=e?.select?e.select(r):r;return e?.structuralSharing??t.options.defaultStructuralSharing?n.current=ee(n.current,i):i}}function cr(e){let t=Nn(),n=d.useContext(e.from?Fn:Pn),r=e.from?t.stores.getRouteMatchStore(e.from):t.stores.matchStores.get(n),i=sr(e,t),a=ar(r??or,e=>e?i(e):or);if(a!==or)return a;(e.shouldThrow??!0)&&me()}function lr(e){return cr({from:e.from,strict:e.strict,structuralSharing:e.structuralSharing,select:t=>e.select?e.select(t.loaderData):t.loaderData})}function ur(e){let{select:t,...n}=e;return cr({...n,select:e=>t?t(e.loaderDeps):e.loaderDeps})}function dr(e){return cr({from:e.from,shouldThrow:e.shouldThrow,structuralSharing:e.structuralSharing,strict:e.strict,select:t=>{let n=e.strict===!1?t.params:t._strictParams;return e.select?e.select(n):n}})}function fr(e){return cr({from:e.from,strict:e.strict,shouldThrow:e.shouldThrow,structuralSharing:e.structuralSharing,select:t=>e.select?e.select(t.search):t.search})}function pr(e){let t=Nn();return d.useCallback(n=>t.navigate({...n,from:n.from??e?.from}),[e?.from,t])}function mr(e){return cr({...e,select:t=>e.select?e.select(t.context):t.context})}var hr=o((e=>{var t=u();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=hr()})),_r=c(gr(),1);function vr(e,t){let n=Nn(),r=g(t),{activeProps:i,inactiveProps:a,activeOptions:o,to:s,preload:c,preloadDelay:l,preloadIntentProximity:u,hashScrollIntoView:f,replace:p,startTransition:m,resetScroll:_,viewTransition:v,children:b,target:x,disabled:S,style:C,className:w,onClick:ee,onBlur:te,onFocus:ne,onMouseEnter:re,onMouseLeave:ie,onTouchStart:oe,ignoreBlocker:se,params:ce,search:T,hash:E,state:le,mask:ue,reloadDocument:fe,unsafeRelative:pe,from:D,_fromLocation:me,...he}=e,ge=An(),_e=d.useMemo(()=>e,[n,e.from,e._fromLocation,e.hash,e.to,e.search,e.params,e.state,e.mask,e.unsafeRelative]),ve=ar(n.stores.location,e=>e,(e,t)=>e.href===t.href),ye=d.useMemo(()=>{let e={_fromLocation:ve,..._e};return n.buildLocation(e)},[n,ve,_e]),be=ye.maskedLocation?ye.maskedLocation.publicHref:ye.publicHref,xe=ye.maskedLocation?ye.maskedLocation.external:ye.external,Se=d.useMemo(()=>Dr(be,xe,n.history,S),[S,xe,be,n.history]),Ce=d.useMemo(()=>{if(Se?.external)return de(Se.href,n.protocolAllowlist)?void 0:Se.href;if(!Or(s)&&!(typeof s!=`string`||s.indexOf(`:`)===-1))try{return new URL(s),de(s,n.protocolAllowlist)?void 0:s}catch{}},[s,Se,n.protocolAllowlist]),we=d.useMemo(()=>{if(Ce)return!1;if(o?.exact){if(!Ke(ve.pathname,ye.pathname,n.basepath))return!1}else{let e=Ge(ve.pathname,n.basepath),t=Ge(ye.pathname,n.basepath);if(!(e.startsWith(t)&&(e.length===t.length||e[t.length]===`/`)))return!1}return(o?.includeSearch??!0)&&!ae(ve.search,ye.search,{partial:!o?.exact,ignoreUndefined:!o?.explicitUndefined})?!1:!o?.includeHash||ge&&ve.hash===ye.hash},[o?.exact,o?.explicitUndefined,o?.includeHash,o?.includeSearch,ve,Ce,ge,ye.hash,ye.pathname,ye.search,n.basepath]),Te=we?y(i,{})??br:yr,Ee=we?yr:y(a,{})??yr,De=[w,Te.className,Ee.className].filter(Boolean).join(` `),Oe=(C||Te.style||Ee.style)&&{...C,...Te.style,...Ee.style},[ke,Ae]=d.useState(!1),je=d.useRef(!1),Me=e.reloadDocument||Ce?!1:c??n.options.defaultPreload,Ne=l??n.options.defaultPreloadDelay??0,Pe=d.useCallback(()=>{n.preloadRoute({..._e,_builtLocation:ye}).catch(e=>{console.warn(e),console.warn(xn)})},[n,_e,ye]);h(r,d.useCallback(e=>{e?.isIntersecting&&Pe()},[Pe]),Tr,{disabled:!!S||Me!==`viewport`}),d.useEffect(()=>{je.current||!S&&Me===`render`&&(Pe(),je.current=!0)},[S,Pe,Me]);let Fe=e=>{let t=e.currentTarget.getAttribute(`target`),r=x===void 0?t:x;if(!S&&!Ar(e)&&!e.defaultPrevented&&(!r||r===`_self`)&&e.button===0){e.preventDefault(),(0,_r.flushSync)(()=>{Ae(!0)});let t=n.subscribe(`onResolved`,()=>{t(),Ae(!1)});n.navigate({..._e,replace:p,resetScroll:_,hashScrollIntoView:f,startTransition:m,viewTransition:v,ignoreBlocker:se})}};if(Ce)return{...he,ref:r,href:Ce,...b&&{children:b},...x&&{target:x},...S&&{disabled:S},...C&&{style:C},...w&&{className:w},...ee&&{onClick:ee},...te&&{onBlur:te},...ne&&{onFocus:ne},...re&&{onMouseEnter:re},...ie&&{onMouseLeave:ie},...oe&&{onTouchStart:oe}};let Ie=e=>{if(S||Me!==`intent`)return;if(!Ne){Pe();return}let t=e.currentTarget;if(wr.has(t))return;let n=setTimeout(()=>{wr.delete(t),Pe()},Ne);wr.set(t,n)},Le=e=>{S||Me!==`intent`||Pe()},Re=e=>{if(S||!Me||!Ne)return;let t=e.currentTarget,n=wr.get(t);n&&(clearTimeout(n),wr.delete(t))};return{...he,...Te,...Ee,href:Se?.href,ref:r,onClick:Er([ee,Fe]),onBlur:Er([te,Re]),onFocus:Er([ne,Ie]),onMouseEnter:Er([re,Ie]),onMouseLeave:Er([ie,Re]),onTouchStart:Er([oe,Le]),disabled:!!S,target:x,...Oe&&{style:Oe},...De&&{className:De},...S&&xr,...we&&Sr,...ge&&ke&&Cr}}var yr={},br={className:`active`},xr={role:`link`,"aria-disabled":!0},Sr={"data-status":`active`,"aria-current":`page`},Cr={"data-transitioning":`transitioning`},wr=new WeakMap,Tr={rootMargin:`100px`},Er=e=>t=>{for(let n of e)if(n){if(t.defaultPrevented)return;n(t)}};function Dr(e,t,n,r){if(!r)return t?{href:e,external:!0}:{href:n.createHref(e)||`/`,external:!1}}function Or(e){if(typeof e!=`string`)return!1;let t=e.charCodeAt(0);return t===47?e.charCodeAt(1)!==47:t===46}var kr=d.forwardRef((e,t)=>{let{_asChild:n,...r}=e,{type:i,...a}=vr(r,t),o=typeof r.children==`function`?r.children({isActive:a[`data-status`]===`active`}):r.children;if(!n){let{disabled:e,...t}=a;return d.createElement(`a`,t,o)}return d.createElement(n,a,o)});function Ar(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}var jr=class extends Sn{constructor(e){super(e),this.useMatch=e=>cr({select:e?.select,from:this.id,structuralSharing:e?.structuralSharing}),this.useRouteContext=e=>mr({...e,from:this.id}),this.useSearch=e=>fr({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useParams=e=>dr({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useLoaderDeps=e=>ur({...e,from:this.id}),this.useLoaderData=e=>lr({...e,from:this.id}),this.useNavigate=()=>pr({from:this.fullPath}),this.Link=d.forwardRef((e,t)=>(0,O.jsx)(kr,{ref:t,from:this.fullPath,...e}))}};function Mr(e){return new jr(e)}var Nr=class extends Cn{constructor(e){super(e),this.useMatch=e=>cr({select:e?.select,from:this.id,structuralSharing:e?.structuralSharing}),this.useRouteContext=e=>mr({...e,from:this.id}),this.useSearch=e=>fr({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useParams=e=>dr({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useLoaderDeps=e=>ur({...e,from:this.id}),this.useLoaderData=e=>lr({...e,from:this.id}),this.useNavigate=()=>pr({from:this.fullPath}),this.Link=d.forwardRef((e,t)=>(0,O.jsx)(kr,{ref:t,from:this.fullPath,...e}))}};function Pr(e){return new Nr(e)}function Fr(e,t){let n,r,i,a,o=()=>(n||=e().then(e=>{n=void 0,r=e[t??`default`]}).catch(e=>{if(i=e,se(i)&&i instanceof Error&&typeof window<`u`&&typeof sessionStorage<`u`){let e=`tanstack_router_reload:${i.message}`;sessionStorage.getItem(e)||(sessionStorage.setItem(e,`1`),a=!0)}}),n),s=function(e){if(a)throw window.location.reload(),new Promise(()=>{});if(i)throw i;if(!r)if(f)f(o());else throw o();return d.createElement(r,e)};return s.preload=o,s}function Ir(e){let t=Nn(),n=`not-found-${ar(t.stores.location,e=>e.pathname)}-${ar(t.stores.status,e=>e)}`;return(0,O.jsx)(En,{getResetKey:()=>n,onCatch:(t,n)=>{if($e(t))e.onCatch?.(t,n);else throw t},errorComponent:({error:t})=>{if($e(t))return e.fallback?.(t);throw t},children:e.children})}function Lr(){return(0,O.jsx)(`p`,{children:`Not Found`})}function Rr(e){return(0,O.jsx)(O.Fragment,{children:e.children})}function zr(e,t,n){return t.options.notFoundComponent?(0,O.jsx)(t.options.notFoundComponent,{...n}):e.options.defaultNotFoundComponent?(0,O.jsx)(e.options.defaultNotFoundComponent,{...n}):(0,O.jsx)(Lr,{})}var Br=(e,t)=>e.routeId===t.routeId&&e._displayPending===t._displayPending,Vr=(e,t)=>e[0]===t[0]&&e[1]===t[1],Hr=d.memo(function({matchId:e}){let t=Nn(),n=t.stores.matchStores.get(e);n||me();let r=ar(t.stores.loadedAt,e=>e),i=ar(n,e=>e,Br);return(0,O.jsx)(Ur,{router:t,matchId:e,resetKey:r,matchState:d.useMemo(()=>{let e=i.routeId,n=t.routesById[e].parentRoute?.id;return{routeId:e,ssr:i.ssr,_displayPending:i._displayPending,parentRouteId:n}},[i._displayPending,i.routeId,i.ssr,t.routesById])})});function Ur({router:e,matchId:t,resetKey:n,matchState:r}){let i=e.routesById[r.routeId],a=i.options.pendingComponent??e.options.defaultPendingComponent,o=a?(0,O.jsx)(a,{}):null,s=i.options.errorComponent??e.options.defaultErrorComponent,c=i.options.onCatch??e.options.defaultOnCatch,l=i.isRoot?i.options.notFoundComponent??e.options.notFoundRoute?.options.component:i.options.notFoundComponent,u=r.ssr===!1||r.ssr===`data-only`,f=(!i.isRoot||i.options.wrapInSuspense||u)&&(i.options.wrapInSuspense??a??(i.options.errorComponent?.preload||u))?d.Suspense:Rr,p=s?En:Rr,m=l?Ir:Rr;return(0,O.jsxs)(i.isRoot?i.options.shellComponent??Rr:Rr,{children:[(0,O.jsx)(Pn.Provider,{value:t,children:(0,O.jsx)(f,{fallback:o,children:(0,O.jsx)(p,{getResetKey:()=>n,errorComponent:s||On,onCatch:(e,t)=>{if($e(e))throw e.routeId??=r.routeId,e;c?.(e,t)},children:(0,O.jsx)(m,{fallback:e=>{if(e.routeId??=r.routeId,!l||e.routeId&&e.routeId!==r.routeId||!e.routeId&&!i.isRoot)throw e;return d.createElement(l,e)},children:u||r._displayPending?(0,O.jsx)(kn,{fallback:o,children:(0,O.jsx)(Gr,{matchId:t})}):(0,O.jsx)(Gr,{matchId:t})})})})}),r.parentRouteId===`__root__`?(0,O.jsxs)(O.Fragment,{children:[(0,O.jsx)(Wr,{}),(e.options.scrollRestoration,null)]}):null]})}function Wr(){let e=Nn(),t=d.useRef();return p(()=>{let n=e.stores.resolvedLocation.get(),r=t.current;n&&(!r||r.href!==n.href)&&e.emit({type:`onRendered`,...un(e.stores.location.get(),r??n)}),t.current=n},[ar(e.stores.resolvedLocation,e=>e?.state.__TSR_key),e]),null}var Gr=d.memo(function({matchId:e}){let t=Nn(),n=(e,n)=>t.getMatch(e.id)?._nonReactive[n]??e._nonReactive[n],r=t.stores.matchStores.get(e);r||me();let i=ar(r,e=>e),a=i.routeId,o=t.routesById[a],s=d.useMemo(()=>{let e=(t.routesById[a].options.remountDeps??t.options.defaultRemountDeps)?.({routeId:a,loaderDeps:i.loaderDeps,params:i._strictParams,search:i._strictSearch});return e?JSON.stringify(e):void 0},[a,i.loaderDeps,i._strictParams,i._strictSearch,t.options.defaultRemountDeps,t.routesById]),c=d.useMemo(()=>{let e=o.options.component??t.options.defaultComponent;return e?(0,O.jsx)(e,{},s):(0,O.jsx)(Kr,{})},[s,o.options.component,t.options.defaultComponent]);if(i._displayPending)throw n(i,`displayPendingPromise`);if(i._forcePending)throw n(i,`minPendingPromise`);if(i.status===`pending`){let e=o.options.pendingMinMs??t.options.defaultPendingMinMs;if(e){let n=t.getMatch(i.id);if(n&&!n._nonReactive.minPendingPromise){let t=oe();n._nonReactive.minPendingPromise=t,setTimeout(()=>{t.resolve(),n._nonReactive.minPendingPromise=void 0},e)}}throw n(i,`loadPromise`)}if(i.status===`notFound`)return $e(i.error)||me(),zr(t,o,i.error);if(i.status===`redirected`)throw Ct(i.error)||me(),n(i,`loadPromise`);if(i.status===`error`)throw i.error;return c}),Kr=d.memo(function(){let e=Nn(),t=d.useContext(Pn),n,r=!1,i;{let a=t?e.stores.matchStores.get(t):void 0;[n,r]=ar(a,e=>[e?.routeId,e?.globalNotFound??!1],Vr),i=ar(e.stores.matchesId,e=>e[e.findIndex(e=>e===t)+1])}let a=n?e.routesById[n]:void 0,o=e.options.defaultPendingComponent?(0,O.jsx)(e.options.defaultPendingComponent,{}):null;if(r)return a||me(),zr(e,a,void 0);if(!i)return null;let s=(0,O.jsx)(Hr,{matchId:i});return n===`__root__`?(0,O.jsx)(d.Suspense,{fallback:o,children:s}):s});function qr(){let e=Nn(),t=d.useRef({router:e,mounted:!1}),[n,r]=d.useState(!1),i=ar(e.stores.isLoading,e=>e),a=ar(e.stores.hasPending,e=>e),o=m(i),s=i||n||a,c=m(s),l=i||a,u=m(l);return e.startTransition=e=>{r(!0),d.startTransition(()=>{e(),r(!1)})},d.useEffect(()=>{let t=e.history.subscribe(e.load),n=e.buildLocation({to:e.latestLocation.pathname,search:!0,params:!0,hash:!0,state:!0,_includeValidateSearch:!0});return Ue(e.latestLocation.publicHref)!==Ue(n.publicHref)&&e.commitLocation({...n,replace:!0}),()=>{t()}},[e,e.history]),p(()=>{typeof window<`u`&&e.ssr||t.current.router===e&&t.current.mounted||(t.current={router:e,mounted:!0},(async()=>{try{await e.load()}catch(e){console.error(e)}})())},[e]),p(()=>{o&&!i&&e.emit({type:`onLoad`,...un(e.stores.location.get(),e.stores.resolvedLocation.get())})},[o,e,i]),p(()=>{u&&!l&&e.emit({type:`onBeforeRouteMount`,...un(e.stores.location.get(),e.stores.resolvedLocation.get())})},[l,u,e]),p(()=>{if(c&&!s){let t=un(e.stores.location.get(),e.stores.resolvedLocation.get());e.emit({type:`onResolved`,...t}),Yn(()=>{e.stores.status.set(`idle`),e.stores.resolvedLocation.set(e.stores.location.get())})}},[s,c,e]),null}function Jr(){let e=Nn(),t=e.routesById.__root__.options.pendingComponent??e.options.defaultPendingComponent,n=t?(0,O.jsx)(t,{}):null,r=(0,O.jsxs)(typeof document<`u`&&e.ssr?Rr:d.Suspense,{fallback:n,children:[(0,O.jsx)(qr,{}),(0,O.jsx)(Yr,{})]});return e.options.InnerWrap?(0,O.jsx)(e.options.InnerWrap,{children:r}):r}function Yr(){let e=Nn(),t=ar(e.stores.firstId,e=>e),n=ar(e.stores.loadedAt,e=>e),r=t?(0,O.jsx)(Hr,{matchId:t}):null;return(0,O.jsx)(Pn.Provider,{value:t,children:e.options.disableGlobalCatchBoundary?r:(0,O.jsx)(En,{getResetKey:()=>n,errorComponent:On,onCatch:void 0,children:r})})}var Xr=e=>({createMutableStore:Qn,createReadonlyStore:Qn,batch:Yn}),Zr=e=>new Qr(e),Qr=class extends dn{constructor(e){super(e,Xr)}};function $r({router:e,children:t,...n}){S(n)&&e.update({...e.options,...n,context:{...e.options.context,...n.context}});let r=(0,O.jsx)(Mn.Provider,{value:e,children:t});return e.options.Wrap?(0,O.jsx)(e.options.Wrap,{children:r}):r}function ei({router:e,...t}){return(0,O.jsx)($r,{router:e,...t,children:(0,O.jsx)(Jr,{})})}function ti(e){let t=Nn({warn:e?.router===void 0}),n=e?.router||t;return ar(n.stores.__store,sr(e,n))}var ni=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,S||(S=!0,re());else{var t=n(l);t!==null&&oe(x,t.startTime-e)}}var S=!1,C=-1,w=5,ee=-1;function te(){return g?!0:!(e.unstable_now()-eet&&te());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&oe(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?re():S=!1}}}var re;if(typeof y==`function`)re=function(){y(ne)};else if(typeof MessageChannel<`u`){var ie=new MessageChannel,ae=ie.port2;ie.port1.onmessage=ne,re=function(){ae.postMessage(null)}}else re=function(){_(ne,0)};function oe(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,oe(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,re()))),r},e.unstable_shouldYield=te,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),ri=o(((e,t)=>{t.exports=ni()})),ii=o((e=>{var t=ri(),n=u(),r=gr();function i(e){var t=`https://react.dev/errors/`+e;if(1de||(e.current=ue[de],ue[de]=null,de--)}function D(e,t){de++,ue[de]=e.current,e.current=t}var me=fe(null),he=fe(null),ge=fe(null),_e=fe(null);function ve(e,t){switch(D(ge,t),D(he,e),D(me,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?qd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=qd(t),e=Jd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}pe(me),D(me,e)}function ye(){pe(me),pe(he),pe(ge)}function be(e){e.memoizedState!==null&&D(_e,e);var t=me.current,n=Jd(t,e.type);t!==n&&(D(he,e),D(me,n))}function xe(e){he.current===e&&(pe(me),pe(he)),_e.current===e&&(pe(_e),ip._currentValue=le)}var Se,Ce;function we(e){if(Se===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);Se=t&&t[1]||``,Ce=-1)`:-1i||c[r]!==l[i]){var u=` +`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{Te=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?we(n):``}function De(e,t){switch(e.tag){case 26:case 27:case 5:return we(e.type);case 16:return we(`Lazy`);case 13:return e.child!==t&&t!==null?we(`Suspense Fallback`):we(`Suspense`);case 19:return we(`SuspenseList`);case 0:case 15:return Ee(e.type,!1);case 11:return Ee(e.type.render,!1);case 1:return Ee(e.type,!0);case 31:return we(`Activity`);default:return``}}function Oe(e){try{var t=``,n=null;do t+=De(e,n),n=e,e=e.return;while(e);return t}catch(e){return` +Error generating stack: `+e.message+` +`+e.stack}}var ke=Object.prototype.hasOwnProperty,Ae=t.unstable_scheduleCallback,je=t.unstable_cancelCallback,Me=t.unstable_shouldYield,Ne=t.unstable_requestPaint,Pe=t.unstable_now,Fe=t.unstable_getCurrentPriorityLevel,Ie=t.unstable_ImmediatePriority,Le=t.unstable_UserBlockingPriority,Re=t.unstable_NormalPriority,ze=t.unstable_LowPriority,Be=t.unstable_IdlePriority,Ve=t.log,He=t.unstable_setDisableYieldValue,Ue=null,We=null;function Ge(e){if(typeof Ve==`function`&&He(e),We&&typeof We.setStrictMode==`function`)try{We.setStrictMode(Ue,e)}catch{}}var Ke=Math.clz32?Math.clz32:Ye,qe=Math.log,Je=Math.LN2;function Ye(e){return e>>>=0,e===0?32:31-(qe(e)/Je|0)|0}var Xe=256,Ze=262144,Qe=4194304;function $e(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function et(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=$e(n))):i=$e(o):i=$e(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=$e(n))):i=$e(o)):i=$e(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function tt(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function nt(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function rt(){var e=Qe;return Qe<<=1,!(Qe&62914560)&&(Qe=4194304),e}function it(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function at(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function ot(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),yn=!1;if(vn)try{var bn={};Object.defineProperty(bn,"passive",{get:function(){yn=!0}}),window.addEventListener(`test`,bn,bn),window.removeEventListener(`test`,bn,bn)}catch{yn=!1}var xn=null,Sn=null,Cn=null;function wn(){if(Cn)return Cn;var e,t=Sn,n=t.length,r,i=`value`in xn?xn.value:xn.textContent,a=i.length;for(e=0;e=er),rr=` `,ir=!1;function ar(e,t){switch(e){case`keyup`:return Qn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function or(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var sr=!1;function cr(e,t){switch(e){case`compositionend`:return or(t);case`keypress`:return t.which===32?(ir=!0,rr):null;case`textInput`:return e=t.data,e===rr&&ir?null:e;default:return null}}function lr(e,t){if(sr)return e===`compositionend`||!$n&&ar(e,t)?(e=wn(),Cn=Sn=xn=null,sr=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Mr(n)}}function Pr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Pr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Fr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Gt(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Gt(e.document)}return t}function Ir(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Lr=vn&&`documentMode`in document&&11>=document.documentMode,Rr=null,zr=null,Br=null,Vr=!1;function Hr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Vr||Rr==null||Rr!==Gt(r)||(r=Rr,`selectionStart`in r&&Ir(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Br&&jr(Br,r)||(Br=r,r=Md(zr,`onSelect`),0>=o,i-=o,Pi=1<<32-Ke(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),M&&Ii(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),M&&Ii(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return M&&Ii(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),M&&Ii(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===_&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case h:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===_){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===te&&Fa(l)===r.type){n(e,r.sibling),c=a(r,o.props),Ha(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===_?(c=Si(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=xi(o.type,o.key,o.props,null,e.mode,c),Ha(c,o),c.return=e,e=c)}return s(e);case g:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=Ti(o,e.mode,c),c.return=e,e=c}return s(e);case te:return o=Fa(o),b(e,r,o,c)}if(ce(o))return v(e,r,o,c);if(ae(o)){if(l=ae(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),y(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Va(o),c);if(o.$$typeof===x)return b(e,r,la(e,o),c);Ua(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=Ci(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{Ba=0;var i=b(e,t,n,r);return za=null,i}catch(t){if(t===ka||t===ja)throw t;var a=_i(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Ga=Wa(!0),Ka=Wa(!1),qa=!1;function Ja(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ya(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function N(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function P(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,B&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=mi(e),pi(e,null,n),t}return ui(e,r,t,n),mi(e)}function Xa(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ct(e,n)}}function Za(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var Qa=!1;function $a(){if(Qa){var e=ba;if(e!==null)throw e}}function eo(e,t,n,r){Qa=!1;var i=e.updateQueue;qa=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,m=f!==s.lane;if(m?(U&f)===f:(r&f)===f){f!==0&&f===ya&&(Qa=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var h=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(h=g.payload,typeof h==`function`){d=h.call(_,d,f);break a}d=h;break a;case 3:h.flags=h.flags&-65537|128;case 0:if(h=g.payload,f=typeof h==`function`?h.call(_,d,f):h,f==null)break a;d=p({},d,f);break a;case 2:qa=!0}}f=s.callback,f!==null&&(e.flags|=64,m&&(e.flags|=8192),m=i.callbacks,m===null?i.callbacks=[f]:m.push(f))}else m={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=m,c=d):u=u.next=m,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;m=s,s=m.next,m.next=null,i.lastBaseUpdate=m,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Zl|=o,e.lanes=o,e.memoizedState=d}}function to(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function no(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=T.T,s={};T.T=s,Bs(e,!1,t,n);try{var c=i(),l=T.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?zs(e,t,Ca(c,r),yu(e)):zs(e,t,r,yu(e))}catch(n){zs(e,t,{then:function(){},status:`rejected`,reason:n},yu())}finally{E.p=a,o!==null&&s.types!==null&&(o.types=s.types),T.T=o}}function ks(){}function As(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=js(e).queue;Os(e,a,t,le,n===null?ks:function(){return Ms(e),n(r)})}function js(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:le,baseState:le,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Vo,lastRenderedState:le},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Vo,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function Ms(e){var t=js(e);t.next===null&&(t=e.alternate.memoizedState),zs(e,t.next.queue,{},yu())}function Ns(){return ca(ip)}function Ps(){return Io().memoizedState}function Fs(){return Io().memoizedState}function Is(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=yu();e=N(n);var r=P(t,e,n);r!==null&&(xu(r,t,n),Xa(r,t,n)),t={cache:ha()},e.payload=t;return}t=t.return}}function Ls(e,t,n){var r=yu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Vs(e)?Hs(t,n):(n=di(e,t,n,r),n!==null&&(xu(n,e,r),Us(n,t,r)))}function Rs(e,t,n){zs(e,t,n,yu())}function zs(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Vs(e))Hs(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Ar(s,o))return ui(e,t,i,0),V===null&&li(),!1}catch{}if(n=di(e,t,i,r),n!==null)return xu(n,e,r),Us(n,t,r),!0}return!1}function Bs(e,t,n,r){if(r={lane:2,revertLane:_d(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},Vs(e)){if(t)throw Error(i(479))}else t=di(e,n,r,2),t!==null&&xu(t,e,2)}function Vs(e){var t=e.alternate;return e===F||t!==null&&t===F}function Hs(e,t){xo=bo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Us(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ct(e,n)}}var Ws={readContext:ca,use:zo,useCallback:L,useContext:L,useEffect:L,useImperativeHandle:L,useLayoutEffect:L,useInsertionEffect:L,useMemo:L,useReducer:L,useRef:L,useState:L,useDebugValue:L,useDeferredValue:L,useTransition:L,useSyncExternalStore:L,useId:L,useHostTransitionStatus:L,useFormState:L,useActionState:L,useOptimistic:L,useMemoCache:L,useCacheRefresh:L};Ws.useEffectEvent=L;var Gs={readContext:ca,use:zo,useCallback:function(e,t){return Fo().memoizedState=[e,t===void 0?null:t],e},useContext:ca,useEffect:hs,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),ps(4194308,4,xs.bind(null,t,e),n)},useLayoutEffect:function(e,t){return ps(4194308,4,e,t)},useInsertionEffect:function(e,t){ps(4,2,e,t)},useMemo:function(e,t){var n=Fo();t=t===void 0?null:t;var r=e();if(So){Ge(!0);try{e()}finally{Ge(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Fo();if(n!==void 0){var i=n(t);if(So){Ge(!0);try{n(t)}finally{Ge(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Ls.bind(null,F,e),[r.memoizedState,e]},useRef:function(e){var t=Fo();return e={current:e},t.memoizedState=e},useState:function(e){e=Zo(e);var t=e.queue,n=Rs.bind(null,F,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:Cs,useDeferredValue:function(e,t){return Es(Fo(),e,t)},useTransition:function(){var e=Zo(!1);return e=Os.bind(null,F,e.queue,!0,!1),Fo().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=F,a=Fo();if(M){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),V===null)throw Error(i(349));U&127||Ko(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,hs(Jo.bind(null,r,o,e),[e]),r.flags|=2048,ds(9,{destroy:void 0},qo.bind(null,r,o,n,t),null),n},useId:function(){var e=Fo(),t=V.identifierPrefix;if(M){var n=Fi,r=Pi;n=(r&~(1<<32-Ke(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=Co++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[ht]=t,o[gt]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Bd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&zc(t)}}return R(t),Bc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&zc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=ge.current,Ji(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=Vi,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[ht]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||Rd(e.nodeValue,n)),e||Gi(t,!0)}else e=Kd(e).createTextNode(r),e[ht]=t,t.stateNode=e}return R(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Ji(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[ht]=t}else Yi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;R(t),e=!1}else n=Xi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(ho(t),t):(ho(t),null);if(t.flags&128)throw Error(i(558))}return R(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Ji(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[ht]=t}else Yi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;R(t),a=!1}else a=Xi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(ho(t),t):(ho(t),null)}return ho(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Hc(t,t.updateQueue),R(t),null);case 4:return ye(),e===null&&Od(t.stateNode.containerInfo),R(t),null;case 10:return na(t.type),R(t),null;case 19:if(pe(go),r=t.memoizedState,r===null)return R(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Uc(r,!1);else{if(Xl!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=_o(e),o!==null){for(t.flags|=128,Uc(r,!1),e=o.updateQueue,t.updateQueue=e,Hc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)bi(n,e),n=n.sibling;return D(go,go.current&1|2),M&&Ii(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Pe()>su&&(t.flags|=128,a=!0,Uc(r,!1),t.lanes=4194304)}else{if(!a)if(e=_o(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,Hc(t,e),Uc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!M)return R(t),null}else 2*Pe()-r.renderingStartTime>su&&n!==536870912&&(t.flags|=128,a=!0,Uc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(R(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Pe(),e.sibling=null,n=go.current,D(go,a?n&1|2:n&1),M&&Ii(t,r.treeForkCount),e);case 22:case 23:return ho(t),so(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(R(t),t.subtreeFlags&6&&(t.flags|=8192)):R(t),n=t.updateQueue,n!==null&&Hc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&pe(Ta),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),na(ma),R(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Gc(e,t){switch(zi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return na(ma),ye(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return xe(t),null;case 31:if(t.memoizedState!==null){if(ho(t),t.alternate===null)throw Error(i(340));Yi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(ho(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Yi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return pe(go),null;case 4:return ye(),null;case 10:return na(t.type),null;case 22:case 23:return ho(t),so(),e!==null&&pe(Ta),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return na(ma),null;case 25:return null;default:return null}}function Kc(e,t){switch(zi(t),t.tag){case 3:na(ma),ye();break;case 26:case 27:case 5:xe(t);break;case 4:ye();break;case 31:t.memoizedState!==null&&ho(t);break;case 13:ho(t);break;case 19:pe(go);break;case 10:na(t.type);break;case 22:case 23:ho(t),so(),e!==null&&pe(Ta);break;case 24:na(ma)}}function qc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){G(t,t.return,e)}}function Jc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){G(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){G(t,t.return,e)}}function Yc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{no(t,n)}catch(t){G(e,e.return,t)}}}function Xc(e,t,n){n.props=Qs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){G(e,t,n)}}function Zc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){G(e,t,n)}}function Qc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){G(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){G(e,t,n)}else n.current=null}function $c(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){G(e,e.return,t)}}function el(e,t,n){try{var r=e.stateNode;Vd(r,e.type,n,t),r[gt]=t}catch(t){G(e,e.return,t)}}function tl(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&rf(e.type)||e.tag===4}function nl(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||tl(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&rf(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function rl(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=ln));else if(r!==4&&(r===27&&rf(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(rl(e,t,n),e=e.sibling;e!==null;)rl(e,t,n),e=e.sibling}function il(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&rf(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(il(e,t,n),e=e.sibling;e!==null;)il(e,t,n),e=e.sibling}function al(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Bd(t,r,n),t[ht]=e,t[gt]=n}catch(t){G(e,e.return,t)}}var ol=!1,sl=!1,cl=!1,ll=typeof WeakSet==`function`?WeakSet:Set,ul=null;function dl(e,t){if(e=e.containerInfo,Wd=pp,e=Fr(e),Ir(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(Gd={focusedElem:e,selectionRange:n},pp=!1,ul=t;ul!==null;)if(t=ul,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,ul=e;else for(;ul!==null;){switch(t=ul,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Bd(o,r,n),o[ht]=e,Ot(o),r=o;break a;case`link`:var s=qf(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=Nr(s,h),v=Nr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,T.T=null,n=hu,hu=null;var o=du,s=pu;if(uu=0,fu=du=null,pu=0,B&6)throw Error(i(331));var c=B;if(B|=4,Bl(o.current),Ml(o,o.current,s,n),B=c,ud(0,!1),We&&typeof We.onPostCommitFiberRoot==`function`)try{We.onPostCommitFiberRoot(Ue,o)}catch{}return!0}finally{E.p=a,T.T=r,qu(e,t)}}function Xu(e,t,n){t=Di(n,t),t=ic(e.stateNode,t,2),e=P(e,t,2),e!==null&&(at(e,2),ld(e))}function G(e,t,n){if(e.tag===3)Xu(e,e,n);else for(;t!==null;){if(t.tag===3){Xu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(lu===null||!lu.has(r))){e=Di(n,e),n=ac(2),r=P(t,n,2),r!==null&&(oc(n,r,t,e),at(r,2),ld(r));break}}t=t.return}}function Zu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Wl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Jl=!0,i.add(n),e=Qu.bind(null,e,t,n),t.then(e,e))}function Qu(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,V===e&&(U&n)===n&&(Xl===4||Xl===3&&(U&62914560)===U&&300>Pe()-au?!(B&2)&&Ou(e,0):$l|=n,tu===U&&(tu=0)),ld(e)}function $u(e,t){t===0&&(t=rt()),e=fi(e,t),e!==null&&(at(e,t),ld(e))}function ed(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),$u(e,n)}function td(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),$u(e,n)}function nd(e,t){return Ae(e,t)}var rd=null,id=null,ad=!1,od=!1,sd=!1,cd=0;function ld(e){e!==id&&e.next===null&&(id===null?rd=id=e:id=id.next=e),od=!0,ad||(ad=!0,gd())}function ud(e,t){if(!sd&&od){sd=!0;do for(var n=!1,r=rd;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Ke(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,hd(r,a))}else a=U,a=et(r,r===V?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||tt(r,a)||(n=!0,hd(r,a));r=r.next}while(n);sd=!1}}function dd(){fd()}function fd(){od=ad=!1;var e=0;cd!==0&&Zd()&&(e=cd);for(var t=Pe(),n=null,r=rd;r!==null;){var i=r.next,a=pd(r,t);a===0?(r.next=null,n===null?rd=i:n.next=i,i===null&&(id=n)):(n=r,(e!==0||a&3)&&(od=!0)),r=i}uu!==0&&uu!==5||ud(e,!1),cd!==0&&(cd=0)}function pd(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Hd(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function Df(e,t,n){var r=Ef;if(r&&typeof t==`string`&&t){var i=qt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),xf.has(i)||(xf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Bd(t,`link`,e),Ot(t),r.head.appendChild(t)))}}function Of(e){Cf.D(e),Df(`dns-prefetch`,e,null)}function kf(e,t){Cf.C(e,t),Df(`preconnect`,e,t)}function Af(e,t,n){Cf.L(e,t,n);var r=Ef;if(r&&e&&t){var i=`link[rel="preload"][as="`+qt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+qt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+qt(n.imageSizes)+`"]`)):i+=`[href="`+qt(e)+`"]`;var a=i;switch(t){case`style`:a=If(e);break;case`script`:a=Bf(e)}bf.has(a)||(e=p({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),bf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(Lf(a))||t===`script`&&r.querySelector(Vf(a))||(t=r.createElement(`link`),Bd(t,`link`,e),Ot(t),r.head.appendChild(t)))}}function jf(e,t){Cf.m(e,t);var n=Ef;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+qt(r)+`"][href="`+qt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Bf(e)}if(!bf.has(a)&&(e=p({rel:`modulepreload`,href:e},t),bf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Vf(a)))return}r=n.createElement(`link`),Bd(r,`link`,e),Ot(r),n.head.appendChild(r)}}}function Mf(e,t,n){Cf.S(e,t,n);var r=Ef;if(r&&e){var i=Dt(r).hoistableStyles,a=If(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(Lf(a)))s.loading=5;else{e=p({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=bf.get(a))&&Wf(e,n);var c=o=r.createElement(`link`);Ot(c),Bd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Uf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Nf(e,t){Cf.X(e,t);var n=Ef;if(n&&e){var r=Dt(n).hoistableScripts,i=Bf(e),a=r.get(i);a||(a=n.querySelector(Vf(i)),a||(e=p({src:e,async:!0},t),(t=bf.get(i))&&Gf(e,t),a=n.createElement(`script`),Ot(a),Bd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Pf(e,t){Cf.M(e,t);var n=Ef;if(n&&e){var r=Dt(n).hoistableScripts,i=Bf(e),a=r.get(i);a||(a=n.querySelector(Vf(i)),a||(e=p({src:e,async:!0,type:`module`},t),(t=bf.get(i))&&Gf(e,t),a=n.createElement(`script`),Ot(a),Bd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Ff(e,t,n,r){var a=(a=ge.current)?Sf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=If(n.href),n=Dt(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=If(n.href);var o=Dt(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(Lf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),bf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},bf.set(e,n),o||zf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Bf(n),n=Dt(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function If(e){return`href="`+qt(e)+`"`}function Lf(e){return`link[rel="stylesheet"][`+e+`]`}function Rf(e){return p({},e,{"data-precedence":e.precedence,precedence:null})}function zf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Bd(t,`link`,n),Ot(t),e.head.appendChild(t))}function Bf(e){return`[src="`+qt(e)+`"]`}function Vf(e){return`script[async]`+e}function Hf(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+qt(n.href)+`"]`);if(r)return t.instance=r,Ot(r),r;var a=p({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),Ot(r),Bd(r,`style`,a),Uf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=If(n.href);var o=e.querySelector(Lf(a));if(o)return t.state.loading|=4,t.instance=o,Ot(o),o;r=Rf(n),(a=bf.get(a))&&Wf(r,a),o=(e.ownerDocument||e).createElement(`link`),Ot(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Bd(o,`link`,r),t.state.loading|=4,Uf(o,n.precedence,e),t.instance=o;case`script`:return o=Bf(n.src),(a=e.querySelector(Vf(o)))?(t.instance=a,Ot(a),a):(r=n,(a=bf.get(o))&&(r=p({},n),Gf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),Ot(a),Bd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Uf(r,n.precedence,e));return t.instance}function Uf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Yf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Xf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Zf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=If(r.href),a=t.querySelector(Lf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=ep.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,Ot(a);return}a=t.ownerDocument||t,r=Rf(r),(i=bf.get(i))&&Wf(r,i),a=a.createElement(`link`),Ot(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Bd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=ep.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Qf=0;function $f(e,t){return e.stylesheets&&e.count===0&&np(e,e.stylesheets),0Qf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function ep(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)np(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var tp=null;function np(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,tp=new Map,t.forEach(rp,e),tp=null,ep.call(e))}function rp(e,t){if(!(t.state.loading&4)){var n=tp.get(e);if(n)var r=n.get(null);else{n=new Map,tp.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=ii()})),oi=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),si=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),ci=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(e,t,n)=>n?n.toUpperCase():t.toLowerCase()),li=e=>{let t=ci(e);return t.charAt(0).toUpperCase()+t.slice(1)},ui={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},di=e=>{for(let t in e)if(t.startsWith(`aria-`)||t===`role`||t===`title`)return!0;return!1},fi=(0,d.createContext)({}),pi=()=>(0,d.useContext)(fi),mi=(0,d.forwardRef)(({color:e,size:t,strokeWidth:n,absoluteStrokeWidth:r,className:i=``,children:a,iconNode:o,...s},c)=>{let{size:l=24,strokeWidth:u=2,absoluteStrokeWidth:f=!1,color:p=`currentColor`,className:m=``}=pi()??{},h=r??f?Number(n??u)*24/Number(t??l):n??u;return(0,d.createElement)(`svg`,{ref:c,...ui,width:t??l??ui.width,height:t??l??ui.height,stroke:e??p,strokeWidth:h,className:oi(`lucide`,m,i),...!a&&!di(s)&&{"aria-hidden":`true`},...s},[...o.map(([e,t])=>(0,d.createElement)(e,t)),...Array.isArray(a)?a:[a]])}),hi=(e,t)=>{let n=(0,d.forwardRef)(({className:n,...r},i)=>(0,d.createElement)(mi,{ref:i,iconNode:t,className:oi(`lucide-${si(li(e))}`,`lucide-${e}`,n),...r}));return n.displayName=li(e),n},gi=hi(`activity`,[[`path`,{d:`M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2`,key:`169zse`}]]),_i=hi(`bot`,[[`path`,{d:`M12 8V4H8`,key:`hb8ula`}],[`rect`,{width:`16`,height:`12`,x:`4`,y:`8`,rx:`2`,key:`enze0r`}],[`path`,{d:`M2 14h2`,key:`vft8re`}],[`path`,{d:`M20 14h2`,key:`4cs60a`}],[`path`,{d:`M15 13v2`,key:`1xurst`}],[`path`,{d:`M9 13v2`,key:`rq6x2g`}]]),vi=hi(`boxes`,[[`path`,{d:`M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z`,key:`lc1i9w`}],[`path`,{d:`m7 16.5-4.74-2.85`,key:`1o9zyk`}],[`path`,{d:`m7 16.5 5-3`,key:`va8pkn`}],[`path`,{d:`M7 16.5v5.17`,key:`jnp8gn`}],[`path`,{d:`M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z`,key:`8zsnat`}],[`path`,{d:`m17 16.5-5-3`,key:`8arw3v`}],[`path`,{d:`m17 16.5 4.74-2.85`,key:`8rfmw`}],[`path`,{d:`M17 16.5v5.17`,key:`k6z78m`}],[`path`,{d:`M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z`,key:`1xygjf`}],[`path`,{d:`M12 8 7.26 5.15`,key:`1vbdud`}],[`path`,{d:`m12 8 4.74-2.85`,key:`3rx089`}],[`path`,{d:`M12 13.5V8`,key:`1io7kd`}]]),yi=hi(`gauge`,[[`path`,{d:`m12 14 4-4`,key:`9kzdfg`}],[`path`,{d:`M3.34 19a10 10 0 1 1 17.32 0`,key:`19p75a`}]]),bi=hi(`rotate-ccw`,[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`,key:`1357e3`}],[`path`,{d:`M3 3v5h5`,key:`1xhq8a`}]]),xi=hi(`settings`,[[`path`,{d:`M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915`,key:`1i5ecw`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]]),Si=hi(`triangle-alert`,[[`path`,{d:`m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3`,key:`wmoenq`}],[`path`,{d:`M12 9v4`,key:`juzpu7`}],[`path`,{d:`M12 17h.01`,key:`p32p05`}]]),Ci=hi(`wrench`,[[`path`,{d:`M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z`,key:`1ngwbx`}]]),wi=ai(),Ti=class extends d.Component{state={error:null};static getDerivedStateFromError(e){return{error:e}}componentDidCatch(e,t){console.error(`Dashboard render defect`,e,t.componentStack)}render(){return this.state.error?(0,O.jsx)(`main`,{className:`mx-auto flex min-h-screen max-w-2xl items-center px-6 py-16`,children:(0,O.jsxs)(`section`,{className:`w-full rounded-xl border border-bad-edge bg-card p-6 shadow-card`,children:[(0,O.jsx)(Si,{"aria-hidden":!0,className:`mb-4 size-6 text-bad`}),(0,O.jsx)(`h1`,{className:`text-lg font-[650]`,children:`The dashboard hit an unexpected error`}),(0,O.jsx)(`p`,{className:`mt-2 text-sm text-mut`,children:`The queue keeps running. Reload this view; if it repeats, the detail below is the defect to inspect.`}),(0,O.jsx)(`pre`,{className:`mt-4 max-h-48 overflow-auto rounded-lg bg-ink p-3 text-xs text-white/80`,children:this.state.error.message}),(0,O.jsxs)(`button`,{type:`button`,onClick:()=>location.reload(),className:`mt-4 inline-flex items-center gap-2 rounded-lg bg-ink px-4 py-2 text-sm font-semibold text-white`,children:[(0,O.jsx)(bi,{"aria-hidden":!0,className:`size-4`}),`Reload dashboard`]})]})}):this.props.children}},Ei=o((e=>{var t=u().__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;e.c=function(e){return t.H.useMemoCache(e)}})),Di=o(((e,t)=>{t.exports=Ei()})),Oi=(e,t)=>{switch(t.length){case 0:return e;case 1:return t[0](e);case 2:return t[1](t[0](e));case 3:return t[2](t[1](t[0](e)));case 4:return t[3](t[2](t[1](t[0](e))));case 5:return t[4](t[3](t[2](t[1](t[0](e)))));case 6:return t[5](t[4](t[3](t[2](t[1](t[0](e))))));case 7:return t[6](t[5](t[4](t[3](t[2](t[1](t[0](e)))))));case 8:return t[7](t[6](t[5](t[4](t[3](t[2](t[1](t[0](e))))))));case 9:return t[8](t[7](t[6](t[5](t[4](t[3](t[2](t[1](t[0](e)))))))));default:{let n=e;for(let e=0,r=t.length;et(e,...arguments)};switch(e){case 0:case 1:throw RangeError(`Invalid arity ${e}`);case 2:return function(e,n){return arguments.length>=2?t(e,n):function(n){return t(n,e)}};case 3:return function(e,n,r){return arguments.length>=3?t(e,n,r):function(r){return t(r,e,n)}};default:return function(){if(arguments.length>=e)return t.apply(this,arguments);let n=arguments;return function(e){return t(e,...n)}}}},ji=e=>e,Mi=(e=>()=>e)(void 0),Ni=Mi;function Pi(e){let t=new WeakMap;return n=>{if(t.has(n))return t.get(n);let r=e(n);return t.set(n,r),r}}var Fi=e=>{let t=new Set(Reflect.ownKeys(e));if(e.constructor===Object)return t;e instanceof Error&&t.delete(`stack`);let n=Object.getPrototypeOf(e),r=n;for(;r!==null&&r!==Object.prototype;){let e=Reflect.ownKeys(r);for(let n=0;nUi(e)&&t in e),Gi=`~effect/interfaces/Hash`,Ki=e=>{switch(typeof e){case`number`:return Zi(e);case`bigint`:return Qi(e.toString(10));case`boolean`:return Qi(String(e));case`symbol`:return Qi(String(e));case`string`:return Qi(e);case`undefined`:return Qi(`undefined`);case`function`:case`object`:if(e===null)return Qi(`null`);if(e instanceof Date)return Qi(e.toISOString());if(e instanceof RegExp)return Qi(e.toString());{if(Ii.has(e))return qi(e);if(oa.has(e))return oa.get(e);let t=ca(e,()=>Xi(e)?e[Gi]():typeof e==`function`?qi(e):Array.isArray(e)||ArrayBuffer.isView(e)?na(e):e instanceof Map?ra(e):e instanceof Set?ia(e):ea(e));return oa.set(e,t),t}default:throw Error(`BUG: unhandled typeof ${typeof e} - please report an issue at https://github.com/Effect-TS/effect/issues`)}},qi=e=>(aa.has(e)||aa.set(e,Zi(Math.floor(Math.random()*(2**53-1)))),aa.get(e)),Ji=A(2,(e,t)=>e*53^t),Yi=e=>e&3221225471|e>>>1&1073741824,Xi=e=>Wi(e,Gi),Zi=e=>{if(e!==e)return Qi(`NaN`);if(e===1/0)return Qi(`Infinity`);if(e===-1/0)return Qi(`-Infinity`);let t=e|0;for(t!==e&&(t^=e*4294967295);e>4294967295;)t^=e/=4294967295;return Yi(t)},Qi=e=>{let t=5381,n=e.length;for(;n;)t=t*33^e.charCodeAt(--n);return Yi(t)},$i=(e,t)=>{let n=12289;for(let r of t)n^=Ji(Ki(r),Ki(e[r]));return Yi(n)},ea=e=>$i(e,Fi(e)),ta=(e,t)=>n=>{let r=e;for(let e of n)r^=t(e);return Yi(r)},na=ta(6151,Ki),ra=ta(Qi(`Map`),([e,t])=>Ji(Ki(e),Ki(t))),ia=ta(Qi(`Set`),Ki),aa=new WeakMap,oa=new WeakMap,sa=new WeakSet;function ca(e,t){if(sa.has(e))return Qi(`[Circular]`);sa.add(e);let n=t();return sa.delete(e),n}var la=`~effect/interfaces/Equal`;function ua(){return arguments.length===1?e=>da(e,arguments[0]):da(arguments[0],arguments[1])}function da(e,t){if(e===t)return!0;if(e==null||t==null)return!1;let n=typeof e;return n===typeof t?n===`number`&&e!==e&&t!==t?!0:n!==`object`&&n!==`function`||Ii.has(e)||Ii.has(t)?!1:ga(e,t,ha):!1}function fa(e,t,n){let r=pa.has(e),i=ma.has(t);if(r&&i)return!0;if(r||i)return!1;pa.add(e),ma.add(t);let a=n();return pa.delete(e),ma.delete(t),a}var pa=new WeakSet,ma=new WeakSet;function ha(e,t){if(Ki(e)!==Ki(t))return!1;if(e instanceof Date)return t instanceof Date&&e.toISOString()===t.toISOString();if(e instanceof RegExp)return t instanceof RegExp&&e.toString()===t.toString();let n=Ta(e),r=Ta(t);if(n!==r)return!1;let i=n&&r;return typeof e==`function`&&!i?!1:fa(e,t,()=>i?e[la](t):Array.isArray(e)?!Array.isArray(t)||e.length!==t.length?!1:va(e,t):ArrayBuffer.isView(e)?!ArrayBuffer.isView(t)||e.byteLength!==t.byteLength?!1:ya(e,t):e instanceof Map?!(t instanceof Map)||e.size!==t.size?!1:Sa(e,t):e instanceof Set?!(t instanceof Set)||e.size!==t.size?!1:wa(e,t):ba(e,t))}function ga(e,t,n){let r=_a.get(e);if(!r)r=new WeakMap,_a.set(e,r);else if(r.has(t))return r.get(t);let i=n(e,t);r.set(t,i);let a=_a.get(t);return a||(a=new WeakMap,_a.set(t,a)),a.set(e,i),i}var _a=new WeakMap;function va(e,t){for(let n=0;nWi(e,la),Ea=e=>(t,n)=>t===n||e(t,n),Da=e=>e.length>0;function Oa(e,t,n){t===`__proto__`?Object.defineProperty(e,t,{value:n,writable:!0,enumerable:!0,configurable:!0}):e[t]=n}function ka(e,t){for(let n of Reflect.ownKeys(t))Object.prototype.propertyIsEnumerable.call(t,n)&&Oa(e,n,t[n])}var Aa=Symbol.for(`~effect/Redactable`),ja=e=>Wi(e,Aa);function Ma(e){return ja(e)?Na(e):e}function Na(e){return e[Aa](globalThis[`~effect/Fiber/currentFiber`]?.context??Fa)}var Pa=`~effect/Fiber/currentFiber`,Fa={"~effect/Context":{},mapUnsafe:new Map,pipe(){return Oi(this,arguments)}};function Ia(e,t){let n=t?.space??0,r=new WeakSet,i=n?typeof n==`number`?` `.repeat(n):n:``,a=e=>i.repeat(e),o=(e,t)=>{let n=e?.constructor;return n&&n!==Object.prototype.constructor&&n.name?`${n.name}(${t})`:t},s=e=>{try{return Reflect.ownKeys(e)}catch{return[`[ownKeys threw]`]}};function c(e,n=0){if(Array.isArray(e)){if(r.has(e))return La;if(r.add(e),!i||e.length<=1)return`[${e.map(e=>c(e,n)).join(`,`)}]`;let t=e.map(e=>c(e,n+1)).join(`, +`+a(n+1));return`[\n${a(n+1)}${t}\n${a(n)}]`}if(e instanceof Date)return Ba(e);if(!t?.ignoreToString&&Wi(e,`toString`)&&typeof e.toString==`function`&&e.toString!==Object.prototype.toString&&e.toString!==Array.prototype.toString){let t=Va(e);return e instanceof Error&&e.cause?`${t} (cause: ${c(e.cause,n)})`:t}if(typeof e==`string`)return JSON.stringify(e);if(typeof e==`number`||e==null||typeof e==`boolean`||typeof e==`symbol`)return String(e);if(typeof e==`bigint`)return String(e)+`n`;if(typeof e==`object`||typeof e==`function`){if(r.has(e))return La;if(r.add(e),Aa in e)return Ia(Na(e));if(Symbol.iterator in e)return`${e.constructor.name}(${c(Array.from(e),n)})`;let t=s(e);if(!i||t.length<=1){let r=`{${t.map(t=>`${Ra(t)}:${c(e[t],n)}`).join(`,`)}}`;return o(e,r)}let l=`{\n${t.map(t=>`${a(n+1)}${Ra(t)}: ${c(e[t],n+1)}`).join(`, +`)}\n${a(n)}}`;return o(e,l)}return String(e)}return c(e,0)}var La=`[Circular]`;function Ra(e){return typeof e==`string`?JSON.stringify(e):String(e)}function za(e){return e.map(e=>`[${Ra(e)}]`).join(``)}function Ba(e){try{return e.toISOString()}catch{return`Invalid Date`}}function Va(e){try{let t=e.toString();return typeof t==`string`?t:String(t)}catch{return`[toString threw]`}}var Ha=Symbol.for(`nodejs.util.inspect.custom`),Ua=e=>{try{if(Wi(e,`toJSON`)&&j(e.toJSON)&&e.toJSON.length===0)return e.toJSON();if(Array.isArray(e))return e.map(Ua)}catch{return`[toJSON threw]`}return Ma(e)},Wa=class e{called=!1;self;constructor(e){this.self=e}next(e){return this.called?{value:e,done:!0}:(this.called=!0,{value:this.self,done:!1})}[Symbol.iterator](){return new e(this.self)}},Ga=(()=>{let e=`~effect/Utils/internal`,t={[e]:e=>e()},n={[e]:e=>{try{return e()}finally{}}};return t[e](()=>Error().stack)?.includes(e)===!0?t[e]:n[e]})(),Ka=`~effect/Effect`,qa=`~effect/Exit`,Ja={_A:ji,_E:ji,_R:ji},Ya=`${Ka}/identifier`,N=`${Ka}/args`,P=`${Ka}/evaluate`,Xa=`${Ka}/successCont`,Za=`${Ka}/failureCont`,Qa=`${Ka}/ensureCont`,$a=Symbol.for(`effect/Effect/Yield`),eo={pipe(){return Oi(this,arguments)},toJSON(){return{...this}},toString(){return Ia(this.toJSON(),{ignoreToString:!0,space:2})},[Ha](){return this.toJSON()}},to={[Ka]:Ja,...eo,[Symbol.iterator](){return new Wa(this)},toJSON(){return{_id:`Effect`,op:this[Ya],...N in this?{args:this[N]}:void 0}}},no=e=>Wi(e,Ka),ro=e=>Wi(e,qa),io=`~effect/Cause`,ao=`~effect/Cause/Reason`,oo=e=>Wi(e,io),so=class{[io];reasons;constructor(e){this[io]=io,this.reasons=e}pipe(){return Oi(this,arguments)}toJSON(){return{_id:`Cause`,failures:this.reasons.map(e=>e.toJSON())}}toString(){return`Cause(${Ia(this.reasons)})`}[Ha](){return this.toJSON()}[la](e){return oo(e)&&this.reasons.length===e.reasons.length&&this.reasons.every((t,n)=>ua(t,e.reasons[n]))}[Gi](){return na(this.reasons)}},co=new WeakMap,lo=class{[ao];annotations;_tag;constructor(e,t,n){if(this[ao]=ao,this._tag=e,t!==uo&&typeof n==`object`&&n&&t.size>0){let e=co.get(n);e&&(t=new Map([...e,...t])),co.set(n,t)}this.annotations=t}annotate(e,t){if(e.mapUnsafe.size===0)return this;let n=new Map(this.annotations);e.mapUnsafe.forEach((e,r)=>{t?.overwrite!==!0&&n.has(r)||n.set(r,e)});let r=Object.assign(Object.create(Object.getPrototypeOf(this)),this);return r.annotations=n,r}pipe(){return Oi(this,arguments)}toString(){return Ia(this)}[Ha](){return this.toString()}},uo=new Map,fo=class extends lo{error;constructor(e,t=uo){super(`Fail`,t,e),this.error=e}toString(){return`Fail(${Ia(this.error)})`}toJSON(){return{_tag:`Fail`,error:this.error}}[la](e){return vo(e)&&ua(this.error,e.error)&&ua(this.annotations,e.annotations)}[Gi](){return Ji(Qi(this._tag))(Ji(Ki(this.error))(Ki(this.annotations)))}},po=e=>new so(e),mo=e=>new so([new fo(e)]),ho=class extends lo{defect;constructor(e,t=uo){super(`Die`,t,e),this.defect=e}toString(){return`Die(${Ia(this.defect)})`}toJSON(){return{_tag:`Die`,defect:this.defect}}[la](e){return F(e)&&ua(this.defect,e.defect)&&ua(this.annotations,e.annotations)}[Gi](){return Ji(Qi(this._tag))(Ji(Ki(this.defect))(Ki(this.annotations)))}},go=e=>new so([new ho(e)]),_o=A(e=>oo(e[0]),(e,t,n)=>t.mapUnsafe.size===0?e:new so(e.reasons.map(e=>e.annotate(t,n)))),vo=e=>e._tag===`Fail`,F=e=>e._tag===`Die`,I=e=>e._tag===`Interrupt`;function yo(e){return Do(`Effect.evaluate: Not implemented`)}var bo=e=>({...to,[Ya]:e.op,[P]:e[P]??yo,[Xa]:e[Xa],[Za]:e[Za],[Qa]:e[Qa]}),xo=e=>{let t=bo(e);return function(){let n=Object.create(t);return n[N]=e.single===!1?arguments:arguments[0],n}},So=e=>{let t={...bo(e),[qa]:qa,_tag:e.op,get[e.prop](){return this[N]},toString(){return`${e.op}(${Ia(this[N])})`},toJSON(){return{_id:`Exit`,_tag:e.op,[e.prop]:this[N]}},[la](e){return ro(e)&&e._tag===this._tag&&ua(this[N],e[N])},[Gi](){return Ji(Qi(e.op),Ki(this[N]))}};return function(e){let n=Object.create(t);return n[N]=e,n}},Co=So({op:`Success`,prop:`value`,[P](e){let t=e.getCont(Xa);return t?t[Xa](this[N],e,this):e.yieldWith(this)}}),wo={key:`effect/Cause/StackTrace`},To={key:`effect/Cause/InterruptorStackTrace`},Eo=So({op:`Failure`,prop:`cause`,[P](e){let t=this[N],n=!1;e.currentStackFrame&&(t=_o(t,{mapUnsafe:new Map([[wo.key,e.currentStackFrame]])}),n=!0);let r=e.getCont(Za);for(;e.interruptible&&e._interruptedCause&&r;)r=e.getCont(Za);return r?r[Za](t,e,n?void 0:this):e.yieldWith(n?Eo(t):this)}}),L=e=>Eo(mo(e)),Do=e=>Eo(go(e)),Oo=xo({op:`WithFiber`,[P](e){return this[N](e)}}),ko=function(){class e extends globalThis.Error{}let t=bo({op:`YieldableError`,[P](){return L(this)}});return delete t.toString,Object.assign(e.prototype,t),e}(),Ao=function(){let e=Symbol.for(`effect/Data/Error/plainArgs`);return class extends ko{constructor(t){super(t?.message,t?.cause?{cause:t.cause}:void 0),t&&(ka(this,t),Object.defineProperty(this,e,{value:t,enumerable:!1}))}toJSON(){return{...this[e],...this}}}}(),jo=e=>{class t extends Ao{_tag=e}return t.prototype.name=e,t};jo(`NoSuchElementError`);var Mo=`~effect/data/Option`,No={[Mo]:{_A:e=>e},...eo,[Symbol.iterator](){return new Wa(this)}},Po=Object.defineProperty(Object.assign(Object.create(No),{_tag:`Some`,_op:`Some`,[la](e){return Lo(e)&&zo(e)&&ua(this.value,e.value)},[Gi](){return Ji(Ki(this._tag))(Ki(this.value))},toString(){return`some(${Ia(this.value)})`},toJSON(){return{_id:`Option`,_tag:this._tag,value:Ua(this.value)}}}),"valueOrUndefined",{get(){return this.value}}),Fo=Ki(`None`),Io=Object.assign(Object.create(No),{_tag:`None`,_op:`None`,valueOrUndefined:void 0,[la](e){return Lo(e)&&Ro(e)},[Gi](){return Fo},toString(){return`none()`},toJSON(){return{_id:`Option`,_tag:this._tag}}}),Lo=e=>Wi(e,Mo),Ro=e=>e._tag===`None`,zo=e=>e._tag===`Some`,Bo=Object.create(Io),Vo=e=>{let t=Object.create(Po);return t.value=e,t},Ho=`~effect/data/Result`,Uo={[Ho]:{_A:e=>e,_E:e=>e},...eo,[Symbol.iterator](){return new Wa(this)}},Wo=Object.assign(Object.create(Uo),{_tag:`Success`,_op:`Success`,[la](e){return Ko(e)&&Jo(e)&&ua(this.success,e.success)},[Gi](){return Ji(Ki(this._tag))(Ki(this.success))},toString(){return`success(${Ia(this.success)})`},toJSON(){return{_id:`Result`,_tag:this._tag,value:Ua(this.success)}}}),Go=Object.assign(Object.create(Uo),{_tag:`Failure`,_op:`Failure`,[la](e){return Ko(e)&&qo(e)&&ua(this.failure,e.failure)},[Gi](){return Ji(Ki(this._tag))(Ki(this.failure))},toString(){return`failure(${Ia(this.failure)})`},toJSON(){return{_id:`Result`,_tag:this._tag,failure:Ua(this.failure)}}}),Ko=e=>Wi(e,Ho),qo=e=>e._tag===`Failure`,Jo=e=>e._tag===`Success`,Yo=e=>{let t=Object.create(Go);return t.failure=e,t},Xo=e=>{let t=Object.create(Wo);return t.success=e,t},Zo=()=>Bo,Qo=Vo,$o=Ro,es=zo,ts=A(2,(e,t)=>$o(e)?Zo():Qo(t(e.value))),ns=Xo,rs=Yo,is=qo,as=globalThis.Array,os=e=>as.isArray(e)?e:as.from(e),ss=A(2,(e,t)=>[...e,t]),cs=A(2,(e,t)=>os(e).concat(os(t)));as.isArray;var ls=Da,us=Da,ds=(e,t)=>{let n=Ki(t),r=e.get(n);if(r===void 0)return e.set(n,[t]),!0;for(let e of r)if(ua(e,t))return!1;return r.push(t),!0},fs=A(2,(e,t)=>{let n=os(e),r=os(t);return us(n)?us(r)?hs(cs(n,r)):n:r}),ps=()=>[],ms=A(2,(e,t)=>e.map(t)),hs=e=>{let t=os(e);if(t.length<2)return[...t];let n=new Map,r=[];for(let e of t)ds(n,e)&&r.push(e);return r},gs=`~effect/BigDecimal`,_s={[gs]:gs,[Gi](){let e=ws(this);return Ji(Ki(e.value),Zi(e.scale))},[la](e){return vs(e)&&Os(this,e)},toString(){return`BigDecimal(${ks(this)})`},toJSON(){return{_id:`BigDecimal`,value:String(this.value),scale:this.scale}},[Ha](){return this.toJSON()},pipe(){return Oi(this,arguments)}},vs=e=>Wi(e,gs),ys=(e,t)=>{let n=Object.create(_s);return n.value=e,n.scale=t,n},bs=(e,t)=>{if(e!==xs&&e%Ss===xs)throw RangeError(`Value must be normalized`);let n=ys(e,t);return n.normalized=n,n},xs=BigInt(0),Ss=BigInt(10),Cs=bs(xs,0),ws=e=>{if(e.normalized===void 0)if(e.value===xs)e.normalized=Cs;else{let t=`${e.value}`,n=0;for(let e=t.length-1;e>=0&&t[e]===`0`;e--)n++;n===0&&(e.normalized=e),e.normalized=bs(BigInt(t.substring(0,t.length-n)),e.scale-n)}return e.normalized},Ts=A(2,(e,t)=>t>e.scale?ys(e.value*Ss**BigInt(t-e.scale),t):te.valuee.scale>t.scale?Ts(t,e.scale).value===e.value:e.scaleDs(e,t)),ks=e=>{let t=ws(e);if(Math.abs(t.scale)>=16)return As(t);let n=t.value=r.length)i=`0`,a=`0`.repeat(t.scale-r.length)+r;else{let e=r.length-t.scale;if(e>r.length){let t=e-r.length;i=`${r}${`0`.repeat(t)}`,a=``}else a=r.slice(e),i=r.slice(0,e)}let o=a===``?i:`${i}.${a}`;return n?`-${o}`:o},As=e=>{if(js(e))return`0e+0`;let t=ws(e),n=`${Es(t).value}`,r=n.slice(0,1),i=n.slice(1),a=`${Ms(t)?`-`:``}${r}`;i!==``&&(a+=`.${i}`);let o=i.length-t.scale;return`${a}e${o>=0?`+`:``}${o}`},js=e=>e.value===xs,Ms=e=>e.valuebo({op:e.label,[P]:e.evaluate}),Ps=(()=>{let e=Object.getOwnPropertyDescriptor(Error,`stackTraceLimit`);return e===void 0?Object.isExtensible(Error):Object.hasOwn(e,`writable`)?e.writable===!0:e.set!==void 0})(),Fs=()=>Error.stackTraceLimit,Is=e=>{Ps&&(Error.stackTraceLimit=e)},Ls=`~effect/Context/Service`,Rs=function(){let e=Fs();Is(2);let t=Error();Is(e);function n(){}let r=n;return Object.setPrototypeOf(r,zs),Object.defineProperty(r,"stack",{get(){return t.stack}}),arguments.length>0?(r.key=arguments[0],arguments[1]?.defaultValue&&(r[Bs]=Bs,r.defaultValue=arguments[1].defaultValue),r):function(e,t){return r.key=e,t?.make&&(r.make=t.make),r}},zs={[Ls]:Ls,...Ns({label:`Service`,evaluate(e){return Co(Ys(e.context,this))}}),toJSON(){return{_id:`Service`,key:this.key,stack:this.stack}},of(e){return e},context(e){return qs(this,e)},use(e){return Oo(t=>e(Ys(t.context,this)))},useSync(e){return Oo(t=>Co(e(Ys(t.context,this))))}},Bs=`~effect/Context/Reference`,Vs=`~effect/Context`,Hs=e=>{let t=Object.create(Us);return t.mapUnsafe=e,t.mutable=!1,t},Us={...eo,[Vs]:{_Services:e=>e},toJSON(){return{_id:`Context`,services:Array.from(this.mapUnsafe).map(([e,t])=>({key:e,value:t}))}},[la](e){if(!Ws(e)||this.mapUnsafe.size!==e.mapUnsafe.size)return!1;for(let t of this.mapUnsafe.keys())if(!e.mapUnsafe.has(t)||!ua(this.mapUnsafe.get(t),e.mapUnsafe.get(t)))return!1;return!0},[Gi](){return Zi(this.mapUnsafe.size)}},Ws=e=>Wi(e,Vs),Gs=()=>Ks,Ks=Hs(new Map),qs=(e,t)=>Hs(new Map([[e.key,t]])),Js=A(3,(e,t,n)=>tc(e,e=>{e.set(t.key,n)})),Ys=A(2,(e,t)=>{if(!e.mapUnsafe.has(t.key)){if(Bs in t)return Qs(t);throw $s(t)}return e.mapUnsafe.get(t.key)}),Xs=(e,t)=>e.mapUnsafe.has(t.key)?e.mapUnsafe.get(t.key):Qs(t),Zs=`~effect/Context/defaultValue`,Qs=e=>Zs in e?e[Zs]:e[Zs]=e.defaultValue(),$s=e=>{let t=Error(`Service not found${e.key?`: ${String(e.key)}`:``}`);if(e.stack){let n=e.stack.split(` +`);if(n.length>2){let e=n[2].match(/at (.*)/);e&&(t.message+=` (defined at ${e[1]})`)}}if(t.stack){let e=t.stack.split(` +`);e.splice(1,3),t.stack=e.join(` +`)}return t},ec=A(2,(e,t)=>e.mapUnsafe.size===0?t:t.mapUnsafe.size===0?e:tc(e,e=>{t.mapUnsafe.forEach((t,n)=>e.set(n,t))})),tc=(e,t)=>{if(e.mutable)return t(e.mapUnsafe),e;let n=new Map(e.mapUnsafe);return t(n),Hs(n)},nc=Rs,rc=`~effect/time/Duration`,ic=BigInt(0),ac=BigInt(1),oc=BigInt(1e3),sc=e=>BigInt(e<0?Math.ceil(e-.5):Math.floor(e+.5)),cc=e=>sc(e*1e6),lc=(e,t)=>e.includes(`.`)?sc(Number(e)*Number(t)):BigInt(e)*t,uc=/^(-?\d+(?:\.\d+)?)\s+(nanos?|micros?|millis?|seconds?|minutes?|hours?|days?|weeks?)$/,dc=e=>{switch(typeof e){case`number`:return Cc(e);case`bigint`:return Sc(e);case`string`:{if(e===`Infinity`)return bc;if(e===`-Infinity`)return xc;let t=uc.exec(e);if(!t)break;let[n,r,i]=t;if(i===`nano`||i===`nanos`)return Sc(lc(r,ac));if(i===`micro`||i===`micros`)return Sc(lc(r,oc));let a=Number(r);switch(i){case`milli`:case`millis`:return Cc(a);case`second`:case`seconds`:return wc(a);case`minute`:case`minutes`:return Tc(a);case`hour`:case`hours`:return Ec(a);case`day`:case`days`:return Dc(a);case`week`:case`weeks`:return Oc(a)}break}case`object`:{if(e===null)break;if(rc in e)return e;if(Array.isArray(e))return e.length!==2||!e.every(Ri)?fc(e):Number.isNaN(e[0])||Number.isNaN(e[1])?yc:e[0]===-1/0||e[1]===-1/0?xc:e[0]===1/0||e[1]===1/0?bc:_c(sc(e[0]*1e9+e[1]));let t=e,n=0;return t.weeks&&(n+=t.weeks*6048e5),t.days&&(n+=t.days*864e5),t.hours&&(n+=t.hours*36e5),t.minutes&&(n+=t.minutes*6e4),t.seconds&&(n+=t.seconds*1e3),t.milliseconds&&(n+=t.milliseconds),!t.microseconds&&!t.nanoseconds?_c(n):_c(sc(n*1e6+(t.microseconds??0)*1e3+(t.nanoseconds??0)))}}return fc(e)},fc=e=>{throw Error(`Invalid Input: ${e}`)},pc={_tag:`Millis`,millis:0},mc={_tag:`Infinity`},hc={_tag:`NegativeInfinity`},gc={[rc]:rc,[Gi](){return ea(this.value)},[la](e){return vc(e)&&Pc(this,e)},toString(){switch(this.value._tag){case`Infinity`:return`Infinity`;case`NegativeInfinity`:return`-Infinity`;case`Nanos`:return`${this.value.nanos} nanos`;case`Millis`:return`${this.value.millis} millis`}},toJSON(){switch(this.value._tag){case`Millis`:return{_id:`Duration`,_tag:`Millis`,millis:this.value.millis};case`Nanos`:return{_id:`Duration`,_tag:`Nanos`,nanos:String(this.value.nanos)};case`Infinity`:return{_id:`Duration`,_tag:`Infinity`};case`NegativeInfinity`:return{_id:`Duration`,_tag:`NegativeInfinity`}}},[Ha](){return this.toJSON()},pipe(){return Oi(this,arguments)}},_c=e=>{let t=Object.create(gc);return typeof e==`number`?isNaN(e)||e===0||Object.is(e,-0)?t.value=pc:Number.isFinite(e)?Number.isInteger(e)?t.value={_tag:`Millis`,millis:e}:t.value={_tag:`Nanos`,nanos:cc(e)}:t.value=e>0?mc:hc:e===ic?t.value=pc:t.value={_tag:`Nanos`,nanos:e},t},vc=e=>Wi(e,rc),yc=_c(0),bc=_c(1/0),xc=_c(-1/0),Sc=e=>_c(e),Cc=e=>_c(e),wc=e=>_c(e*1e3),Tc=e=>_c(e*6e4),Ec=e=>_c(e*36e5),Dc=e=>_c(e*864e5),Oc=e=>_c(e*6048e5),kc=e=>jc(dc(e),{onMillis:ji,onNanos:e=>Number(e)/1e6,onInfinity:()=>1/0,onNegativeInfinity:()=>-1/0}),Ac=e=>{let t=dc(e);switch(t.value._tag){case`Infinity`:case`NegativeInfinity`:throw Error(`Cannot convert infinite duration to nanos`);case`Nanos`:return t.value.nanos;case`Millis`:return cc(t.value.millis)}},jc=A(2,(e,t)=>{switch(e.value._tag){case`Millis`:return t.onMillis(e.value.millis);case`Nanos`:return t.onNanos(e.value.nanos);case`Infinity`:return t.onInfinity();case`NegativeInfinity`:return(t.onNegativeInfinity??t.onInfinity)()}}),Mc=A(3,(e,t,n)=>e.value._tag===`Infinity`||e.value._tag===`NegativeInfinity`||t.value._tag===`Infinity`||t.value._tag===`NegativeInfinity`?n.onInfinity(e,t):e.value._tag===`Millis`?t.value._tag===`Millis`?n.onMillis(e.value.millis,t.value.millis):n.onNanos(Ac(e),t.value.nanos):n.onNanos(e.value.nanos,Ac(t))),Nc=(e,t)=>Mc(e,t,{onMillis:(e,t)=>e===t,onNanos:(e,t)=>e===t,onInfinity:(e,t)=>e.value._tag===t.value._tag}),Pc=A(2,(e,t)=>Nc(e,t)),Fc=nc(`effect/Scheduler`,{defaultValue:()=>new Rc}),Ic=`setImmediate`in globalThis?e=>{let t=globalThis.setImmediate(e);return()=>globalThis.clearImmediate(t)}:e=>{let t=setTimeout(e,0);return()=>clearTimeout(t)},Lc=class{buckets=[];scheduleTask(e,t){let n=this.buckets,r=n.length,i,a=0;for(;at);a++)i=n[a];i&&i[0]===t?i[1].push(e):a===r?n.push([t,[e]]):n.splice(a,0,[t,[e]])}drain(){let e=this.buckets;return this.buckets=[],e}},Rc=class{executionMode;setImmediate;constructor(e=`async`,t=Ic){this.executionMode=e,this.setImmediate=t}shouldYield(e){return e.currentOpCount>=e.maxOpsBeforeYield}makeDispatcher(){return new zc(this.setImmediate)}},zc=class{tasks=new Lc;running=void 0;setImmediate;constructor(e=Ic){this.setImmediate=e}scheduleTask(e,t){this.tasks.scheduleTask(e,t),this.running===void 0&&(this.running=this.setImmediate(this.afterScheduled))}afterScheduled=()=>{this.running=void 0,this.runTasks()};runTasks(){let e=this.tasks.drain();for(let t=0;t0;)this.running!==void 0&&(this.running(),this.running=void 0),this.runTasks()}},Bc=nc(`effect/Scheduler/MaxOpsBeforeYield`,{defaultValue:()=>2048}),Vc=nc(`effect/Scheduler/PreventSchedulerYield`,{defaultValue:()=>!1}),Hc=`effect/Tracer/ParentSpan`;Rs()(Hc);var Uc=`effect/Tracer`,R=`effect/observability/Metric/FiberRuntimeMetricsKey`,Wc=nc(`effect/References/CurrentStackFrame`,{defaultValue:Mi}),Gc=nc(`effect/References/CurrentLogLevel`,{defaultValue:()=>`Info`}),Kc=nc(`effect/References/MinimumLogLevel`,{defaultValue:()=>`Info`}),qc=class extends lo{fiberId;constructor(e,t=uo){super(`Interrupt`,t,`Interrupted`),this.fiberId=e}toString(){return`Interrupt(${this.fiberId})`}toJSON(){return{_tag:`Interrupt`,fiberId:this.fiberId}}[la](e){return I(e)&&this.fiberId===e.fiberId&&this.annotations===e.annotations}[Gi](){return Ji(Qi(`${this._tag}:${this.fiberId}`))(qi(this.annotations))}},Jc=e=>new so([new qc(e)]),Yc=e=>{for(let t=0;te.reasons.some(I),Zc=A(2,(e,t)=>{if(e.reasons.length===0)return t;if(t.reasons.length===0)return e;let n=new so(fs(e.reasons,t.reasons));return ua(e,n)?e:n}),Qc=A(2,(e,t)=>{let n=!1,r=e.reasons.map(e=>vo(e)?(n=!0,new fo(t(e.error))):e);return n?po(r):e}),$c=e=>{let t={Fail:[],Die:[],Interrupt:[]};for(let n=0;n{let t=$c(e);return t.Fail.length>0?t.Fail[0].error:t.Die.length>0?t.Die[0].defect:t.Interrupt.length>0?new globalThis.Error(`All fibers interrupted without error`):new globalThis.Error(`Empty cause`)},tl=`~effect/Fiber/dev`,nl={_A:ji,_E:ji},rl={id:0},il=()=>globalThis[Pa],al=class{constructor(e,t=!0){this[tl]=nl,this.setContext(e),this.id=++rl.id,this.currentOpCount=0,this.interruptible=t,this._stack=[],this._observers=[],this._exit=void 0,this._children=void 0,this._interruptedCause=void 0,this._yielded=void 0,this._running=!1,this._deferredInterrupt=!1,this.runtimeMetrics?.recordFiberStart(this.context)}[tl];id;interruptible;currentOpCount;_stack;_observers;_exit;_children;_interruptedCause;_yielded;_running;_deferredInterrupt;context;currentScheduler;currentTracerContext;currentSpan;currentLogLevel;minimumLogLevel;currentStackFrame;runtimeMetrics;maxOpsBeforeYield;currentPreventYield;_dispatcher=void 0;get currentDispatcher(){return this._dispatcher??=this.currentScheduler.makeDispatcher()}getRef(e){return Xs(this.context,e)}addObserver(e){return this._exit?(e(this._exit),Ni):(this._observers.push(e),()=>{let t=this._observers.indexOf(e);t>=0&&this._observers.splice(t,1)})}interruptUnsafe(e,t){if(this._exit)return;let n=Jc(e);this.currentStackFrame&&(n=_o(n,qs(wo,this.currentStackFrame))),t&&(n=_o(n,t)),this._interruptedCause=this._interruptedCause?Zc(this._interruptedCause,n):n,this.interruptible&&(this._running?this._deferredInterrupt=!0:this.evaluate(ml(this._interruptedCause)))}pollUnsafe(){return this._exit}evaluate(e){if(this._exit)return;if(this._yielded!==void 0){let e=this._yielded;this._yielded=void 0,e()}let t=this.runLoop(e);if(t===$a)return;let n=sl.interruptChildren&&sl.interruptChildren(this);if(n!==void 0)return this.evaluate(Bl(n,()=>t));this._exit=t,this.runtimeMetrics?.recordFiberEnd(this.context,this._exit);for(let e=0;ee)}if(i=this.currentTracerContext?this.currentTracerContext(i,this):i[P](this),i===$a){let e=this._yielded;if(qa in e)return this._deferredInterrupt=!1,this._yielded=void 0,e;if(this._deferredInterrupt){this._yielded=void 0,e();continue}return $a}}}catch(e){return Wi(i,P)?this.runLoop(Do(e)):Do(`Fiber.runLoop: Not a valid effect: ${String(i)}`)}finally{this._running=n,globalThis[Pa]=t}}getCont(e){if(this._deferredInterrupt)return this._deferredInterrupt=!1,ol;for(;;){let t=this._stack.pop();if(!t)return;let n=t[Qa]&&t[Qa](this);if(n)return n[e]=n,n;if(t[e])return t}}yieldWith(e){return this._yielded=e,$a}children(){return this._children??=new Set}pipe(){return Oi(this,arguments)}setContext(e){this.context=e;let t=this.getRef(Fc);t!==this.currentScheduler&&(this.currentScheduler=t,this._dispatcher=void 0),this.currentSpan=e.mapUnsafe.get(Hc),this.currentLogLevel=this.getRef(Gc),this.minimumLogLevel=this.getRef(Kc),this.currentStackFrame=e.mapUnsafe.get(Wc.key),this.maxOpsBeforeYield=this.getRef(Bc),this.currentPreventYield=this.getRef(Vc),this.runtimeMetrics=e.mapUnsafe.get(R);let n=e.mapUnsafe.get(Uc);this.currentTracerContext=n?n.context:void 0}get currentSpanLocal(){return this.currentSpan?._tag===`Span`?this.currentSpan:void 0}},ol={[Xa](e,t){return ml(t._interruptedCause)},[Za](e,t){return ml(t._interruptedCause)}},sl={interruptChildren:void 0},cl=e=>{if(!e.currentStackFrame)return;let t=new Map;return t.set(To.key,e.currentStackFrame),Hs(t)},ll=e=>{let t=e;return t._exit?z(t._exit):kl(n=>t._exit?n(z(t._exit)):gl(e.addObserver(e=>n(z(e)))))},ul=e=>kl(t=>{let n=e[Symbol.iterator](),r=[],i;function a(){let e=n.next();for(;!e.done;){if(e.value._exit){r.push(e.value._exit),e=n.next();continue}i=e.value.addObserver(e=>{r.push(e),a()});return}t(z(r))}return a(),gl(()=>i?.())}),dl=e=>Oo(t=>fl(e,t.id)),fl=A(e=>Wi(e[0],tl),(e,t,n)=>Oo(r=>{let i=cl(r);return i=i&&n?ec(i,n):i??n,e.interruptUnsafe(t,i),zl(ll(e))})),pl=e=>Oo(t=>{let n=cl(t),r=ps();for(let i of e)i.interruptUnsafe(t.id,n),r.push(i);return zl(ul(r))}),z=Co,ml=Eo,hl=L,gl=xo({op:`Sync`,[P](e){let t=this[N](),n=e.getCont(Xa);return n?n[Xa](t,e):e.yieldWith(Co(t))}}),_l=xo({op:`Suspend`,[P](e){return this[N]()}}),vl=xo({op:`Yield`,[P](e){let t=!1;return e.currentDispatcher.scheduleTask(()=>{t||e.evaluate(H)},this[N]??0),e.yieldWith(()=>{t=!0})}})(0),yl=e=>z(Qo(e)),bl=z(Zo()),xl=e=>_l(()=>ml(Ga(e))),Sl=e=>Do(e),Cl=e=>_l(()=>hl(Ga(e))),wl=z(void 0),Tl=e=>{let t=typeof e==`function`?e:e.try,n=typeof e==`function`?e=>new Ku(e,`An error occurred in Effect.try`):e.catch;return _l(()=>{try{return z(Ga(t))}catch(e){return hl(Ga(()=>n(e)))}})},El=e=>{let t=typeof e==`function`?e:e.try,n=typeof e==`function`?e=>new Ku(e,`An error occurred in Effect.tryPromise`):e.catch;return Dl(function(e,r){let i=t=>{try{e(hl(Ga(()=>n(t))))}catch(t){e(Sl(t))}};try{Ga(()=>t(r)).then(t=>e(z(t)),i)}catch(e){i(e)}},t.length!==0)},Dl=xo({op:`Async`,single:!1,[P](e){let t=Ga(()=>this[N][0].bind(e.currentScheduler)),n=!1,r=!1,i=this[N][1]?new AbortController:void 0,a=t(t=>{n||(n=!0,r?e.evaluate(t):r=t)},i?.signal);return r===!1?(r=!0,e._yielded=()=>{n=!0},i===void 0&&a===void 0||e._stack.push(Ol(()=>(n=!0,i?.abort(),a??H))),$a):r}}),Ol=xo({op:`AsyncFinalizer`,[Qa](e){e.interruptible&&(e.interruptible=!1,e._stack.push(xu))},[Za](e,t){return Xc(e)?Bl(this[N](),()=>ml(e)):ml(e)}}),kl=e=>Dl(e,e.length>=2),Al=kl(Ni),jl=(...e)=>_l(()=>Il(e.length===1?e[0]():e[1].call(e[0].self))),Ml=(e,...t)=>{let n=t.length===0?function(){return _l(()=>Il(e.apply(this,arguments)))}:function(){let n=_l(()=>Il(e.apply(this,arguments)));for(let e=0;eObject.defineProperty(t,"length",{value:e,configurable:!0}),Pl=(e,...t)=>Nl(e.length,t.length===0?function(){return Fl(()=>e.apply(this,arguments))}:function(){let n=Fl(()=>e.apply(this,arguments));for(let e of t)n=e(n);return n}),Fl=e=>{try{let t=e(),n;for(;;){let r=t.next(n);if(r.done)return z(r.value);let i=r.value;if(i&&i._tag===`Success`){n=i.value;continue}else if(i&&i._tag===`Failure`)return r.value;else{let n=!0;return _l(()=>n?(n=!1,Bl(r.value,e=>Il(t,e))):_l(()=>Il(e())))}}}catch(e){return Sl(e)}},Il=xo({op:`Iterator`,single:!1,[Xa](e,t){let n=this[N][0];for(;;){let r=n.next(e);if(r.done)return z(r.value);if(!Hl(r.value))return t._stack.push(this),r.value;if(r.value._tag===`Failure`)return r.value;e=r.value.value}},[P](e){return this[Xa](this[N][1],e)}}),Ll=A(2,(e,t)=>{let n=z(t);return Bl(e,e=>n)}),Rl=A(2,(e,t)=>Bl(e,e=>Ll(no(t)?t:Ga(()=>t(e)),e))),zl=e=>Bl(e,e=>H),Bl=A(2,(e,t)=>{let n=Object.create(Vl);return n[N]=e,n[Xa]=t.length===1?t:e=>t(e),n}),Vl=bo({op:`OnSuccess`,[P](e){return e._stack.push(this),this[N]}}),Hl=e=>qa in e,Ul=A(2,(e,t)=>Hl(e)?e._tag===`Success`?t(e.value):e:Bl(e,t)),Wl=A(2,(e,t)=>Bl(e,e=>z(Ga(()=>t(e))))),B=A(2,(e,t)=>Hl(e)?U(e,t):Wl(e,t)),V=e=>e._tag===`Success`,H=Co(void 0),U=A(2,(e,t)=>e._tag===`Success`?Co(t(e.value)):e),W=e=>{let t=[];for(let n of e)n._tag===`Failure`&&t.push(...n.cause.reasons);return t.length===0?H:Eo(po(t))},Gl=A(2,(e,t)=>Oo(n=>{let r=n.context,i=t(r);return r===i?e:(n.setContext(i),vu(e,()=>{n.setContext(r)}))})),Kl=e=>Oo(t=>e(t.context)),ql=A(2,(e,t)=>Hl(e)?e:Gl(e,ec(t))),Jl=A(2,(e,t)=>{let n=Object.create(Yl);return n[N]=e,n[Za]=t.length===1?t:e=>t(e),n}),Yl=bo({op:`OnFailure`,[P](e){return e._stack.push(this),this[N]}}),Xl=A(3,(e,t,n)=>Jl(e,e=>{let r=t(e);return is(r)?ml(r.failure):Ga(()=>n(r.success,e))})),Zl=A(2,(e,t)=>Xl(e,Yc,e=>t(e))),Ql=A(2,(e,t)=>Zl(e,e=>Cl(()=>t(e)))),$l=A(2,(e,t)=>Zl(e,e=>gl(t))),eu=A(2,(e,t)=>{let n=Object.create(tu);return n[N]=e,n[Xa]=t.onSuccess.length===1?t.onSuccess:e=>t.onSuccess(e),n[Za]=t.onFailure.length===1?t.onFailure:e=>t.onFailure(e),n}),tu=bo({op:`OnSuccessAndFailure`,[P](e){return e._stack.push(this),this[N]}}),nu=A(2,(e,t)=>eu(e,{onFailure:e=>{let n=e.reasons.find(vo);return n?Ga(()=>t.onFailure(n.error)):ml(e)},onSuccess:t.onSuccess})),ru=A(2,(e,t)=>nu(e,{onFailure:e=>gl(()=>t.onFailure(e)),onSuccess:e=>gl(()=>t.onSuccess(e))})),iu=e=>Hl(e)?Co(e):au(e),au=xo({op:`Exit`,[P](e){return e._stack.push(this),this[N]},[Xa](e,t,n){return z(n??Co(e))},[Za](e,t,n){return z(n??Eo(e))}}),ou=`~effect/Scope`,su=`~effect/Scope/Closeable`,cu=Rs(`effect/Scope`),lu=(e,t)=>{if(e.state._tag===`Closed`)return;let n={_tag:`Closed`,exit:t};if(e.state._tag===`Empty`){e.state=n;return}let{finalizers:r}=e.state;if(e.state=n,r.size!==0)return r.size===1?r.values().next().value(t):uu(e,r,t)},uu=Ml(function*(e,t,n){let r=[],i=[],a=Array.from(t.values()),o=il();for(let t=a.length-1;t>=0;t--){let s=a[t];e.strategy===`sequential`?r.push(yield*iu(s(n))):i.push(Ou(o,s(n),!0,!0,`inherit`))}return i.length>0&&(r=yield*ul(i)),yield*W(r)}),du=(e,t)=>_l(()=>e.state._tag===`Closed`?t(e.state.exit):(fu(e,{},t),wl)),fu=(e,t,n)=>{e.state._tag===`Empty`?e.state={_tag:`Open`,finalizers:new Map([[t,n]])}:e.state._tag===`Open`&&e.state.finalizers.set(t,n)},pu=(e=`sequential`)=>({[su]:su,[ou]:ou,strategy:e,state:mu}),mu={_tag:`Empty`},hu=cu,gu=e=>Oo(t=>{let n=t.context,r=pu();return t.setContext(Js(t.context,cu,r)),vu(e,e=>(t.setContext(n),lu(r,e)))}),_u=(e,t,n)=>Kl(r=>Tu(i=>Bl(hu,a=>Rl(n?.interruptible?i(e):e,e=>du(a,n=>ql(t(e,n),r)))))),vu=xo({op:`OnExit`,single:!1,[P](e){return e._stack.push(this),this[N][0]},[Qa](e){e.interruptible&&this[N][2]!==!0&&(e._stack.push(xu),e.interruptible=!1)},[Xa](e,t,n){n??=Co(e);let r=this[N][1](n);return r?Bl(r,e=>n):n},[Za](e,t,n){n??=Eo(e);let r=this[N][1](n);return r?Bl(r,e=>n):n}}),yu=e=>Oo(t=>t.interruptible?(t.interruptible=!1,t._stack.push(xu),e):e),bu=xo({op:`SetInterruptible`,[Qa](e){if(e.interruptible=this[N],e._interruptedCause&&e.interruptible)return()=>ml(e._interruptedCause)}}),xu=bu(!0),Su=bu(!1),Cu=e=>{if(e.interruptible=!0,e._stack.push(Su),e._interruptedCause)return ml(e._interruptedCause)},wu=e=>Oo(t=>t.interruptible?e:Cu(t)??e),Tu=e=>Oo(t=>t.interruptible?(t.interruptible=!1,t._stack.push(xu),e(wu)):e(ji)),Eu=e=>{let t=e.onItem,n=e.step;return(e,r,i)=>{let a=i?.start??0,o=i?.end??r.length,s=i?.concurrency??1,c=i?.orderedStep===!0&&s>1,l=!1,u,d,f,p=!1,m,h,g=a,_=c?Array(o):void 0,v=e=>{let t=Do(e);return m=t,l=!0,p=!0,d&&d.size>0?Bl(yu(pl(Array.from(d))),()=>t):t},y=(t,i,a)=>{if(!c)return n(e,t,i,a);if(m)return m;for(_[a]=i;g{let n=!1;for(;!m&&a(m=y(i,e,a),a++,m??b()??wl));else if(u){h=void 0;let e=Ou(u,o,!0,!0,`inherit`);if(e._exit){if(m=y(i,e._exit,a),m)break;continue}d.add(e);let t=a;if(e.addObserver(r=>{d.delete(e);try{if(m){if(!p&&r._tag===`Failure`)for(let e of r.cause.reasons)if(e._tag===`Interrupt`)continue;else m._tag===`Failure`?m.cause.reasons.push(e):m=Eo(po([e]))}else{let e=y(i,r,t);e&&(m=e._tag===`Failure`?Eo(po(e.cause.reasons.slice())):e,b())}if(n){let e=b();e&&f(e)}else l&&d.size===0&&f(m??wl)}catch(e){f(v(e))}}),d.size{u=il(),d=new Set,h=o,f=e;let t;try{t=b()}catch(t){return e(v(t))}return t?e(t):_l(()=>(m=H,p=!0,d?pl(d):wl))})}if(l=!0,m){if(d&&d.size>0){let e=cl(u);d.forEach(t=>t.interruptUnsafe(u.id,e));return}if(f||m._tag===`Failure`)return m}else if(f)if(d)d.size===0&&f(wl);else return H};return b()}},Du=()=>Eu,Ou=(e,t,n=!1,r=!1,i=!1)=>{let a=i===`inherit`?e.interruptible:!i,o=new al(e.context,a);return n?o.evaluate(t):e.currentDispatcher.scheduleTask(()=>o.evaluate(t),0),!r&&!o._exit&&(e.children().add(o),o.addObserver(()=>e._children.delete(o))),o},ku=e=>(t,n)=>{let r=new al(n?.scheduler?Js(e,Fc,n.scheduler):e,n?.uninterruptible!==!0);if(r.evaluate(t),r._exit)return r;if(n?.signal)if(n.signal.aborted)r.interruptUnsafe();else{let e=()=>r.interruptUnsafe();n.signal.addEventListener(`abort`,e,{once:!0}),r.addObserver(()=>n.signal.removeEventListener(`abort`,e))}return n?.onFiberStart&&n.onFiberStart(r),r},Au=ku(Gs()),ju=e=>{let t=ku(e);return(e,n)=>{let r=t(e,n);return new Promise(e=>{r.addObserver(t=>e(t))})}},Mu=(e=>{let t=ju(e);return(e,n)=>t(e,n).then(e=>{if(e._tag===`Failure`)throw el(e.cause);return e.value})})(Gs()),Nu=e=>{let t=ku(e);return e=>{if(Hl(e))return e;let n=new Rc(`sync`),r=t(e,{scheduler:n});return r._dispatcher?.flush(),r._exit??Do(new Wu(r))}},Pu=Nu(Gs()),Fu=(e=>{let t=Nu(e);return e=>{let n=t(e);if(n._tag===`Failure`)throw el(n.cause);return n.value}})(Gs()),Iu=nc(`effect/Clock`,{defaultValue:()=>new Ru}),Lu=2**31-1,Ru=class{currentTimeMillisUnsafe(){return Date.now()}currentTimeMillis=gl(()=>this.currentTimeMillisUnsafe());currentTimeNanosUnsafe(){return Bu()}currentTimeNanos=gl(()=>this.currentTimeNanosUnsafe());sleep(e){return this.sleepMillis(kc(e))}sleepMillis(e){return e<=0?vl:Number.isFinite(e)?kl(t=>{let n=e>Lu?this.sleepMillis(e-Lu):wl,r=setTimeout(()=>t(n),Math.min(e,Lu));return gl(()=>clearTimeout(r))}):Al}},zu=function(){let e=BigInt(1e6);if(typeof performance>`u`||performance.now===void 0)return()=>BigInt(Date.now())*e;let t;return()=>(t??=BigInt(Date.now())*e-BigInt(Math.round(performance.now()*1e6)),t+BigInt(Math.round(performance.now()*1e6)))}(),Bu=function(){let e=typeof process==`object`&&`hrtime`in process&&typeof process.hrtime.bigint==`function`?process.hrtime:void 0;if(!e)return zu;let t=BigInt(Date.now())*BigInt(1e6)-e.bigint();return()=>t+e.bigint()}(),Vu=e=>Oo(t=>e(t.getRef(Iu))),Hu=e=>Vu(t=>t.sleep(dc(e)));jo(`TimeoutError`),jo(`IllegalArgumentError`),jo(`ExceededCapacityError`);var Uu=`~effect/Cause/AsyncFiberError`,Wu=class extends jo(`AsyncFiberError`){[Uu]=Uu;constructor(e){super({message:`An asynchronous Effect was executed with Effect.runSync`,fiber:e})}},Gu=`~effect/Cause/UnknownError`,Ku=class extends jo(`UnknownError`){[Gu]=Gu;constructor(e,t){super({message:t,cause:e})}},qu={bold:`1`,red:`31`,green:`32`,yellow:`33`,blue:`34`,cyan:`36`,white:`37`,gray:`90`,black:`30`,bgBrightRed:`101`};qu.gray,qu.blue,qu.green,qu.yellow,qu.red,qu.bgBrightRed,qu.black;var Ju=vo,Yu=Qc;Rs()(`effect/Cause/StackTrace`),Rs()(`effect/Cause/InterruptorStackTrace`);var Xu=Eo,G=L,Zu=H,Qu=V,$u=jo,ed=`~effect/time/DateTime`,td=`~effect/time/DateTime/TimeZone`,nd={[ed]:ed,pipe(){return Oi(this,arguments)},[Ha](){return this.toString()},toJSON(){return id(this).toJSON()}};({...nd}),{...nd};var rd={[td]:td,[Ha](){return this.toString()}};({...rd}),{...rd};var id=e=>new Date(e.epochMilliseconds),ad=El,od=z,sd=bl,cd=yl,ld=gl,ud=wl,dd=kl,fd=jl,pd=hl,md=xl,hd=Tl,gd=Bl,_d=iu,vd=Wl,yd=Zl,bd=Jl,xd=Ql,Sd=$l,Cd=Hu,wd=ru,Td=gu,K=_u,Ed=Au,Dd=Mu,Od=Fu,kd=Pu;Rs()(`effect/Effect/Transaction`);var Ad=B,jd=Ul,Md=Pl;function Nd(e){return e.checks?e.checks[e.checks.length-1].annotations:e.annotations}function Pd(e){return t=>Nd(t)?.[e]}var Fd=`~sentinels`,Id=Pd(`identifier`),Ld=Pi(e=>{let t=Id(e);return typeof t==`string`?t:e.getExpected(Ld)}),Rd=`~effect/SchemaIssue/Issue`;function q(e){return Wi(e,Rd)&&e[Rd]===Rd}var zd=class{[Rd]=Rd;toString(){return of(this)}},Bd=class extends zd{_tag=`Filter`;actual;filter;issue;constructor(e,t,n){super(),this.actual=e,this.filter=t,this.issue=n}},Vd=class extends zd{_tag=`Encoding`;ast;actual;issue;constructor(e,t,n){super(),this.ast=e,this.actual=t,this.issue=n}},Hd=class extends zd{_tag=`Pointer`;path;issue;constructor(e,t){super(),this.path=e,this.issue=t}},Ud=class extends zd{_tag=`MissingKey`;annotations;constructor(e){super(),this.annotations=e}},Wd=class extends zd{_tag=`UnexpectedKey`;ast;actual;constructor(e,t){super(),this.ast=e,this.actual=t}},Gd=class extends zd{_tag=`Composite`;ast;actual;issues;constructor(e,t,n){super(),this.ast=e,this.actual=t,this.issues=n}},Kd=class extends zd{_tag=`InvalidType`;ast;actual;constructor(e,t){super(),this.ast=e,this.actual=t}},qd=class extends zd{_tag=`InvalidValue`;actual;annotations;constructor(e,t){super(),this.actual=e,this.annotations=t}},Jd=class extends zd{_tag=`AnyOf`;ast;actual;issues;constructor(e,t,n){super(),this.ast=e,this.actual=t,this.issues=n}},Yd=class extends zd{_tag=`OneOf`;ast;actual;successes;constructor(e,t,n){super(),this.ast=e,this.actual=t,this.successes=n}};function Xd(e,t){if(q(t))return t;if(typeof t==`string`)return new qd(Qo(e),{message:t});let n=typeof t.issue==`string`?new qd(Qo(e),{message:t.issue}):t.issue;return new Hd(t.path,n)}function Zd(e,t){if(t!==void 0)return typeof t==`boolean`?t?void 0:new qd(Qo(e)):Xd(e,t)}function Qd(e,t,n){return Array.isArray(n)?us(n)?n.length===1?Xd(e,n[0]):new Gd(t,Qo(e),ms(n,t=>Xd(e,t))):void 0:Zd(e,n)}var $d=e=>{let t=cf(e);if(t!==void 0)return t;switch(e._tag){case`InvalidType`:return tf(Ld(e.ast),uf(e.actual));case`InvalidValue`:return`Invalid data ${uf(e.actual)}`;case`MissingKey`:return`Missing key`;case`UnexpectedKey`:return`Unexpected key with value ${Ia(e.actual)}`;case`Forbidden`:return`Forbidden operation`;case`OneOf`:return`Expected exactly one member to match the input ${Ia(e.actual)}`}},ef=e=>cf(e.issue)??cf(e);function tf(e,t){return`Expected ${e}, got ${t}`}function nf(e,t,n,r){switch(e._tag){case`Filter`:{let i=r(e);if(i!==void 0)return[{path:t,message:i}];switch(e.issue._tag){case`InvalidValue`:return[{path:t,message:tf(rf(e.filter),Ia(e.actual))}];default:return nf(e.issue,t,n,r)}}case`Encoding`:return nf(e.issue,t,n,r);case`Pointer`:return nf(e.issue,[...t,...e.path],n,r);case`Composite`:return e.issues.flatMap(e=>nf(e,t,n,r));case`AnyOf`:{let i=cf(e);return e.issues.length===0?i===void 0?[{path:t,message:tf(Ld(e.ast),Ia(e.actual))}]:[{path:t,message:i}]:e.issues.flatMap(e=>nf(e,t,n,r))}default:return[{path:t,message:n(e)}]}}function rf(e){let t=e.annotations?.expected;if(typeof t==`string`)return t;switch(e._tag){case`Filter`:return``;case`FilterGroup`:return e.checks.map(e=>rf(e)).join(` & `)}}function af(){return e=>nf(e,[],$d,ef).map(sf).join(` +`)}var of=af();function sf(e){let t=e.message;if(e.path&&e.path.length>0){let n=za(e.path);t+=`\n at ${n}`}return t}function cf(e){switch(e._tag){case`InvalidType`:case`OneOf`:case`Composite`:case`AnyOf`:return lf(e.ast.annotations);case`InvalidValue`:case`Forbidden`:return lf(e.annotations);case`MissingKey`:return lf(e.annotations,`messageMissingKey`);case`UnexpectedKey`:return lf(e.ast.annotations,`messageUnexpectedKey`);case`Filter`:return lf(e.filter.annotations);case`Encoding`:return cf(e.issue)}}function lf(e,t=`message`){let n=e?.[t];if(typeof n==`string`)return n}function uf(e){return $o(e)?`no value provided`:Ia(e.value)}function df(e){let t;for(let n of e.reasons){if(!Ju(n)||!q(n.error))return;t??=n.error}return t}function ff(e,t){let n=df(e);if(n===void 0)throw Error(t,{cause:e});return n}Rs()(`effect/DateTime/CurrentTimeZone`),$u(`EncodingError`);var pf=class e extends Ai{run;constructor(e){super(),this.run=e}map(t){return new e((e,n)=>this.run(e,n).pipe(Ad(ts(t))))}compose(t){return hf(this)?t:hf(t)?this:new e((e,n)=>this.run(e,n).pipe(jd(e=>t.run(e,n))))}},mf=new pf(od);function hf(e){return e.run===mf.run}function gf(){return mf}function _f(e){return new pf((t,n)=>$o(t)?sd:e(t.value,n))}function vf(e){return yf(ts(e))}function yf(e){return new pf(t=>od(e(t)))}function bf(){return vf(globalThis.String)}function xf(){return vf(globalThis.Number)}function Sf(e){return _f(t=>hd({try:()=>Qo(JSON.parse(t,e?.reviver)),catch:e=>new qd(Qo(t),{message:globalThis.String(e)})}))}function Cf(e){return _f(t=>hd({try:()=>{let n=JSON.stringify(t,e?.replacer,e?.space);if(n===void 0)throw TypeError(`Value cannot be represented as JSON`);return Qo(n)},catch:e=>new qd(Qo(t),{message:globalThis.String(e)})}))}var wf=`~effect/SchemaTransformation/Transformation`,Tf=class e{[wf]=wf;_tag=`Transformation`;decode;encode;constructor(e,t){this.decode=e,this.encode=t}flip(){return new e(this.encode,this.decode)}compose(t){return new e(this.decode.compose(t.decode),t.encode.compose(this.encode))}};function Ef(e){return Wi(e,wf)&&e[wf]===wf}var Df=e=>Ef(e)?e:new Tf(e.decode,e.encode),Of=new Tf(gf(),gf());function kf(){return Of}var Af=new Tf(xf(),bf()),jf=new Tf(Sf(),Cf());function Mf(e){return t=>t._tag===e}var Nf=Mf(`Declaration`),Pf=Mf(`Never`),Ff=Mf(`Literal`),If=Mf(`UniqueSymbol`),Lf=Mf(`Arrays`),Rf=Mf(`Objects`),zf=class{to;transformation;constructor(e,t){this.to=e,this.transformation=t}},Bf={},Vf=class{isOptional;isMutable;defaultValue;annotations;constructor(e,t,n=void 0,r=void 0){this.isOptional=e,this.isMutable=t,this.defaultValue=n,this.annotations=r}},Hf=`~effect/Schema`,Uf=class{[Hf]=Hf;annotations;checks;encoding;context;constructor(e=void 0,t=void 0,n=void 0,r=void 0){this.annotations=e,this.checks=t,this.encoding=n,this.context=r}toString(){return`<${this._tag}>`}},Wf=new class extends Uf{_tag=`Null`;getParser(){return cm(this,null)}getExpected(){return`null`}},Gf=new class extends Uf{_tag=`Unknown`;getParser(){return lm(this,Hi)}getExpected(){return`unknown`}},Kf=class extends Uf{_tag=`Literal`;literal;constructor(e,t,n,r,i){if(super(t,n,r,i),typeof e==`number`&&!globalThis.Number.isFinite(e))throw Error(`A numeric literal must be finite, got ${Ia(e)}`);this.literal=e}getParser(){return cm(this,this.literal)}matchPart(e,t){return e===globalThis.String(this.literal)?this.literal:void 0}toCodecJson(){return typeof this.literal==`bigint`?qf(this):this}toCodecStringTree(){return typeof this.literal==`string`?this:qf(this)}getExpected(){return typeof this.literal==`string`?JSON.stringify(this.literal):globalThis.String(this.literal)}};function qf(e){let t=globalThis.String(e.literal);return Lp(e,[new zf(new Kf(t),new Tf(vf(()=>e.literal),vf(()=>t)))])}var Jf=new class extends Uf{_tag=`String`;getParser(){return lm(this,Li)}matchPart(e,t){return um(this,e,t)}getExpected(){return`string`}},Yf=class extends Uf{_tag=`Number`;getParser(){return lm(this,Ri)}matchKey(e,t){return this._match(pm,e,t)}matchPart(e,t){return this._match(fm,e,t)}_match(e,t,n){return e.test(t)?um(this,globalThis.Number(t),n):void 0}toCodecJson(){return this.checks&&(Xf(this.checks,`effect/schema/isFinite`)||Xf(this.checks,`effect/schema/isInt`))?this:Lp(this,[Zf(this.checks)])}toCodecStringTree(){return this.toCodecJson()===this?Lp(this,[gm]):Lp(this,[_m])}getExpected(){return`number`}};function Xf(e,t){return e.some(e=>e.annotations?.representation?.id===t||e._tag===`FilterGroup`&&Xf(e.checks,t))}function Zf(e){return new zf(new Tp([e===void 0?Pp:Vp(Pp,e),Dp],`anyOf`),new Tf(xf(),vf(e=>globalThis.Number.isFinite(e)?e:globalThis.String(e))))}var Qf=new Yf,$f=new class extends Uf{_tag=`Boolean`;getParser(){return lm(this,zi)}getExpected(){return`boolean`}},ep=class e extends Uf{_tag=`Arrays`;isMutable;elements;rest;encodingChecks;constructor(e,t,n,r,i,a,o,s){super(r,i,a,o),this.isMutable=e,this.elements=t,this.rest=n,this.encodingChecks=s;let c=t.findIndex(em);if(c!==-1&&(t.slice(c+1).some(e=>!em(e))||n.length>1))throw Error(`A required element cannot follow an optional element. ts(1257)`);if(n.length>1&&n.slice(1).some(em))throw Error(`An optional element cannot follow a rest element. ts(1266)`)}getParser(e){let t=this,n=t.elements.map(t=>({ast:t,parser:e(t)})),r=t.rest.map(t=>({ast:t,parser:e(t)})),i=n.length,[a,...o]=r,s=o.length;function c(e,t){return t=e?o[t-e]:a}return Md(function*(e,n){if(e._tag===`None`)return e;let r=e.value;if(!Array.isArray(r))return yield*pd(new Kd(t,e));let a=r.length,o={ast:t,getParser:c,oinput:e,len:a,tailThreshold:np(a,i,s),output:new globalThis.Array(a),issues:void 0,options:n},l=tp(o,r,{concurrency:rp(n?.concurrency)?.concurrency,end:t.rest.length===0?i:Math.max(a,i+s)});if(l&&(yield*l),t.rest.length===0&&a>i)for(let s=i;s<=a-1;s++){let i=new Hd([s],new Wd(t,r[s]));if(n.errors===`all`)o.issues?o.issues.push(i):o.issues=[i];else return yield*pd(new Gd(t,e,[i]))}return o.issues?yield*pd(new Gd(t,e,o.issues)):Qo(o.output)})}_rebuild(t,n,r){let i=qp(this.elements,t),a=qp(this.rest,t);return i===this.elements&&a===this.rest&&n===this.checks&&r===this.encodingChecks?this:new e(this.isMutable,i,a,this.annotations,n,void 0,this.context,r)}recur(e){return this._rebuild(e,this.checks,this.encodingChecks)}flip(e){return this._rebuild(e,this.encodingChecks,this.checks)}getExpected(){return`array`}},tp=Du()({onItem(e,t,n){let r=n(e=e===`unbounded`?1/0:e??1,e>1?{concurrency:e}:void 0),ip=(e,t,n,r)=>{if(r.cause.reasons.length===0)return r;let i=df(r.cause);if(i===void 0)return Xu(Yu(r.cause,r=>new Gd(t,e.oinput,[new Hd([n],r)])));let a=new Hd([n],i);if(e.options.errors===`all`)e.issues?e.issues.push(a):e.issues=[a];else return G(new Gd(t,e.oinput,[a]))},ap=`[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?`;function op(e,t,n=Bf){let r,i;function a(t){switch(t._tag){case`String`:case`TemplateLiteral`:return(r??=Object.keys(e)).filter(e=>t.matchPart(e,n)!==void 0);case`Number`:return(r??=Object.keys(e)).filter(e=>t.matchKey(e,n)!==void 0);case`Symbol`:return(i??=Object.getOwnPropertySymbols(e)).filter(e=>t.matchKey(e,n)!==void 0);case`Union`:return[...new Set(t.types.flatMap(a))];default:return[]}}return a(dm(im(t)))}var sp=class{name;type;constructor(e,t){this.name=e,this.type=t}},cp=class e{decode;encode;constructor(e,t){this.decode=e,this.encode=t}flip(){return new e(this.encode,this.decode)}};function lp(e){switch(e._tag){case`String`:case`Number`:case`Symbol`:case`TemplateLiteral`:return!0;case`Union`:return e.types.every(lp);default:return!1}}function up(e){return lp(e)&&lp(im(e))}var dp=class{parameter;type;merge;constructor(e,t,n){if(!up(e))throw Error(`Invalid index signature parameter ${e._tag}`);if(this.parameter=e,this.type=t,this.merge=n,em(t)&&!sm(t))throw Error("Cannot use `Schema.optionalKey` with index signatures, use `Schema.optional` instead.")}},fp=class e extends Uf{_tag=`Objects`;propertySignatures;indexSignatures;encodingChecks;constructor(e,t,n,r,i,a,o){super(n,r,i,a),this.propertySignatures=e,this.indexSignatures=t,this.encodingChecks=o;let s=e.map(e=>e.name).filter((e,t,n)=>n.indexOf(e)!==t);if(s.length>0)throw Error(`Duplicate identifiers: ${JSON.stringify(s)}. ts(2300)`)}getParser(e){let t=this,n=[],r=new Set,i=[];for(let a of t.propertySignatures)n.push(a.name),r.add(a.name),i.push({ps:a,parser:e(a.type),name:a.name,type:a.type});let a=t.indexSignatures.length;if(t.propertySignatures.length===0&&t.indexSignatures.length===0)return lm(t,M);let o=a>0?Du()({onItem:Md(function*(n,[i,a]){let o=e(dm(a.parameter))(Qo(i),n.options),s=Hl(o)?o:yield*_d(o);if(s._tag===`Failure`){let e=ip(n,t,i,s);e&&(yield*e);return}let c=Qo(n.input[i]),l=e(a.type)(c,n.options),u=Hl(l)?l:yield*_d(l);if(u._tag===`Failure`){let e=ip(n,t,i,u);e&&(yield*e);return}else if(s.value._tag===`Some`&&u.value._tag===`Some`){let e=s.value.value;if(r.has(i)||r.has(e))return;let t=u.value.value;if(a.merge&&a.merge.decode&&Object.hasOwn(n.out,e)){let[r,i]=a.merge.decode.combine([e,n.out[e]],[e,t]);Oa(n.out,r,i)}else Oa(n.out,e,t)}}),step:(e,t,n)=>n._tag===`Failure`?n:void 0}):void 0;return Md(function*(e,s){if(e._tag===`None`)return e;let c=e.value;if(!(typeof c==`object`&&c&&!Array.isArray(c)))return yield*pd(new Kd(t,e));let l={},u={ast:t,oinput:e,input:c,out:l,issues:void 0,options:s},d=s.errors===`all`,f=s.onExcessProperty===`error`,p=s.onExcessProperty===`preserve`,m;if(t.indexSignatures.length===0&&(f||p)){m=Reflect.ownKeys(c);for(let n=0;n{let n=t(e.type);return n===e.type?e:new sp(e.name,n)}),s=qp(this.indexSignatures,e=>{let i=n(e.parameter),a=t(e.type),o=r?e.merge?.flip():e.merge;return i===e.parameter&&a===e.type&&o===e.merge?e:new dp(i,a,o)});return o===this.propertySignatures&&s===this.indexSignatures&&i===this.checks&&a===this.encodingChecks?this:new e(o,s,this.annotations,i,void 0,this.context,a)}flip(e){return this._rebuild(e,e,!0,this.encodingChecks,this.checks)}recur(e,t=e){return this._rebuild(e,t,!1,this.checks,this.encodingChecks)}getExpected(){return this.propertySignatures.length===0&&this.indexSignatures.length===0?`object | array`:`object`}},pp=Du()({onItem(e,t){let n=Object.hasOwn(e.input,t.name)?Qo(e.input[t.name]):Zo();return t.parser(n,e.options)},step(e,t,n){if(n._tag===`Failure`)return ip(e,e.ast,t.name,n);if(n.value._tag===`Some`)Oa(e.out,t.name,n.value.value);else if(!em(t.type)){let n=new Hd([t.name],new Ud(t.type.context?.annotations));if(e.options.errors===`all`){e.issues?e.issues.push(n):e.issues=[n];return}else return G(new Gd(e.ast,e.oinput,[n]))}}});function mp(e,t){return e?t?[...e,...t]:e:t}function hp(e,t,n){return new fp(Reflect.ownKeys(e).map(t=>new sp(t,e[t].ast)),[],n,t)}function gp(e){return e.ast}function _p(e,t=void 0){return new ep(!1,e.map(e=>e.ast),[],void 0,t)}function vp(e,t,n){return new Tp(e.map(gp),t,void 0,n)}function yp(e){switch(e._tag){case`Null`:return[`null`];case`Undefined`:return[`undefined`];case`String`:case`TemplateLiteral`:return[`string`];case`Number`:return[`number`];case`Boolean`:return[`boolean`];case`Symbol`:case`UniqueSymbol`:return[`symbol`];case`BigInt`:return[`bigint`];case`Arrays`:return[`array`];case`ObjectKeyword`:return[`object`,`array`,`function`];case`Objects`:return e.propertySignatures.length||e.indexSignatures.length?[`object`]:[`string`,`number`,`boolean`,`symbol`,`bigint`,`object`,`array`,`function`];case`Enum`:return Array.from(new Set(e.enums.map(([,e])=>typeof e)));case`Literal`:return[typeof e.literal];case`Union`:return Array.from(new Set(e.types.flatMap(yp)));default:return[`null`,`undefined`,`string`,`number`,`boolean`,`symbol`,`bigint`,`object`,`array`,`function`]}}function bp(e){switch(e._tag){default:return[];case`Declaration`:{let t=e.annotations?.[Fd];return Array.isArray(t)?t:[]}case`Objects`:return e.propertySignatures.flatMap(e=>{let t=e.type;if(!em(t)){if(Ff(t))return[{key:e.name,literal:t.literal}];if(If(t))return[{key:e.name,literal:t.symbol}]}return[]});case`Arrays`:return e.elements.flatMap((e,t)=>Ff(e)&&!em(e)?[{key:t,literal:e.literal}]:[]);case`Suspend`:return bp(e.thunk())}}var xp=new WeakMap;function Sp(e){let t=xp.get(e);if(t)return t;t={};for(let n=0;n0){t.bySentinel??=new Map;for(let{key:e,literal:r}of o){let i=t.bySentinel.get(e);i||t.bySentinel.set(e,i=new Map);let a=i.get(r);a||i.set(r,a=[]),a.push(n)}}else{t.otherwise??={};for(let e of a)(t.otherwise[e]??=[]).push(n)}}return xp.set(e,t),t}function Cp(e){return t=>{let n=im(t);return n._tag===`Literal`?n.literal===e:n._tag!==`UniqueSymbol`||n.symbol===e}}function wp(e,t){let n=Sp(t),r=e===null?`null`:Array.isArray(e)?`array`:typeof e;if(n.bySentinel){let i=n.otherwise?.[r]??[];if(r===`object`||r===`array`){let r=new Set(i);for(let[t,i]of n.bySentinel)if(Object.hasOwn(e,t)){let n=i.get(e[t]);if(n)for(let e of n)r.add(e)}return Array.from(r).sort((e,t)=>e-t).map(e=>t[e]).filter(Cp(e))}return i.map(e=>t[e])}return(n.byType?.[r]??[]).map(e=>t[e]).filter(Cp(e))}var Tp=class e extends Uf{_tag=`Union`;types;mode;encodingChecks;constructor(e,t,n,r,i,a,o){super(n,r,i,a),this.types=e,this.mode=t,this.encodingChecks=o}getParser(e){let t=this;return(n,r)=>{if(n._tag===`None`)return od(n);let i=n.value,a=wp(i,t.types),o={ast:t,recur:e,oinput:n,input:i,out:void 0,successes:[],issues:void 0,options:r},s=rp(r?.concurrency),c=Ep(o,a,s?{...s,orderedStep:!0}:void 0);return c?gd(c,e=>o.out?od(o.out):pd(new Jd(t,i,o.issues??[]))):o.out?od(o.out):pd(new Jd(t,i,o.issues??[]))}}_rebuild(t,n,r){let i=qp(this.types,t);return i===this.types&&n===this.checks&&r===this.encodingChecks?this:new e(i,this.mode,this.annotations,n,void 0,this.context,r)}recur(e){return this._rebuild(e,this.checks,this.encodingChecks)}flip(e){return this._rebuild(e,this.encodingChecks,this.checks)}matchPart(e,t){for(let n of this.types){let r=n.matchPart(e,t);if(r!==void 0)return r}}getExpected(e){let t=this.annotations?.expected;if(typeof t==`string`)return t;if(this.types.length===0)return`never`;let n=this.types.map(t=>{let n=im(t);switch(n._tag){case`Arrays`:{let t=n.elements.filter(Ff);if(t.length>0)return`${Op(n.isMutable)}[ ${t.map(t=>e(t)+kp(t.context?.isOptional)).join(`, `)}, ... ]`;break}case`Objects`:{let t=n.propertySignatures.filter(e=>Ff(e.type));if(t.length>0)return`{ ${t.map(t=>`${Op(t.type.context?.isMutable)}${Ra(t.name)}${kp(t.type.context?.isOptional)}: ${e(t.type)}`).join(`, `)}, ... }`;break}}return e(n)});return Array.from(new Set(n)).join(` | `)}},Ep=Du()({onItem(e,t){return e.recur(t)(e.oinput,e.options)},step(e,t,n){if(n._tag===`Failure`){let t=df(n.cause);if(t===void 0)return n;e.issues?e.issues.push(t):e.issues=[t]}else{if(e.out&&e.ast.mode===`oneOf`)return e.successes.push(t),G(new Yd(e.ast,e.input,e.successes));if(e.out=n.value,e.successes.push(t),e.ast.mode===`anyOf`)return Zu}}}),Dp=new Tp([new Kf(`Infinity`),new Kf(`-Infinity`),new Kf(`NaN`)],`anyOf`);function Op(e){return e?``:`readonly `}function kp(e){return e?`?`:``}var Ap=class e extends Ai{_tag=`Filter`;run;annotations;aborted;constructor(e,t=void 0,n=!1){super(),this.run=e,this.annotations=t,this.aborted=n}annotate(t){return new e(this.run,{...this.annotations,...t},this.aborted)}abort(){return new e(this.run,this.annotations,!0)}and(e,t){return new jp([this,e],t)}},jp=class e extends Ai{_tag=`FilterGroup`;checks;annotations;constructor(e,t=void 0){super(),this.checks=e,this.annotations=t}annotate(t){return new e(this.checks,{...this.annotations,...t})}and(t,n){return new e([this,t],n)}};function Mp(e,t,n=!1){return new Ap((t,n,r)=>Qd(t,n,e(t,n,r)),t,n)}function Np(e){return Mp(e=>globalThis.Number.isFinite(e),{expected:`a finite number`,representation:{id:`effect/schema/isFinite`,payload:null},toJsonSchema:()=>({type:`number`}),toCode:()=>({runtime:`Schema.isFinite()`}),arbitrary:{constraint:{noInfinity:!0,noNaN:!0}},...e})}var Pp=Vp(Qf,[Np()]);function Fp(e,t){let n=e.source;return Mp(t=>e.test(t),{expected:`a string matching the RegExp ${n}`,representation:{id:`effect/schema/isPattern`,payload:{source:n,flags:e.flags}},toJsonSchema:()=>({pattern:n}),arbitrary:{constraint:{patterns:[e.source]}},...t})}function Ip(e,t){let n=Object.getOwnPropertyDescriptors(e);return t(n),Object.create(Object.getPrototypeOf(e),n)}function Lp(e,t){return e.encoding===t?e:Ip(e,e=>{e.encoding.value=t})}function Rp(e,t){return e.context===t?e:Ip(e,e=>{e.context.value=t})}function zp(e,t){if(e.checks){let n=e.checks[e.checks.length-1];return Bp(e,ss(e.checks.slice(0,-1),n.annotate(t)))}return Ip(e,e=>{e.annotations.value={...e.annotations.value,...t}})}function Bp(e,t){if(e._tag===`Suspend`&&t!==void 0)throw Error(`Cannot add checks to Suspend`);return e.checks===t?e:Ip(e,e=>{e.checks.value=t})}function Vp(e,t){return Bp(e,mp(e.checks,t))}function Hp(e,t){let n=t(e.to);return n===e.to?e:new zf(n,e.transformation)}function Up(e,t){let n=e,r=n[n.length-1],i=Hp(r,t);return i===r?e:ss(e.slice(0,e.length-1),i)}function Wp(e){return t=>t.encoding?Lp(t,Up(t.encoding,e)):t}function Gp(e){function t(n){return n.encoding?Lp(n,Up(n.encoding,t)):e(n)}return Pi(t)}function Kp(e,t,n){let r=new zf(e,t);return Lp(n,n.encoding?[...n.encoding,r]:[r])}function qp(e,t){let n=!1,r=Array(e.length);for(let i=0;inew sp(e,t)),i.map(e=>new dp(e,t,n)))}function em(e){return e.context?.isOptional??!1}function tm(e){return e.annotations?.[`~structural`]===!0||e._tag===`FilterGroup`&&e.checks.every(tm)}function nm(e){function t(e){return tm(e)?[e]:e._tag===`FilterGroup`?e.checks.flatMap(t):[]}let n=e.flatMap(t);return ls(n)?n:void 0}var rm=Pi(e=>{if(e.encoding)return rm(Lp(e,void 0));let t=e,n=t.recur?.(rm)??t,r=n.encodingChecks;if(r){let t=n===e?r:Lf(n)||Rf(n)||Nf(n)&&n.typeParameters.length>0?nm(r):void 0;return Ip(n,e=>{e.encodingChecks.value=void 0,e.checks.value=mp(n.checks,t)})}return n}),im=Pi(e=>rm(om(e)));function am(e,t){let n=t,r=n.length,i=n[r-1],a=[new zf(om(Lp(e,void 0)),n[0].transformation.flip())];for(let e=1;e{if(e.encoding)return am(e,e.encoding);let t=e;return t.flip?.(om)??t.recur?.(om)??t});function sm(e){switch(e._tag){case`Undefined`:return!0;case`Union`:return e.types.some(sm);default:return!1}}function cm(e,t){let n=cd(t);return r=>r._tag===`None`?sd:r.value===t?n:pd(new Kd(e,r))}function lm(e,t){return n=>n._tag===`None`?sd:t(n.value)?od(n):pd(new Kd(e,n))}function um(e,t,n){if(n?.disableChecks||e.checks===void 0)return t;let r=[];return vm(e.checks,t,r,e,n),r.length===0?t:void 0}var dm=Gp(e=>{switch(e._tag){default:return e;case`Number`:return e.toCodecStringTree();case`Union`:return e.recur(dm)}}),fm=new globalThis.RegExp(`^${ap}$`),pm=new globalThis.RegExp(`^(?:${ap}|Infinity|-Infinity|NaN)$`);function mm(e){return Fp(fm,{expected:`a string representing a finite number`,representation:{id:`effect/schema/isStringFinite`,payload:null},toJsonSchema:()=>({pattern:fm.source}),...e})}var hm=Vp(Jf,[mm()]),gm=new zf(hm,Af),_m=new zf(new Tp([hm,Dp],`anyOf`),Af);function vm(e,t,n,r,i){for(let a=0;a{switch(e._tag){case`Declaration`:{let t=e.annotations?.[ym];return j(t)?Lp(e,[Hp(t(e.typeParameters),Cm)]):e}case`Objects`:case`Arrays`:return e.recur(e=>{let t=e.context?.defaultValue;if(t){let n=Cm(e);return Lp(n,n.encoding?[...n.encoding,...t]:t)}return Cm(e)});case`Suspend`:return e.recur(Cm);default:return e}});function wm(e){let t=km(Cm(rm(e.ast)));return(e,n)=>t(e,n?.disableChecks?n?.parseOptions?{...n.parseOptions,disableChecks:!0}:{disableChecks:!0}:n?.parseOptions)}function Tm(e){let t=wm(e);return(e,n)=>{let r=kd(t(e,n));return Qu(r)?Qo(r.value):(ff(r.cause,`Option adapter can only return none for schema issues`),Zo())}}function Em(e){let t=wm(e);return(e,n)=>{let r=kd(t(e,n));if(Qu(r))return r.value;let i=ff(r.cause,`Constructor adapter can only throw schema issues`);throw Error(i.toString(),{cause:i})}}function Dm(e,t){let n=km(e.ast);return t===void 0?n:(e,r)=>n(e,Om(t,r))}var Om=(e,t)=>t===void 0?e:{...e,...t};function km(e){let t=jm(e);return(e,n)=>jd(t(Qo(e),n??Bf),e=>e._tag===`None`?pd(new qd(e)):od(e.value))}function Am(e,t){return bd(e,e=>md(()=>Yu(e,t)))}var jm=Pi(e=>{let t,n=e.checks,r=e.encoding,i=r,a=i?.length??0,o=e.encodingChecks,s=(n?n[n.length-1].annotations:e.annotations)?.parseOptions;return!e.context&&!r&&!n&&!o?(n,r)=>(t??=e.getParser(jm),s&&(r={...r,...s}),t(n,r)):(r,c)=>{s&&(c={...c,...s});let l;if(i){for(let e=a-1;e>=0;e--){let t=i[e],n=t.to,a=jm(n);if(l=l?jd(l,e=>a(e,c)):a(r,c),t.transformation._tag===`Transformation`){let e=t.transformation.decode;l=jd(l,t=>e.run(t,c))}else l=t.transformation.decode(l,c)}l=Am(l,t=>new Vd(e,r,t))}t??=e.getParser(jm);let u=r=>{let i=t(r,c);return o&&!c?.disableChecks&&(i=jd(i,t=>{if(es(r)&&es(t)){let t=[];if(vm(o,r.value,t,e,c),ls(t))return pd(new Gd(e,r,t))}return od(t)})),n&&!c?.disableChecks&&(i=jd(i,t=>{if(es(t)){let r=t.value,i=[];if(vm(n,r,i,e,c),ls(i))return pd(new Gd(e,t,i))}return od(t)})),i};return l?jd(l,u):u(r)}}),Mm=`~effect/Schema/Schema`,Nm={[Mm]:Mm,pipe(){return Oi(this,arguments)},annotate(e){return this.rebuild(zp(this.ast,e))},annotateKey(e){return this.rebuild(Jp(this.ast,e))},check(...e){return this.rebuild(Vp(this.ast,e))}};function Pm(e,t){function n(){}let r=Object.defineProperties(Object.setPrototypeOf(n,Nm),Object.getOwnPropertyDescriptors({...t}));r.ast=e,r.rebuild=e=>Pm(e,t);let i=wm(r);return r.makeEffect=(e,t)=>Fm(i(e,t)),r.make=Em(r),r.makeOption=Tm(r),r}function Fm(e){return bd(e,e=>md(()=>Yu(e,e=>new Sm(e))))}var Im=e=>e;function Lm(e,t){let n=Dm(e,t);return(e,t)=>Fm(n(e,t))}var Rm=Pm,zm=Im(e=>Rm(Xp(e.ast),{schema:e}));function Bm(e){let t=Rm(new Kf(e),{literal:e,transform(n){return t.pipe(Qm(Bm(n),{decode:vf(()=>n),encode:vf(()=>e)}))}});return t}var Vm=Rm(Gf),Hm=Rm(Wf),J=Rm(Jf),Y=Rm(Qf),X=Rm($f);function Um(e,t){return Rm(e,{fields:t,mapFields(e,t){let n=e(this.fields);return Um(hp(n,t?.unsafePreserveChecks?this.ast.checks:void 0),n)}})}function Z(e){return Um(hp(e,void 0),e)}function Wm(e,t,n){let r=n?.keyValueCombiner?.decode||n?.keyValueCombiner?.encode?new cp(n.keyValueCombiner.decode,n.keyValueCombiner.encode):void 0;return Rm($p(e.ast,t.ast,r),{key:e,value:t})}function Gm(e,t){return Rm(e,{elements:t,mapElements(e,t){let n=e(this.elements);return Gm(_p(n,t?.unsafePreserveChecks?this.ast.checks:void 0),n)}})}var Km=Im(e=>Rm(new ep(!1,[],[e.ast]),{value:e})),qm=Im(e=>Rm(new ep(!0,e.ast.elements,e.ast.rest),{schema:e}));function Jm(e,t){return Rm(e,{members:t,mapMembers(e,t){let n=e(this.members);return Jm(vp(n,this.ast.mode,t?.unsafePreserveChecks?this.ast.checks:void 0),n)}})}function Ym(e,t){return Jm(vp(e,t?.mode??`anyOf`,void 0),e)}function Xm(e){let t=e.map(Bm);return Rm(vp(t,`anyOf`,void 0),{literals:e,members:t,mapMembers(e){return Ym(e(this.members))},pick(e){return Xm(e)},transform(e){return Ym(t.map((t,n)=>t.transform(e[n])))}})}var Zm=Im(e=>Ym([e,Hm]));function Qm(e,t){return n=>Rm(Zp(n.ast,e.ast,t?Df(t):kf()),{from:n,to:e})}globalThis.RegExp,globalThis.URL;var $m=J.annotate({expected:`a string that will be decoded as JSON`,contentMediaType:`application/json`});function eh(e){return $m.pipe(Qm(e,jf))}globalThis.File,globalThis.FormData,globalThis.URLSearchParams,globalThis.Uint8Array;var th=Di(),Q=e=>qm(Km(e)),$=zm,nh=Wm(J,J),rh=Z({kind:Xm([`stranded`,`blocked`,`reviewing`,`awaiting`,`queued`,`held`,`idle`]),text:J,detail:$(J),subject:$(J)}),ih=Z({fires:Y,limit:$(Y),complete:X,since:$(J),level:Xm([`ok`,`warn`,`over`]),note:J}),ah=Z({scope:$(J),remaining:$(Zm(Y)),blocked_until:$(J),source:$(J),checked_at:$(J),last_fired:$(J),fair_use:ih}),oh=Z({held:X,key:$(J),since:$(J),hold_until:$(J)}),sh=Z({owner:J,host:J,expires_at:J,expired:X}),ch=Z({in_flight:Y,queued:Y,held:Y,fixing:Y}),lh=Z({kind:J,level:Xm([`bad`,`warn`]),subject:$(J),text:J,detail:$(J),link:$(J),link_text:$(J)}),uh=Z({login:J,name:J,mark:Xm([`commanded`,`claimed`,`pending`]),required:$(X),at:$(J),primary:$(X)}),dh=Z({title:$(J),key:J,repo:J,pr:Y,head:J,phase:J,fired_at:$(J),deadline:$(J),bots:Q(uh),host:$(J),note:$(J),next:$(J),fixing:$(X)}),fh=Z({title:$(J),key:J,repo:J,pr:Y,head:J,position:$(Y),ready_at:$(J),why:$(J),attempts:$(Y),host:$(J),co_only:$(X),next:$(J)}),ph=Z({title:$(J),key:J,repo:J,pr:Y,head:$(J),reason:$(J),by:$(J),at:J}),mh=Z({key:J,repo:J,pr:Y,head:$(J),host:$(J),model:$(J),attempt:$(Y),max_attempts:$(Y),findings:$(Y),log:$(J),since:J,heartbeat:$(J)}),hh=Z({name:J,health:Xm([`healthy`,`unhealthy`,`unknown`]),failures:$(Y),last_error:$(J),last_failure:$(J),last_success:$(J)}),gh=Z({title:$(J),key:J,repo:J,pr:Y,head:J,outcome:J,note:$(J),at:$(J)}),_h=Z({now:J,rev:Y,wrote_at:$(J),headline:rh,quota:ah,slot:oh,leader:$(sh),counts:ch,attention:Q(lh),in_flight:Q(dh),queue:Q(fh),held:Q(ph),autofix:Z({sessions:Q(mh),hosts:Q(hh)}),finished:Q(gh)}),vh=Z({overridden:X,agent:$(J),models:Q(J),model_choices:Q(J),model:$(J),effort:$(J),prompt:$(J),max_attempts:Y,severities:Q(J),ask_mode:J,forks:X,skip_authors:Q(J),sources:nh,by:$(J),lagging_hosts:$(Q(J)),agent_on:$(Q(Z({host:J,has:$(X),path:$(J),stale:$(X)})))}),yh=Z({repo:J,enrollment:Xm([`state`,`env`,`excluded`,`scope`,`off`]),reviewed:X,env_conflict:$(X),clear_enables:$(X),enroll_reason:$(J),enroll_by:$(J),enroll_at:$(J),env_host:$(J),reviewers:Q(J),required:Q(J),primary_off:$(X),solver:$(vh),override:X,override_by:$(J),override_at:$(J),autofix:Xm([`default`,`on`,`off`]),autofix_reason:$(J),autofix_by:$(J),autofix_at:$(J),active_rounds:Y,queued_rounds:Y,held_prs:Y,fixing:Y}),bh=Z({login:J,name:J,primary:X,metered:X,enabled:X,required:X,configurable:X,command:$(J),trigger:$(J),grace:$(J),last_seen:$(J),seen_on:$(J),repo_count:Y,status:Xm([`working`,`quiet`,`silent`,`unverified`,`off`]),last_asked:$(J),site:$(J),docs:$(J),pitch:$(J),cost:$(J),setup:$(Q(J)),suited_to:$(J),prices_checked_at:$(J),suggested:$(X),because:$(J)}),xh=Z({key:J,label:J,status:Xm([`ok`,`warn`,`bad`,`unknown`]),detail:$(J)}),Sh=Z({fix:$(Q(J)),name:J,purpose:J,required:X,found:X,path:$(J)}),Ch=Z({name:J,roles:$(Q(J)),health:$(Xm([`healthy`,`unhealthy`,`unknown`])),last_seen:$(J),failures:$(Y),last_error:$(J),caps:$(Y)}),wh=Z({host:J,agent:$(J),version:$(J),caps:$(Y),roles:$(Q(J)),tools:Q(Z({name:J,path:$(J),version:$(J)})),at:$(J),stale:$(X),behind:$(X)}),Th=Z({checks:Q(xh),tools:Q(Sh),hosts:Q(Ch),tools_host:J,fleet:$(Q(wh)),ready:Y,attention:Y,optional:Y}),Eh=Z({login:J,name:J,primary:X,required:X,metered:X,command:$(J),trigger:$(J),grace:$(J)}),Dh=Z({gate_repo:J,state_ref:J,dashboard_issue:$(Y),calibration_pr:$(Y),scope:$(Q(J)),allow_repos:$(Q(J)),exclude_repos:$(Q(J)),skip_authors:$(Q(J)),skip_marker:$(J),min_interval:J,inflight_timeout:J,watch_interval:J,reviewers:Q(Eh),autofix_command:$(Q(J)),autofix_max_attempts:$(Y),autofix_concurrency:$(Y),autofix_forks:$(X),workspace_root:$(J)}),Oh=Z({key:J,value:J,detail:$(J)}),kh=Z({recorded:X,reviewers:Q(Z({login:J,budget:J,required:X,trigger:$(J)})),min_interval:J,weekly_limit:Y,autofix_default:X,sources:nh,overriding:$(Q(J)),by:$(J),updated_at:$(J),lagging_hosts:$(Q(J))}),Ah=Z({key:J,kind:J,group:J,label:J,help:J,per_host:$(X),identity:$(X),review_impact:$(X),value:J,source:Xm([`fleet`,`env`,`default`]),host_value:$(J)}),jh=Z({config:Dh,quota:ah,plumbing:Q(Oh),fleet:$(kh),env:$(Q(Ah))}),Mh=Z({id:J,bot:J,severity:J,scale:$(J),category:$(J),effort:$(J),path:$(J),line:$(Y),title:J,body:$(J),thread_id:$(J),url:$(J),source:$(J),commit:$(J),created_at:$(J)}),Nh=Z({head:J,converged:X,status:$(J),reason:$(J),reviewed_by:$(Wm(J,X)),findings:Q(Mh),dismissed:$(Y),checked_at:J}),Ph=Z({head:J,phase:J,attempts:$(Y),enqueued_at:J,fired_at:$(J),deadline:$(J),retry_at:$(J),note:$(J),host:$(J),co_only:$(X),bots:Q(uh),fixing:$(mh),dismissed:$(Q(Z({id:J,reason:J}))),next:$(J)}),Fh=Z({head:J,outcome:J,note:$(J),at:$(J),current:$(X)}),Ih=Z({low:Y,high:Y,exact:$(X),unpriced:$(Q(J)),summary:J,prices_checked_at:J,pricing_note:J,reviewers:Q(Z({bot:J,low:Y,high:Y,exact:$(X),unknown:$(X),basis:J})),diff:Z({additions:Y,deletions:Y,changed_files:Y})}),Lh=Z({repo:J,pr:Y,rev:Y,round:$(Ph),hold:$(ph),title:$(J),observed:$(Nh),observe_error:$(J),cost:$(Ih),cost_error:$(J),history:Q(Fh)}),Rh=Z({at:J,kind:J,level:Xm([`ok`,`warn`,`bad`,`info`]),repo:$(J),pr:$(Y),head:$(J),text:J,detail:$(J)}),zh=Z({overview:_h,repos:Q(yh),bots:Q(bh),setup:Th,settings:jh,events:Q(Rh),stale:$(Z({error:J,since:J}))}),Bh=Z({repos:Q(Z({repo:J,private:X,archived:X,fork:X,issues:Y,pushed_at:$(J),language:$(J),enrollment:$(Z({source:J,enabled:X,env_conflict:$(X),clear_enables:$(X),reason:$(J),by:$(J)}))})),truncated:$(Q(J))}),Vh=Z({rev:Y,repo:J,open:Y,eligible:Y,skipped:$(Wm(J,Y)),low:Y,high:Y,summary:J,prices_checked_at:J}),Hh=Z({impact:Z({rev:Y,summary:J,changes:Q(J),reopened:Y})}),Uh=Ym([Z({snapshot:zh,warning:$(J)}),zh]),Wh=class extends $u(`DashboardError`){},Gh=(e,t,n)=>Lm(eh(e))(t).pipe(xd(e=>new Wh({kind:`decode`,message:`${n} returned invalid data: ${e.message}`,cause:e}))),Kh=(e,t,n)=>typeof e==`object`&&e&&`error`in e&&typeof e.error==`string`?e.error:n.trim()||`HTTP ${t}`,qh=(e,t,n)=>fd(function*(){let r=new Headers(n?.headers);r.set(`X-CRQ-Dashboard`,`1`);let i=yield*ad({try:e=>fetch(t,{...n,headers:r,signal:e}),catch:e=>new Wh({kind:`network`,message:`Could not reach crq serve`,cause:e})}),a=yield*ad({try:()=>i.text(),catch:e=>new Wh({kind:`network`,message:`The server response could not be read`,cause:e})});if(!i.ok){let e=yield*Gh(Vm,a,`The server`).pipe(Sd(()=>void 0));return yield*new Wh({kind:`http`,status:i.status,message:Kh(e,i.status,a)})}return yield*Gh(e,a,`The server`)}),Jh=(e,t)=>Gh(e,t,`The event stream`),Yh=()=>new Wh({kind:`stream`,message:`The live state stream disconnected`}),Xh=Z({error:J});function Zh(e,t){let n=n=>Td(fd(function*(){let r=yield*K(ld(()=>new EventSource(`/api/events`)),e=>ld(()=>e.close()));return yield*dd(i=>{r.onopen=()=>{n()},r.onmessage=n=>{let r=Od(Jh(zh,n.data).pipe(wd({onFailure:()=>void 0,onSuccess:e=>e})));r&&(e(r),t(r.stale?{status:`reconnecting`,error:r.stale.error}:{status:`live`}))};let a=e=>{t({status:`reconnecting`,error:Od(Jh(Xh,e.data).pipe(wd({onFailure:()=>void 0,onSuccess:e=>e})))?.error??`The shared state ref is unavailable`})};return r.addEventListener(`unavailable`,a),r.onerror=()=>{t({status:`reconnecting`,error:`Lost the connection to crq serve`}),i(pd(Yh()))},ld(()=>{r.removeEventListener(`unavailable`,a),r.onopen=null,r.onmessage=null,r.onerror=null})})})),r=Ed(fd(function*(){t({status:`connecting`});let e=1e3;for(;;)yield*n(()=>{e=1e3}).pipe(yd(()=>ud)),yield*Cd(e),e=Math.min(e*2,3e4)}));return()=>{Ed(bm(r))}}function Qh(e,t){return(e===`fleet`||e===`env`||e===`reviewers`)&&t.preview===!0?qh(Hh,`/api/action/${e}`,{method:`POST`,headers:{"Content-Type":`application/json`,"X-CRQ-Dashboard":`1`},body:JSON.stringify(t)}).pipe(vd(({impact:e})=>e)):qh(Uh,`/api/action/${e}`,{method:`POST`,headers:{"Content-Type":`application/json`,"X-CRQ-Dashboard":`1`},body:JSON.stringify(t)}).pipe(vd(e=>`snapshot`in e?e:{snapshot:e}))}var $h=(e=!1)=>qh(Bh,`/api/discover${e?`?refresh=1`:``}`),eg=e=>qh(Vh,`/api/enroll-preview?repo=${encodeURIComponent(e)}`),tg=(e,t,n=!1,r=!1)=>{let i=new URLSearchParams;return n&&i.set(`refresh`,`1`),r&&i.set(`state_only`,`1`),qh(Lh,`/api/pr/${e}/${t}${i.size>0?`?${i}`:``}`)},ng=e=>Qh(`fleet`,{fleet:e,preview:!0}),rg=(0,d.createContext)(null);function ig(){let e=(0,d.use)(rg);if(!e)throw Error(`useDashboard must be used inside DashboardProvider`);return e}function ag(e){let t=(0,th.c)(3),n=e===void 0?1e3:e,[r,i]=(0,d.useState)(og),a,o;return t[0]===n?(a=t[1],o=t[2]):(a=()=>{let e=setInterval(()=>i(Date.now()),n);return()=>clearInterval(e)},o=[n],t[0]=n,t[1]=a,t[2]=o),(0,d.useEffect)(a,o),r}function og(){return Date.now()}function sg(e){return e?new Date(e).toLocaleTimeString([],{hour:`2-digit`,minute:`2-digit`,second:`2-digit`,hour12:!1}):`—`}function cg(e){let t=Math.max(0,Math.floor(e)),n=Math.floor(t/3600),r=Math.floor(t%3600/60),i=t%60,a=n>0?String(r).padStart(2,`0`):String(r);return`${n>0?`${n}:`:``}${a}:${String(i).padStart(2,`0`)}`}function lg(e,t){if(!e)return`—`;let n=(new Date(e).getTime()-t)/1e3;return n<=0?`due`:`−${cg(n)}`}function ug(e,t){return e?cg((t-new Date(e).getTime())/1e3):`—`}function dg(e,t){if(!e)return`—`;let n=Math.max(0,(t-new Date(e).getTime())/1e3);return n<90?`${Math.round(n)}s ago`:n<5400?`${Math.round(n/60)}m ago`:n<172800?`${Math.round(n/3600)}h ago`:`${Math.round(n/86400)}d ago`}function fg(e,t){if(!e||t.overview.rev>e.overview.rev)return t;if(t.overview.rev{i(t=>fg(t,e))},t[1]=c):c=t[1];let l=c,u,f;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(u=()=>Zh(l,s),f=[l],t[2]=u,t[3]=f):(u=t[2],f=t[3]),(0,d.useEffect)(u,f);let p;t[4]!==o||t[5]!==r?(p={snapshot:r,setSnapshot:l,live:o},t[4]=o,t[5]=r,t[6]=p):p=t[6];let m;return t[7]!==n||t[8]!==p?(m=(0,O.jsx)(rg,{value:p,children:n}),t[7]=n,t[8]=p,t[9]=m):m=t[9],m}function mg(){let e=(0,th.c)(8),{live:t,snapshot:n}=ig(),r=ag(5e3),i;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(i=(0,O.jsx)(`span`,{className:`size-2 animate-pulse rounded-full bg-faint`}),e[0]=i):i=e[0];let a;e[1]!==t.error||e[2]!==t.status||e[3]!==r||e[4]!==n?.overview?(a=t.status===`reconnecting`?`${t.error??`Cannot reach the server`} · last state ${dg(n?.overview.wrote_at,r)}`:`Reading the shared state ref`,e[1]=t.error,e[2]=t.status,e[3]=r,e[4]=n?.overview,e[5]=a):a=e[5];let o;return e[6]===a?o=e[7]:(o=(0,O.jsx)(`main`,{className:`mx-auto max-w-[1400px] px-4 py-16 text-mut sm:px-6`,children:(0,O.jsxs)(`div`,{className:`flex items-center gap-3 font-mono text-[12px] tracking-[0.04em] uppercase`,children:[i,a]})}),e[6]=a,e[7]=o),o}var hg=[{label:`Overview`,to:`/`,icon:yi},{label:`Repos`,to:`/repos`,icon:vi},{label:`Bots`,to:`/bots`,icon:_i},{label:`Setup`,to:`/setup`,icon:Ci},{label:`Settings`,to:`/settings`,icon:xi}];function gg(){let e=(0,th.c)(1),t;return e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,O.jsx)(pg,{children:(0,O.jsx)(_g,{})}),e[0]=t):t=e[0],t}function _g(){let e=(0,th.c)(39),{snapshot:t,live:n}=ig(),r=ag(5e3),i;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(i={select:vg},e[0]=i):i=e[0];let a=ti(i),o;e[1]===a?o=e[2]:(o=a.startsWith(`/pr/`)?`/`:a,e[1]=a,e[2]=o);let s=o,c;e[3]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,O.jsx)(`span`,{className:`rounded-[5px] bg-[#B9F4D2] px-1.5 py-0.5 font-mono text-[13px] font-semibold tracking-[-0.03em] text-[#10251A] shadow-[inset_0_0_0_1px_rgb(255_255_255/0.3)]`,children:`crq`}),e[3]=c):c=e[3];let l;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,O.jsxs)(kr,{to:`/`,className:`group flex items-center gap-3`,"aria-label":`Code Review Queue overview`,children:[c,(0,O.jsxs)(`span`,{className:`flex flex-col leading-none`,children:[(0,O.jsx)(`span`,{className:`text-[14.5px] font-[650] tracking-[-0.015em]`,children:`Code Review Queue`}),(0,O.jsx)(`span`,{className:`mt-1 font-mono text-[9px] tracking-[0.14em] text-white/45 uppercase`,children:`fleet control`})]})]}),e[4]=l):l=e[4];let u;e[5]===s?u=e[6]:(u=hg.map(e=>{let{icon:t,label:n,to:r}=e,i=s===r;return(0,O.jsxs)(kr,{to:r,"aria-current":i?`page`:void 0,className:`inline-flex shrink-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-[13px] font-medium ${i?`bg-white text-ink shadow-sm`:`text-white/65 hover:bg-white/8 hover:text-white`}`,children:[(0,O.jsx)(t,{"aria-hidden":!0,className:`size-3.5`,strokeWidth:1.8}),n]},r)}),e[5]=s,e[6]=u);let d;e[7]===u?d=e[8]:(d=(0,O.jsx)(`nav`,{"aria-label":`Dashboard`,className:`order-3 flex w-full gap-1 overflow-x-auto sm:order-none sm:w-auto`,children:u}),e[7]=u,e[8]=d);let f;e[9]!==n.error||e[10]!==n.status||e[11]!==r||e[12]!==t?.overview?(f=n.status===`live`?`Live, revision ${t?.overview.rev??`unknown`}`:n.status===`connecting`?`Connecting to the dashboard server`:`${n.error??`Reconnecting`}; last state ${dg(t?.overview.wrote_at,r)}`,e[9]=n.error,e[10]=n.status,e[11]=r,e[12]=t?.overview,e[13]=f):f=e[13];let p=`ml-auto flex items-center gap-2 rounded-full border px-2.5 py-1 font-mono text-[10.5px] ${n.status===`live`?`border-[#68D99B]/30 bg-[#68D99B]/10 text-[#A7EDC5]`:`border-white/15 bg-white/5 text-white/60`}`,m=`size-2 rounded-full ${n.status===`live`?`bg-[#68D99B] shadow-[0_0_0_3px_rgb(104_217_155/0.12)]`:n.status===`connecting`?`animate-pulse bg-white/35`:`bg-[#F3B66A]`}`,h;e[14]===m?h=e[15]:(h=(0,O.jsx)(`span`,{className:m}),e[14]=m,e[15]=h);let g;e[16]!==n.status||e[17]!==r||e[18]!==t?.overview?(g=n.status===`live`?(0,O.jsxs)(O.Fragment,{children:[`LIVE · REV `,t?.overview.rev??`—`]}):n.status===`connecting`?`CONNECTING…`:(0,O.jsxs)(O.Fragment,{children:[`STALE · `,dg(t?.overview.wrote_at,r)]}),e[16]=n.status,e[17]=r,e[18]=t?.overview,e[19]=g):g=e[19];let _;e[20]!==g||e[21]!==f||e[22]!==p||e[23]!==h?(_=(0,O.jsxs)(`span`,{title:f,className:p,children:[h,g]}),e[20]=g,e[21]=f,e[22]=p,e[23]=h,e[24]=_):_=e[24];let v;e[25]!==_||e[26]!==d?(v=(0,O.jsx)(`header`,{className:`sticky top-0 z-40 border-b border-white/10 bg-ink text-white shadow-[0_8px_24px_rgb(14_24_36/0.16)]`,children:(0,O.jsxs)(`div`,{className:`mx-auto flex max-w-[1600px] flex-wrap items-center gap-x-5 gap-y-2 px-4 py-2.5 sm:px-6`,children:[l,d,_]})}),e[25]=_,e[26]=d,e[27]=v):v=e[27];let y;e[28]!==n.error||e[29]!==n.status||e[30]!==t?(y=n.status===`reconnecting`&&t&&(0,O.jsxs)(`div`,{role:`status`,className:`border-b border-warn-edge bg-warn-bg px-4 py-2 text-[12.5px] text-warn sm:px-6`,children:[n.error?`${n.error}.`:t.stale?`The shared state ref is unavailable: ${t.stale.error}.`:`Lost the connection to crq serve.`,` `,`This is the last state loaded; actions are disabled until it becomes current again.`]}),e[28]=n.error,e[29]=n.status,e[30]=t,e[31]=y):y=e[31];let b=n.status!==`live`||void 0,x;e[32]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,O.jsx)(Kr,{}),e[32]=x):x=e[32];let S;e[33]===b?S=e[34]:(S=(0,O.jsx)(`div`,{inert:b,children:x}),e[33]=b,e[34]=S);let C;return e[35]!==v||e[36]!==y||e[37]!==S?(C=(0,O.jsxs)(`div`,{className:`min-h-screen`,children:[v,y,S]}),e[35]=v,e[36]=y,e[37]=S,e[38]=C):C=e[38],C}function vg(e){return e.location.pathname}function yg(e){let t=(0,th.c)(4),{error:n}=e,r,i;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(r=(0,O.jsx)(gi,{"aria-hidden":!0,className:`mb-3 size-5`}),i=(0,O.jsx)(`h1`,{className:`font-[650]`,children:`This dashboard view could not render`}),t[0]=r,t[1]=i):(r=t[0],i=t[1]);let a;return t[2]===n.message?a=t[3]:(a=(0,O.jsx)(`main`,{className:`mx-auto max-w-[900px] px-6 py-16`,children:(0,O.jsxs)(`div`,{className:`rounded-xl border border-bad-edge bg-bad-bg p-5 text-bad`,children:[r,i,(0,O.jsx)(`p`,{className:`mt-1 text-sm`,children:n.message})]})}),t[2]=n.message,t[3]=a),a}function bg(){let e=(0,th.c)(1),t;return e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,O.jsxs)(`main`,{className:`mx-auto max-w-[900px] px-6 py-16 text-mut`,children:[(0,O.jsx)(`p`,{className:`font-mono text-xs tracking-wider text-faint uppercase`,children:`404 · unknown control surface`}),(0,O.jsx)(`h1`,{className:`mt-2 text-xl font-[650] text-ink`,children:`That dashboard route does not exist.`}),(0,O.jsx)(kr,{to:`/`,className:`mt-4 inline-block font-semibold text-acc hover:underline`,children:`Return to Overview`})]}),e[0]=t):t=e[0],t}var xg=`modulepreload`,Sg=function(e){return`/`+e},Cg={},wg=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=o(t.map(t=>{if(t=Sg(t,n),t=s(t),t in Cg)return;Cg[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:xg,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},Tg=Pr({component:gg,errorComponent:yg,notFoundComponent:bg}),Eg=Mr({getParentRoute:()=>Tg,path:`/`,component:Fr(()=>wg(()=>import(`./OverviewRoute-DRxoPlyS.js`),__vite__mapDeps([0,1,2])),`OverviewRoute`)}),Dg=Mr({getParentRoute:()=>Tg,path:`/repos`,component:Fr(()=>wg(()=>import(`./ReposRoute-Du6RfsHV.js`),__vite__mapDeps([3,1,2])),`ReposRoute`)}),Og=Mr({getParentRoute:()=>Tg,path:`/bots`,component:Fr(()=>wg(()=>import(`./BotsRoute-Coq2BPzC.js`),__vite__mapDeps([4,1])),`BotsRoute`)}),kg=Mr({getParentRoute:()=>Tg,path:`/setup`,component:Fr(()=>wg(()=>import(`./SetupRoute-6djY24Gk.js`),__vite__mapDeps([5,1])),`SetupRoute`)}),Ag=Mr({getParentRoute:()=>Tg,path:`/settings`,component:Fr(()=>wg(()=>import(`./SettingsRoute-zk9_WV54.js`),__vite__mapDeps([6,1,2])),`SettingsRoute`)}),jg=Mr({getParentRoute:()=>Tg,path:`/pr/$owner/$name/$pr`,params:{parse:({name:e,owner:t,pr:n})=>({name:e,owner:t,pr:Number(n)}),stringify:({name:e,owner:t,pr:n})=>({name:e,owner:t,pr:String(n)})},beforeLoad:({params:e})=>{if(!Number.isInteger(e.pr)||e.pr<=0)throw Qe()},component:Fr(()=>wg(()=>import(`./PRRoute-B5ErkSzr.js`),__vite__mapDeps([7,1,2])),`PRRoute`)}),Mg=Zr({routeTree:Tg.addChildren([Eg,Dg,Og,kg,Ag,jg]),history:on(),defaultPreload:`intent`,defaultPreloadDelay:120,defaultPreloadStaleTime:3e3}),Ng=document.getElementById(`root`);if(!Ng)throw Error(`Dashboard root element is missing`);(0,wi.createRoot)(Ng).render((0,O.jsx)(d.StrictMode,{children:(0,O.jsx)(Ti,{children:(0,O.jsx)(ei,{router:Mg})})}));export{c as S,hi as _,lg as a,Tn as b,ig as c,ng as d,tg as f,Di as g,Dd as h,sg as i,$h as l,wd as m,mg as n,ug as o,Qh as p,dg as r,ag as s,jg as t,eg as u,kr as v,u as x,gr as y}; \ No newline at end of file diff --git a/internal/serve/dist/assets/ui-Bim2uazI.js b/internal/serve/dist/assets/ui-Bim2uazI.js new file mode 100644 index 00000000..5de20e34 --- /dev/null +++ b/internal/serve/dist/assets/ui-Bim2uazI.js @@ -0,0 +1 @@ +import{S as e,_ as t,b as n,g as r,v as i,x as a}from"./index-DlC1aBiV.js";var o=t(`external-link`,[[`path`,{d:`M15 3h6v6`,key:`1q9fwt`}],[`path`,{d:`M10 14 21 3`,key:`gplh6r`}],[`path`,{d:`M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6`,key:`a6xqqp`}]]),s=r(),c=e(a(),1),l=n();function u({tone:e=`mut`,children:t}){let n={ok:`text-ok bg-ok-bg border-ok-edge`,warn:`text-warn bg-warn-bg border-warn-edge`,bad:`text-bad bg-bad-bg border-bad-edge`,mut:`text-mut bg-[#EEF0F3] border-edge`,acc:`text-acc bg-acc-bg border-acc-edge`}[e];return(0,l.jsxs)(`span`,{className:`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 py-px text-xs font-medium ${n}`,children:[(0,l.jsx)(`span`,{className:`size-[7px] shrink-0 rounded-full bg-current`}),t]})}function d(e){let t=(0,s.c)(13),{title:n,count:r,end:i,children:a}=e,o;t[0]===n?o=t[1]:(o=(0,l.jsx)(`h2`,{className:`text-[14px] font-[650] tracking-[-0.01em]`,children:n}),t[0]=n,t[1]=o);let c;t[2]===r?c=t[3]:(c=r!==void 0&&(0,l.jsx)(`span`,{className:`text-[12.5px] text-faint`,children:r}),t[2]=r,t[3]=c);let u;t[4]===i?u=t[5]:(u=i&&(0,l.jsx)(`span`,{className:`ml-auto text-[12.5px] text-faint`,children:i}),t[4]=i,t[5]=u);let d;t[6]!==o||t[7]!==c||t[8]!==u?(d=(0,l.jsxs)(`header`,{className:`sticky left-0 flex min-w-fit flex-wrap items-baseline gap-2.5 border-b border-edge/70 px-[18px] py-2.5`,children:[o,c,u]}),t[6]=o,t[7]=c,t[8]=u,t[9]=d):d=t[9];let f;return t[10]!==a||t[11]!==d?(f=(0,l.jsxs)(`section`,{className:`data-card mb-3.5 overflow-x-auto rounded-[10px] border border-edge bg-card shadow-card`,children:[d,a]}),t[10]=a,t[11]=d,t[12]=f):f=t[12],f}function f(e){let t=(0,s.c)(2),{children:n}=e,r;return t[0]===n?r=t[1]:(r=(0,l.jsx)(`p`,{className:`px-[18px] py-4 text-[13px] text-faint`,children:n}),t[0]=n,t[1]=r),r}function p({repo:e,size:t=16}){let[n,r]=(0,c.useState)(!1),i=e.split(`/`).pop()??e,a={width:t,height:t,borderRadius:t>20?7:4};return n?(0,l.jsx)(`span`,{title:e,style:{...a,fontSize:Math.max(9,t*.5)},className:`inline-flex shrink-0 items-center justify-center border border-edge bg-[#7A8496] align-[-3px] font-bold text-white`,children:i.slice(0,1).toUpperCase()}):(0,l.jsx)(`img`,{src:`/api/icon/repo/${e}`,alt:``,title:e,style:a,onError:()=>r(!0),className:`shrink-0 border border-edge bg-white object-cover align-[-3px]`})}function m({login:e,name:t,size:n=20}){let[r,i]=(0,c.useState)(!1),a={width:n,height:n,borderRadius:n>24?10:5};return r?(0,l.jsx)(`span`,{title:t,style:{...a,fontSize:Math.max(9,n*.42)},className:`inline-flex shrink-0 items-center justify-center border border-edge bg-bg font-mono font-semibold text-mut`,children:t.slice(0,2).toUpperCase()}):(0,l.jsx)(`img`,{src:`/api/icon/bot/${encodeURIComponent(e)}`,alt:``,title:t,style:a,onError:()=>i(!0),className:`shrink-0 border border-edge bg-white object-cover align-[-3px]`})}function h({repo:e,pr:t,className:n=``}){let[r=``,a=e]=e.split(`/`);return(0,l.jsxs)(`span`,{className:`inline-flex items-baseline gap-1`,children:[(0,l.jsxs)(i,{to:`/pr/$owner/$name/$pr`,params:{owner:r,name:a,pr:t},className:`text-acc hover:underline ${n}`,children:[a,`#`,t]}),(0,l.jsxs)(`a`,{href:`https://github.com/${e}/pull/${t}`,target:`_blank`,rel:`noreferrer`,title:`Open the pull request on GitHub`,className:`text-faint hover:text-acc`,children:[(0,l.jsx)(o,{"aria-hidden":!0,className:`inline size-3`}),(0,l.jsx)(`span`,{className:`sr-only`,children:`Open on GitHub`})]})]})}function g({repo:e,sha:t,className:n=``}){return t?(0,l.jsx)(`a`,{href:`https://github.com/${e}/commit/${t}`,target:`_blank`,rel:`noreferrer`,title:`commit ${t}`,className:`font-mono text-acc hover:underline ${n}`,children:t}):(0,l.jsx)(`span`,{className:`text-faint`,children:`—`})}function _(e){let t=(0,s.c)(4),{bots:n}=e,r;t[0]===n?r=t[1]:(r=n.map(v),t[0]=n,t[1]=r);let i;return t[2]===r?i=t[3]:(i=(0,l.jsx)(`span`,{className:`inline-flex items-center gap-2`,children:r}),t[2]=r,t[3]=i),i}function v(e){let t=e.mark===`commanded`?`✓`:e.mark===`claimed`?`⏳`:`—`,n=e.mark===`commanded`?`bg-ok`:e.mark===`claimed`?`bg-warn-fg`:`bg-faint`;return(0,l.jsxs)(`span`,{title:`${e.name}${e.required?` (required)`:``} — ${e.mark===`commanded`?`trigger commanded`:e.mark===`claimed`?`claim posted, not yet recorded`:`runs here — not asked for this head yet`}`,className:`relative inline-block ${e.mark===`pending`?`opacity-45`:``}`,children:[(0,l.jsx)(m,{login:e.login,name:e.name,size:22}),(0,l.jsx)(`i`,{className:`absolute -right-1 -bottom-1 flex size-[13px] items-center justify-center rounded-full border-[1.5px] border-card text-[8px] leading-none text-white not-italic ${n}`,children:t})]},e.login)}function y({children:e,className:t=``}){return(0,l.jsx)(`th`,{className:`border-b border-edge px-[18px] py-1.5 text-left text-[11px] font-medium tracking-[0.06em] text-faint uppercase ${t}`,children:e})}function b({children:e,className:t=``}){return(0,l.jsx)(`td`,{className:`border-b border-[#EEF0F3] px-[18px] py-2.5 align-top ${t}`,children:e})}function x(e){let t=(0,s.c)(10),{on:n,label:r,locked:i,title:a,onClick:o}=e,c=`relative inline-block h-[19px] w-[34px] shrink-0 rounded-full transition-colors ${n?`bg-ok`:`bg-[#D6DAE0]`} ${i?`opacity-55`:``}`,u=`absolute top-0.5 size-[15px] rounded-full bg-white shadow transition-all ${n?`left-[17px]`:`left-0.5`}`,d;t[0]===u?d=t[1]:(d=(0,l.jsx)(`span`,{className:u}),t[0]=u,t[1]=d);let f;return t[2]!==r||t[3]!==i||t[4]!==n||t[5]!==o||t[6]!==c||t[7]!==d||t[8]!==a?(f=(0,l.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":n,"aria-label":r,disabled:i,onClick:o,title:a,className:c,children:d}),t[2]=r,t[3]=i,t[4]=n,t[5]=o,t[6]=c,t[7]=d,t[8]=a,t[9]=f):f=t[9],f}function S({repo:e,pr:t,title:n,className:r=``}){return(0,l.jsxs)(`span`,{className:`flex min-w-0 items-baseline gap-2 ${r}`,children:[(0,l.jsx)(h,{repo:e,pr:t}),n&&(0,l.jsx)(`span`,{className:`min-w-0 truncate text-[12.5px] text-mut`,title:n,children:n})]})}export{f as a,u as c,y as d,x as f,g as i,p as l,_ as n,h as o,o as p,d as r,S as s,m as t,b as u}; \ No newline at end of file diff --git a/internal/serve/dist/assets/useOperation-BqNdl-Dh.js b/internal/serve/dist/assets/useOperation-BqNdl-Dh.js new file mode 100644 index 00000000..42424824 --- /dev/null +++ b/internal/serve/dist/assets/useOperation-BqNdl-Dh.js @@ -0,0 +1,41 @@ +import{S as e,_ as t,b as n,g as r,h as i,m as a,x as o,y as s}from"./index-DlC1aBiV.js";var c=t(`x`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]),l=e(o(),1),u=r(),d=Object.defineProperty,f=(e,t)=>d(e,`name`,{value:t,configurable:!0}),p=!!(typeof window<`u`&&window.document&&window.document.createElement);function m(e,t,{checkForDefaultPrevented:n=!0}={}){return f(function(r){if(e?.(r),n===!1||!r||!r.defaultPrevented)return t?.(r)},`handleEvent`)}f(m,`composeEventHandlers`);function h(e){if(!p)throw Error(`Cannot access window outside of the DOM`);return e?.ownerDocument?.defaultView??window}f(h,`getOwnerWindow`);function g(e){if(!p)throw Error(`Cannot access document outside of the DOM`);return e?.ownerDocument??document}f(g,`getOwnerDocument`);function ee(e,t=!1){let{activeElement:n}=g(e);if(!n?.nodeName)return null;if(_(n)&&n.contentDocument)return ee(n.contentDocument.body,t);if(t){let e=n.getAttribute(`aria-activedescendant`);if(e){let t=g(n).getElementById(e);if(t)return t}}return n}f(ee,`getActiveElement`);function _(e){return e.tagName===`IFRAME`}f(_,`isFrame`);var v=Object.defineProperty,y=(e,t)=>v(e,`name`,{value:t,configurable:!0});function b(e,t){if(typeof e==`function`)return e(t);e!=null&&(e.current=t)}y(b,`setRef`);function x(...e){return t=>{let n=!1,r=e.map(e=>{let r=b(e,t);return!n&&typeof r==`function`&&(n=!0),r});if(n)return()=>{for(let t=0;tw(e,`name`,{value:t,configurable:!0});function E(e,t){let n=l.createContext(t);n.displayName=e+`Context`;let r=T(e=>{let{children:t,...r}=e,i=l.useMemo(()=>r,Object.values(r));return(0,C.jsx)(n.Provider,{value:i,children:t})},`Provider`);r.displayName=e+`Provider`;function i(r,i={}){let{optional:a=!1}=i,o=l.useContext(n);if(o)return o;if(t!==void 0)return t;if(!a)throw Error(`\`${r}\` must be used within \`${e}\``)}return T(i,`useContext`),[r,i]}T(E,`createContext`);function D(e,t=[]){let n=[];function r(t,r){let i=l.createContext(r);i.displayName=t+`Context`;let a=n.length;n=[...n,r];let o=T(t=>{let{scope:n,children:r,...o}=t,s=n?.[e]?.[a]||i,c=l.useMemo(()=>o,Object.values(o));return(0,C.jsx)(s.Provider,{value:c,children:r})},`Provider`);o.displayName=t+`Provider`;function s(n,o,s={}){let{optional:c=!1}=s,u=o?.[e]?.[a]||i,d=l.useContext(u);if(d)return d;if(r!==void 0)return r;if(!c)throw Error(`\`${n}\` must be used within \`${t}\``)}return T(s,`useContext`),[o,s]}T(r,`createContext`);let i=T(()=>{let t=n.map(e=>l.createContext(e));return T(function(n){let r=n?.[e]||t;return l.useMemo(()=>({[`__scope${e}`]:{...n,[e]:r}}),[n,r])},`useScope`)},`createScope`);return i.scopeName=e,[r,O(i,...t)]}T(D,`createContextScope`);function O(...e){let t=e[0];if(e.length===1)return t;let n=T(()=>{let n=e.map(e=>({useScope:e(),scopeName:e.scopeName}));return T(function(e){let r=n.reduce((t,{useScope:n,scopeName:r})=>{let i=n(e)[`__scope${r}`];return{...t,...i}},{});return l.useMemo(()=>({[`__scope${t.scopeName}`]:r}),[r])},`useComposedScopes`)},`createScope`);return n.scopeName=t.scopeName,n}T(O,`composeContextScopes`);var k=globalThis?.document?l.useLayoutEffect:()=>{},A=Object.defineProperty,j=(e,t)=>A(e,`name`,{value:t,configurable:!0}),M=l.useId||(()=>void 0),N=0;function P(e){let[t,n]=l.useState(M());return k(()=>{e||n(e=>e??String(N++))},[e]),e||(t?`radix-${t}`:``)}j(P,`useId`);var F=Object.defineProperty,te=(e,t)=>F(e,`name`,{value:t,configurable:!0}),ne=l.useEffectEvent,re=l.useInsertionEffect;function ie(e){if(typeof ne==`function`)return ne(e);let t=l.useRef(()=>{throw Error(`Cannot call an event handler while rendering.`)});return typeof re==`function`?re(()=>{t.current=e}):k(()=>{t.current=e}),l.useMemo(()=>((...e)=>t.current?.(...e)),[])}te(ie,`useEffectEvent`);var I=Object.defineProperty,L=(e,t)=>I(e,`name`,{value:t,configurable:!0}),ae=l.useInsertionEffect||k;function oe({prop:e,defaultProp:t,onChange:n=L(()=>{},`onChange`),caller:r}){let[i,a,o]=R({defaultProp:t,onChange:n}),s=e!==void 0;return[s?e:i,l.useCallback(t=>{if(s){let n=se(t)?t(e):t;n!==e&&o.current?.(n)}else a(t)},[s,e,a,o])]}L(oe,`useControllableState`);function R({defaultProp:e,onChange:t}){let[n,r]=l.useState(e),i=l.useRef(n),a=l.useRef(t);return ae(()=>{a.current=t},[t]),l.useEffect(()=>{i.current!==n&&(a.current?.(n),i.current=n)},[n,i]),[n,r,a]}L(R,`useUncontrolledState`);function se(e){return typeof e==`function`}L(se,`isFunction`);var ce=Symbol(`RADIX:SYNC_STATE`);function le(e,t,n,r){let{prop:i,defaultProp:a,onChange:o,caller:s}=t,c=i!==void 0,u=ie(o),d=[{...n,state:a}];r&&d.push(r);let[f,p]=l.useReducer((t,n)=>{if(n.type===ce)return{...t,state:n.state};let r=e(t,n);return c&&!Object.is(r.state,t.state)&&u(r.state),r},...d),m=f.state,h=l.useRef(m);l.useEffect(()=>{h.current!==m&&(h.current=m,c||u(m))},[m,h,c]);let g=l.useMemo(()=>i===void 0?f:{...f,state:i},[f,i]);return l.useEffect(()=>{c&&!Object.is(i,f.state)&&p({type:ce,state:i})},[i,f.state,c]),[g,p]}L(le,`useControllableStateReducer`);var ue=e(s(),1),de=Object.defineProperty,z=(e,t)=>de(e,`name`,{value:t,configurable:!0});function fe(e){let t=l.forwardRef((t,n)=>{let{children:r,...i}=t,a=null,o=!1,s=[];be(r)&&typeof we==`function`&&(r=we(r._payload)),l.Children.forEach(r,e=>{if(ve(e)){o=!0;let t=e,n=`child`in t.props?t.props.child:t.props.children;be(n)&&typeof we==`function`&&(n=we(n._payload)),a=he(t,n),s.push(a?.props?.children)}else s.push(e)}),a?a=l.cloneElement(a,void 0,s):!o&&l.Children.count(r)===1&&l.isValidElement(r)&&(a=r);let c=a?_e(a):void 0,u=S(n,c);if(!a){if(r||r===0)throw Error(o?Ce(e):Se(e));return r}let d=ge(i,a.props??{});return a.type!==l.Fragment&&(d.ref=n?u:c),l.cloneElement(a,d)});return t.displayName=`${e}.Slot`,t}z(fe,`createSlot`);var pe=Symbol.for(`radix.slottable`);function me(e){let t=z(e=>`child`in e?e.children(e.child):e.children,`Slottable`);return t.displayName=`${e}.Slottable`,t.__radixId=pe,t}z(me,`createSlottable`);var he=z((e,t)=>{if(`child`in e.props){let t=e.props.child;return l.isValidElement(t)?l.cloneElement(t,void 0,e.props.children(t.props.children)):null}return l.isValidElement(t)?t:null},`getSlottableElementFromSlottable`);function ge(e,t){let n={...t};for(let r in t){let i=e[r],a=t[r];/^on[A-Z]/.test(r)?i&&a?n[r]=(...e)=>{let t=a(...e);return i(...e),t}:i&&(n[r]=i):r===`style`?n[r]={...i,...a}:r===`className`&&(n[r]=[i,a].filter(Boolean).join(` `))}return{...e,...n}}z(ge,`mergeProps`);function _e(e){let t=Object.getOwnPropertyDescriptor(e.props,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning;return n?e.ref:(t=Object.getOwnPropertyDescriptor(e,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning,n?e.props.ref:e.props.ref||e.ref)}z(_e,`getElementRef`);function ve(e){return l.isValidElement(e)&&typeof e.type==`function`&&`__radixId`in e.type&&e.type.__radixId===pe}z(ve,`isSlottable`);var ye=Symbol.for(`react.lazy`);function be(e){return typeof e==`object`&&!!e&&`$$typeof`in e&&e.$$typeof===ye&&`_payload`in e&&xe(e._payload)}z(be,`isLazyComponent`);function xe(e){return typeof e==`object`&&!!e&&`then`in e}z(xe,`isPromiseLike`);var Se=z(e=>`${e} failed to slot onto its children. Expected a single React element child or \`Slottable\`.`,`createSlotError`),Ce=z(e=>`${e} failed to slot onto its \`Slottable\`. Expected \`Slottable\` to receive a single React element child.`,`createSlottableError`),we=l.use,Te=Object.defineProperty,Ee=(e,t)=>Te(e,`name`,{value:t,configurable:!0}),De=[`a`,`button`,`div`,`form`,`h2`,`h3`,`img`,`input`,`label`,`li`,`nav`,`ol`,`p`,`select`,`span`,`svg`,`ul`].reduce((e,t)=>{let n=fe(`Primitive.${t}`),r=l.forwardRef((e,r)=>{let{asChild:i,...a}=e,o=i?n:t;return typeof window<`u`&&(window[Symbol.for(`radix-ui`)]=!0),(0,C.jsx)(o,{...a,ref:r})});return r.displayName=`Primitive.${t}`,{...e,[t]:r}},{});function Oe(e,t){e&&ue.flushSync(()=>e.dispatchEvent(t))}Ee(Oe,`dispatchDiscreteCustomEvent`);var ke=Object.defineProperty,Ae=(e,t)=>ke(e,`name`,{value:t,configurable:!0});function je(e){let t=l.useRef(e);return l.useEffect(()=>{t.current=e}),l.useMemo(()=>((...e)=>t.current?.(...e)),[])}Ae(je,`useCallbackRef`);var Me=Object.defineProperty,B=(e,t)=>Me(e,`name`,{value:t,configurable:!0}),Ne=`dismissableLayer.update`,Pe=`dismissableLayer.pointerDownOutside`,Fe=`dismissableLayer.focusOutside`,Ie,Le=l.createContext({layers:new Set,layersWithOutsidePointerEventsDisabled:new Set,branches:new Set,dismissableSurfaces:new Set}),Re=l.forwardRef(B(function(e,t){let{disableOutsidePointerEvents:n=!1,deferPointerDownOutside:r=!1,onEscapeKeyDown:i,onPointerDownOutside:a,onFocusOutside:o,onInteractOutside:s,onDismiss:c,...u}=e,d=l.useContext(Le),[f,p]=l.useState(null),h=f?.ownerDocument??globalThis?.document,[,g]=l.useState({}),ee=S(t,p),_=Array.from(d.layers),[v]=[...d.layersWithOutsidePointerEventsDisabled].slice(-1),y=v?_.indexOf(v):-1,b=f?_.indexOf(f):-1,x=d.layersWithOutsidePointerEventsDisabled.size>0,w=b>=y,T=l.useRef(!1),E=Ve(e=>{a?.(e),s?.(e),e.defaultPrevented||c?.()},{ownerDocument:h,deferPointerDownOutside:r,isDeferredPointerDownOutsideRef:T,dismissableSurfaces:d.dismissableSurfaces,shouldHandlePointerDownOutside:l.useCallback(e=>{if(!(e instanceof Node))return!1;let t=[...d.branches].some(t=>t.contains(e));return w&&!t},[d.branches,w])}),D=He(e=>{if(r&&T.current)return;let t=e.target;[...d.branches].some(e=>e.contains(t))||(o?.(e),s?.(e),e.defaultPrevented||c?.())},h),O=f?b===_.length-1:!1,k=je(e=>{e.key===`Escape`&&(i?.(e),!e.defaultPrevented&&c&&(e.preventDefault(),c()))});return l.useEffect(()=>{if(O)return h.addEventListener(`keydown`,k,{capture:!0}),()=>h.removeEventListener(`keydown`,k,{capture:!0})},[h,O,k]),l.useEffect(()=>{if(f)return n&&(d.layersWithOutsidePointerEventsDisabled.size===0&&(Ie=h.body.style.pointerEvents,h.body.style.pointerEvents=`none`),d.layersWithOutsidePointerEventsDisabled.add(f)),d.layers.add(f),Ue(),()=>{n&&(d.layersWithOutsidePointerEventsDisabled.delete(f),d.layersWithOutsidePointerEventsDisabled.size===0&&(h.body.style.pointerEvents=Ie))}},[f,h,n,d]),l.useEffect(()=>()=>{f&&(d.layers.delete(f),d.layersWithOutsidePointerEventsDisabled.delete(f),Ue())},[f,d]),l.useEffect(()=>{let e=B(()=>g({}),`handleUpdate`);return document.addEventListener(Ne,e),()=>document.removeEventListener(Ne,e)},[]),(0,C.jsx)(De.div,{...u,ref:ee,style:{pointerEvents:x?w?`auto`:`none`:void 0,...e.style},onFocusCapture:m(e.onFocusCapture,D.onFocusCapture),onBlurCapture:m(e.onBlurCapture,D.onBlurCapture),onPointerDownCapture:m(e.onPointerDownCapture,E.onPointerDownCapture)})},`DismissableLayer`));function ze(){let e=l.useContext(Le),[t,n]=l.useState(null);return l.useEffect(()=>{if(t)return e.dismissableSurfaces.add(t),()=>{e.dismissableSurfaces.delete(t)}},[t,e.dismissableSurfaces]),n}B(ze,`useDismissableLayerSurface`);var Be=B(()=>!0,`IS_TRUE`);function Ve(e,t){let{ownerDocument:n=globalThis?.document,deferPointerDownOutside:r=!1,isDeferredPointerDownOutsideRef:i,dismissableSurfaces:a,shouldHandlePointerDownOutside:o=Be}=t,s=je(e),c=l.useRef(!1),u=l.useRef(!1),d=l.useRef(new Map),f=l.useRef(()=>{});return l.useEffect(()=>{function e(){u.current=!1,i.current=!1,d.current.clear()}B(e,`resetOutsideInteraction`);function t(){return Array.from(d.current.values()).some(Boolean)}B(t,`isOutsideInteractionIntercepted`);function l(e){if(!u.current)return;let t=e.target;t instanceof Node&&[...a].some(e=>e.contains(t))||d.current.set(e.type,!0),e.type===`click`&&window.setTimeout(()=>{u.current&&f.current()},0)}B(l,`handleInteractionCapture`);function p(e){u.current&&d.current.set(e.type,!1)}B(p,`handleInteractionBubble`);let m=B(a=>{if(a.target&&!c.current){let l=function(){n.removeEventListener(`click`,f.current);let r=t();e(),r||We(Pe,s,p,{discrete:!0})};if(B(l,`handleAndDispatchPointerDownOutsideEvent`),!o(a.target)){n.removeEventListener(`click`,f.current),e(),c.current=!1;return}let p={originalEvent:a};u.current=!0,i.current=r&&a.button===0,d.current.clear(),!r||a.button!==0?l():(n.removeEventListener(`click`,f.current),f.current=l,n.addEventListener(`click`,f.current,{once:!0}))}else n.removeEventListener(`click`,f.current),e();c.current=!1},`handlePointerDown`),h=[`pointerup`,`mousedown`,`mouseup`,`touchstart`,`touchend`,`click`];for(let e of h)n.addEventListener(e,l,!0),n.addEventListener(e,p);let g=window.setTimeout(()=>{n.addEventListener(`pointerdown`,m)},0);return()=>{window.clearTimeout(g),n.removeEventListener(`pointerdown`,m),n.removeEventListener(`click`,f.current);for(let e of h)n.removeEventListener(e,l,!0),n.removeEventListener(e,p)}},[n,s,r,i,a,o]),{onPointerDownCapture:B(()=>c.current=!0,`onPointerDownCapture`)}}B(Ve,`usePointerDownOutside`);function He(e,t=globalThis?.document){let n=je(e),r=l.useRef(!1);return l.useEffect(()=>{let e=B(e=>{e.target&&!r.current&&We(Fe,n,{originalEvent:e},{discrete:!1})},`handleFocus`);return t.addEventListener(`focusin`,e),()=>t.removeEventListener(`focusin`,e)},[t,n]),{onFocusCapture:B(()=>r.current=!0,`onFocusCapture`),onBlurCapture:B(()=>r.current=!1,`onBlurCapture`)}}B(He,`useFocusOutside`);function Ue(){let e=new CustomEvent(Ne);document.dispatchEvent(e)}B(Ue,`dispatchUpdate`);function We(e,t,n,{discrete:r}){let i=n.originalEvent.target,a=new CustomEvent(e,{bubbles:!1,cancelable:!0,detail:n});t&&i.addEventListener(e,t,{once:!0}),r?Oe(i,a):i.dispatchEvent(a)}B(We,`handleAndDispatchCustomEvent`);var Ge=Object.defineProperty,V=(e,t)=>Ge(e,`name`,{value:t,configurable:!0}),Ke=`focusScope.autoFocusOnMount`,qe=`focusScope.autoFocusOnUnmount`,Je={bubbles:!1,cancelable:!0},Ye=l.forwardRef(V(function(e,t){let{loop:n=!1,trapped:r=!1,onMountAutoFocus:i,onUnmountAutoFocus:a,...o}=e,[s,c]=l.useState(null),u=je(i),d=je(a),f=l.useRef(null),p=S(t,c),m=l.useRef({paused:!1,pause(){this.paused=!0},resume(){this.paused=!1}}).current;l.useEffect(()=>{if(r){let e=function(e){if(m.paused||!s)return;let t=e.target;s.contains(t)?f.current=t:H(f.current,{select:!0})},t=function(e){if(m.paused||!s)return;let t=e.relatedTarget;t!==null&&(s.contains(t)||H(f.current,{select:!0}))},n=function(e){if(document.activeElement===document.body)for(let t of e)t.removedNodes.length>0&&H(s)};V(e,`handleFocusIn`),V(t,`handleFocusOut`),V(n,`handleMutations`),document.addEventListener(`focusin`,e),document.addEventListener(`focusout`,t);let r=new MutationObserver(n);return s&&r.observe(s,{childList:!0,subtree:!0}),()=>{document.removeEventListener(`focusin`,e),document.removeEventListener(`focusout`,t),r.disconnect()}}},[r,s,m.paused]),l.useEffect(()=>{if(s){nt.add(m);let e=document.activeElement;if(!s.contains(e)){let t=new CustomEvent(Ke,Je);s.addEventListener(Ke,u),s.dispatchEvent(t),t.defaultPrevented||(Xe(at(Qe(s)),{select:!0}),document.activeElement===e&&H(s))}return()=>{s.removeEventListener(Ke,u),setTimeout(()=>{let t=new CustomEvent(qe,Je);s.addEventListener(qe,d),s.dispatchEvent(t),t.defaultPrevented||H(e??document.body,{select:!0}),s.removeEventListener(qe,d),nt.remove(m)},0)}}},[s,u,d,m]);let h=l.useCallback(e=>{if(!n&&!r||m.paused)return;let t=e.key===`Tab`&&!e.altKey&&!e.ctrlKey&&!e.metaKey,i=document.activeElement;if(t&&i){let t=e.currentTarget,[r,a]=Ze(t);r&&a?!e.shiftKey&&i===a?(e.preventDefault(),n&&H(r,{select:!0})):e.shiftKey&&i===r&&(e.preventDefault(),n&&H(a,{select:!0})):i===t&&e.preventDefault()}},[n,r,m.paused]);return(0,C.jsx)(De.div,{tabIndex:-1,...o,ref:p,onKeyDown:h})},`FocusScope`));function Xe(e,{select:t=!1}={}){let n=document.activeElement;for(let r of e)if(H(r,{select:t}),document.activeElement!==n)return}V(Xe,`focusFirst`);function Ze(e){let t=Qe(e);return[$e(t,e),$e(t.reverse(),e)]}V(Ze,`getTabbableEdges`);function Qe(e){let t=[],n=document.createTreeWalker(e,NodeFilter.SHOW_ELEMENT,{acceptNode:V(e=>{let t=e.tagName===`INPUT`&&e.type===`hidden`;return e.disabled||e.hidden||t?NodeFilter.FILTER_SKIP:e.tabIndex>=0?NodeFilter.FILTER_ACCEPT:NodeFilter.FILTER_SKIP},`acceptNode`)});for(;n.nextNode();)t.push(n.currentNode);return t}V(Qe,`getTabbableCandidates`);function $e(e,t){let n=typeof t.checkVisibility==`function`&&t.checkVisibility({checkVisibilityCSS:!0});for(let r of e)if(!(n?!r.checkVisibility({checkVisibilityCSS:!0}):et(r,{upTo:t})))return r}V($e,`findVisible`);function et(e,{upTo:t}){if(getComputedStyle(e).visibility===`hidden`)return!0;for(;e;){if(t!==void 0&&e===t)return!1;if(getComputedStyle(e).display===`none`)return!0;e=e.parentElement}return!1}V(et,`isHidden`);function tt(e){return e instanceof HTMLInputElement&&`select`in e}V(tt,`isSelectableInput`);function H(e,{select:t=!1}={}){if(e&&e.focus){let n=document.activeElement;e.focus({preventScroll:!0}),e!==n&&tt(e)&&t&&e.select()}}V(H,`focus`);var nt=rt();function rt(){let e=[];return{add(t){let n=e[0];t!==n&&n?.pause(),e=it(e,t),e.unshift(t)},remove(t){e=it(e,t),e[0]?.resume()}}}V(rt,`createFocusScopesStack`);function it(e,t){let n=[...e],r=n.indexOf(t);return r!==-1&&n.splice(r,1),n}V(it,`arrayRemove`);function at(e){return e.filter(e=>e.tagName!==`A`)}V(at,`removeLinks`);var ot=Object.defineProperty,st=l.forwardRef(((e,t)=>ot(e,`name`,{value:t,configurable:!0}))(function(e,t){let{container:n,...r}=e,[i,a]=l.useState(!1);k(()=>a(!0),[]);let o=n||i&&globalThis?.document?.body;return o?ue.createPortal((0,C.jsx)(De.div,{...r,ref:t}),o):null},`Portal`)),ct=Object.defineProperty,U=(e,t)=>ct(e,`name`,{value:t,configurable:!0});function lt(e,t){return l.useReducer((e,n)=>t[e][n]??e,e)}U(lt,`useStateMachine`);var ut=U(e=>{let{present:t,children:n}=e,r=dt(t),i=typeof n==`function`?n({present:r.isPresent}):l.Children.only(n),a=pt(r.ref,ht(i));return typeof n==`function`||r.isPresent?l.cloneElement(i,{ref:a}):null},`Presence`);function dt(e){let[t,n]=l.useState(),r=l.useRef(null),i=l.useRef(e),a=l.useRef(`none`),o=l.useRef(void 0),[s,c]=lt(e?`mounted`:`unmounted`,{mounted:{UNMOUNT:`unmounted`,ANIMATION_OUT:`unmountSuspended`},unmountSuspended:{MOUNT:`mounted`,ANIMATION_END:`unmounted`},unmounted:{MOUNT:`mounted`}});return l.useEffect(()=>{s===`mounted`?(a.current=o.current??mt(r.current),o.current=void 0):a.current=`none`},[s]),k(()=>{let t=r.current,n=i.current;if(n!==e){let r=a.current,s=mt(t);e?(o.current=s,c(`MOUNT`)):s===`none`||t?.display===`none`?c(`UNMOUNT`):c(n&&r!==s?`ANIMATION_OUT`:`UNMOUNT`),i.current=e}},[e,c]),k(()=>{if(t){let e,n=t.ownerDocument.defaultView??window,o=U(a=>{let o=mt(r.current).includes(CSS.escape(a.animationName));if(a.target===t&&o&&(c(`ANIMATION_END`),!i.current)){let r=t.style.animationFillMode;t.style.animationFillMode=`forwards`,e=n.setTimeout(()=>{t.style.animationFillMode===`forwards`&&(t.style.animationFillMode=r)})}},`handleAnimationEnd`),s=U(e=>{e.target===t&&(a.current=mt(r.current))},`handleAnimationStart`);return t.addEventListener(`animationstart`,s),t.addEventListener(`animationcancel`,o),t.addEventListener(`animationend`,o),()=>{n.clearTimeout(e),t.removeEventListener(`animationstart`,s),t.removeEventListener(`animationcancel`,o),t.removeEventListener(`animationend`,o)}}else c(`ANIMATION_END`)},[t,c]),{isPresent:[`mounted`,`unmountSuspended`].includes(s),ref:l.useCallback(e=>{if(e){let t=getComputedStyle(e);r.current=t,o.current=mt(t)}else r.current=null;n(e)},[])}}U(dt,`usePresence`);function ft(e,t){if(typeof e==`function`)return e(t);e!=null&&(e.current=t)}U(ft,`setRef`);function pt(...e){let t=l.useRef(e);return t.current=e,l.useCallback(e=>{let n=t.current,r=!1,i=n.map(t=>{let n=ft(t,e);return!r&&typeof n==`function`&&(r=!0),n});if(r)return()=>{for(let e=0;egt(e,`name`,{value:t,configurable:!0}),vt=0,yt=null;function bt(e){return xt(),e.children}_t(bt,`FocusGuards`);function xt(){l.useEffect(()=>{yt||={start:St(),end:St()};let{start:e,end:t}=yt;return document.body.firstElementChild!==e&&document.body.insertAdjacentElement(`afterbegin`,e),document.body.lastElementChild!==t&&document.body.insertAdjacentElement(`beforeend`,t),vt++,()=>{vt===1&&(yt?.start.remove(),yt?.end.remove(),yt=null),vt=Math.max(0,vt-1)}},[])}_t(xt,`useFocusGuards`);function St(){let e=document.createElement(`span`);return e.setAttribute(`data-radix-focus-guard`,``),e.tabIndex=0,e.style.outline=`none`,e.style.opacity=`0`,e.style.position=`fixed`,e.style.pointerEvents=`none`,e}_t(St,`createFocusGuard`);var W=function(){return W=Object.assign||function(e){for(var t,n=1,r=arguments.length;n`u`)return Xt;var t=Qt(e),n=document.documentElement.clientWidth,r=window.innerWidth;return{left:t[0],top:t[1],right:t[2],gap:Math.max(0,r-n+t[2]-t[0])}},en=Yt(),tn=`data-scroll-locked`,nn=function(e,t,n,r){var i=e.left,a=e.top,o=e.right,s=e.gap;return n===void 0&&(n=`margin`),` + .${Dt} { + overflow: hidden ${r}; + padding-right: ${s}px ${r}; + } + body[${tn}] { + overflow: hidden ${r}; + overscroll-behavior: contain; + ${[t&&`position: relative ${r};`,n===`margin`&&` + padding-left: ${i}px; + padding-top: ${a}px; + padding-right: ${o}px; + margin-left:0; + margin-top:0; + margin-right: ${s}px ${r}; + `,n===`padding`&&`padding-right: ${s}px ${r};`].filter(Boolean).join(``)} + } + + .${Tt} { + right: ${s}px ${r}; + } + + .${Et} { + margin-right: ${s}px ${r}; + } + + .${Tt} .${Tt} { + right: 0 ${r}; + } + + .${Et} .${Et} { + margin-right: 0 ${r}; + } + + body[${tn}] { + ${Ot}: ${s}px; + } +`},rn=function(){var e=parseInt(document.body.getAttribute(`data-scroll-locked`)||`0`,10);return isFinite(e)?e:0},an=function(){l.useEffect(function(){return document.body.setAttribute(tn,(rn()+1).toString()),function(){var e=rn()-1;e<=0?document.body.removeAttribute(tn):document.body.setAttribute(tn,e.toString())}},[])},on=function(e){var t=e.noRelative,n=e.noImportant,r=e.gapMode,i=r===void 0?`margin`:r;an();var a=l.useMemo(function(){return $t(i)},[i]);return l.createElement(en,{styles:nn(a,!t,i,n?``:`!important`)})},sn=!1;if(typeof window<`u`)try{var cn=Object.defineProperty({},"passive",{get:function(){return sn=!0,!0}});window.addEventListener(`test`,cn,cn),window.removeEventListener(`test`,cn,cn)}catch{sn=!1}var ln=sn?{passive:!1}:!1,un=function(e){return e.tagName===`TEXTAREA`},dn=function(e,t){if(!(e instanceof Element))return!1;var n=window.getComputedStyle(e);return n[t]!==`hidden`&&!(n.overflowY===n.overflowX&&!un(e)&&n[t]===`visible`)},fn=function(e){return dn(e,`overflowY`)},pn=function(e){return dn(e,`overflowX`)},mn=function(e,t){var n=t.ownerDocument,r=t;do{if(typeof ShadowRoot<`u`&&r instanceof ShadowRoot&&(r=r.host),_n(e,r)){var i=vn(e,r);if(i[1]>i[2])return!0}r=r.parentNode}while(r&&r!==n.body);return!1},hn=function(e){return[e.scrollTop,e.scrollHeight,e.clientHeight]},gn=function(e){return[e.scrollLeft,e.scrollWidth,e.clientWidth]},_n=function(e,t){return e===`v`?fn(t):pn(t)},vn=function(e,t){return e===`v`?hn(t):gn(t)},yn=function(e,t){return e===`h`&&t===`rtl`?-1:1},bn=function(e,t,n,r,i){var a=yn(e,window.getComputedStyle(t).direction),o=a*r,s=n.target,c=t.contains(s),l=!1,u=o>0,d=0,f=0;do{if(!s)break;var p=vn(e,s),m=p[0],h=p[1]-p[2]-a*m;(m||h)&&_n(e,s)&&(d+=h,f+=m);var g=s.parentNode;s=g&&g.nodeType===Node.DOCUMENT_FRAGMENT_NODE?g.host:g}while(!c&&s!==document.body||c&&(t.contains(s)||t===s));return(u&&(i&&Math.abs(d)<1||!i&&o>d)||!u&&(i&&Math.abs(f)<1||!i&&-o>f))&&(l=!0),l},xn=function(e){return`changedTouches`in e?[e.changedTouches[0].clientX,e.changedTouches[0].clientY]:[0,0]},Sn=function(e){return[e.deltaX,e.deltaY]},Cn=function(e){return e&&`current`in e?e.current:e},wn=function(e,t){return e[0]===t[0]&&e[1]===t[1]},Tn=function(e){return` + .block-interactivity-${e} {pointer-events: none;} + .allow-interactivity-${e} {pointer-events: all;} +`},En=0,Dn=[];function On(e){var t=l.useRef([]),n=l.useRef([0,0]),r=l.useRef(),i=l.useState(En++)[0],a=l.useState(Yt)[0],o=l.useRef(e);l.useEffect(function(){o.current=e},[e]),l.useEffect(function(){if(e.inert){document.body.classList.add(`block-interactivity-${i}`);var t=wt([e.lockRef.current],(e.shards||[]).map(Cn),!0).filter(Boolean);return t.forEach(function(e){return e.classList.add(`allow-interactivity-${i}`)}),function(){document.body.classList.remove(`block-interactivity-${i}`),t.forEach(function(e){return e.classList.remove(`allow-interactivity-${i}`)})}}},[e.inert,e.lockRef.current,e.shards]);var s=l.useCallback(function(e,t){if(`touches`in e&&e.touches.length===2||e.type===`wheel`&&e.ctrlKey)return!o.current.allowPinchZoom;var i=xn(e),a=n.current,s=`deltaX`in e?e.deltaX:a[0]-i[0],c=`deltaY`in e?e.deltaY:a[1]-i[1],l,u=e.target,d=Math.abs(s)>Math.abs(c)?`h`:`v`;if(`touches`in e&&d===`h`&&u.type===`range`)return!1;var f=window.getSelection(),p=f&&f.anchorNode;if(p&&(p===u||p.contains(u)))return!1;var m=mn(d,u);if(!m)return!0;if(m?l=d:(l=d===`v`?`h`:`v`,m=mn(d,u)),!m)return!1;if(!r.current&&`changedTouches`in e&&(s||c)&&(r.current=l),!l)return!0;var h=r.current||l;return bn(h,t,e,h===`h`?s:c,!0)},[]),c=l.useCallback(function(e){var n=e;if(!(!Dn.length||Dn[Dn.length-1]!==a)){var r=`deltaY`in n?Sn(n):xn(n),i=t.current.filter(function(e){return e.name===n.type&&(e.target===n.target||n.target===e.shadowParent)&&wn(e.delta,r)})[0];if(i&&i.should){n.cancelable&&n.preventDefault();return}if(!i){var c=(o.current.shards||[]).map(Cn).filter(Boolean).filter(function(e){return e.contains(n.target)});(c.length>0?s(n,c[0]):!o.current.noIsolation)&&n.cancelable&&n.preventDefault()}}},[]),u=l.useCallback(function(e,n,r,i){var a={name:e,delta:n,target:r,should:i,shadowParent:kn(r)};t.current.push(a),setTimeout(function(){t.current=t.current.filter(function(e){return e!==a})},1)},[]),d=l.useCallback(function(e){n.current=xn(e),r.current=void 0},[]),f=l.useCallback(function(t){u(t.type,Sn(t),t.target,s(t,e.lockRef.current))},[]),p=l.useCallback(function(t){u(t.type,xn(t),t.target,s(t,e.lockRef.current))},[]);l.useEffect(function(){return Dn.push(a),e.setCallbacks({onScrollCapture:f,onWheelCapture:f,onTouchMoveCapture:p}),document.addEventListener(`wheel`,c,ln),document.addEventListener(`touchmove`,c,ln),document.addEventListener(`touchstart`,d,ln),function(){Dn=Dn.filter(function(e){return e!==a}),document.removeEventListener(`wheel`,c,ln),document.removeEventListener(`touchmove`,c,ln),document.removeEventListener(`touchstart`,d,ln)}},[]);var m=e.removeScrollBar,h=e.inert;return l.createElement(l.Fragment,null,h?l.createElement(a,{styles:Tn(i)}):null,m?l.createElement(on,{noRelative:e.noRelative,gapMode:e.gapMode}):null)}function kn(e){for(var t=null;e!==null;)e instanceof ShadowRoot&&(t=e.host,e=e.host),e=e.parentNode;return t}var An=Rt(zt,On),jn=l.forwardRef(function(e,t){return l.createElement(Vt,W({},e,{ref:t,sideCar:An}))});jn.classNames=Vt.classNames;var Mn=function(e){return typeof document>`u`?null:(Array.isArray(e)?e[0]:e).ownerDocument.body},Nn=new WeakMap,Pn=new WeakMap,Fn={},In=0,Ln=function(e){return e&&(e.host||Ln(e.parentNode))},Rn=function(e,t){return t.map(function(t){if(e.contains(t))return t;var n=Ln(t);return n&&e.contains(n)?n:(console.error(`aria-hidden`,t,`in not contained inside`,e,`. Doing nothing`),null)}).filter(function(e){return!!e})},zn=function(e,t,n,r){var i=Rn(t,Array.isArray(e)?e:[e]);Fn[n]||(Fn[n]=new WeakMap);var a=Fn[n],o=[],s=new Set,c=new Set(i),l=function(e){!e||s.has(e)||(s.add(e),l(e.parentNode))};i.forEach(l);var u=function(e){!e||c.has(e)||Array.prototype.forEach.call(e.children,function(e){if(s.has(e))u(e);else try{var t=e.getAttribute(r),i=t!==null&&t!==`false`,c=(Nn.get(e)||0)+1,l=(a.get(e)||0)+1;Nn.set(e,c),a.set(e,l),o.push(e),c===1&&i&&Pn.set(e,!0),l===1&&e.setAttribute(n,`true`),i||e.setAttribute(r,`true`)}catch(t){console.error(`aria-hidden: cannot operate on `,e,t)}})};return u(t),s.clear(),In++,function(){o.forEach(function(e){var t=Nn.get(e)-1,i=a.get(e)-1;Nn.set(e,t),a.set(e,i),t||(Pn.has(e)||e.removeAttribute(r),Pn.delete(e)),i||e.removeAttribute(n)}),In--,In||(Nn=new WeakMap,Nn=new WeakMap,Pn=new WeakMap,Fn={})}},Bn=function(e,t,n){n===void 0&&(n=`data-aria-hidden`);var r=Array.from(Array.isArray(e)?e:[e]),i=t||Mn(e);return i?(r.push.apply(r,Array.from(i.querySelectorAll(`[aria-live], script`))),zn(r,i,n,`aria-hidden`)):function(){return null}},Vn=Object.defineProperty,G=(e,t)=>Vn(e,`name`,{value:t,configurable:!0}),Hn=`Dialog`,[Un,Wn]=D(Hn),[Gn,K]=Un(Hn),Kn=G(e=>{let{__scopeDialog:t,children:n,open:r,defaultOpen:i,onOpenChange:a,modal:o=!0}=e,s=l.useRef(null),c=l.useRef(null),[u,d]=oe({prop:r,defaultProp:i??!1,onChange:a,caller:Hn}),[f,p]=l.useState(0),[m,h]=l.useState(0);return(0,C.jsx)(Gn,{scope:t,triggerRef:s,contentRef:c,contentId:P(),titleId:P(),descriptionId:P(),titlePresent:f>0,descriptionPresent:m>0,setTitleCount:p,setDescriptionCount:h,open:u,onOpenChange:d,onOpenToggle:l.useCallback(()=>d(e=>!e),[d]),modal:o,children:n})},`Dialog`),qn=`DialogPortal`,[Jn,Yn]=Un(qn,{forceMount:void 0}),Xn=G(e=>{let{__scopeDialog:t,forceMount:n,children:r,container:i}=e,a=K(qn,t);return(0,C.jsx)(Jn,{scope:t,forceMount:n,children:l.Children.map(r,e=>(0,C.jsx)(ut,{present:n||a.open,children:(0,C.jsx)(st,{asChild:!0,container:i,children:e})}))})},`DialogPortal`),Zn=`DialogOverlay`,Qn=l.forwardRef(G(function(e,t){let n=Yn(Zn,e.__scopeDialog),{forceMount:r=n.forceMount,...i}=e,a=K(Zn,e.__scopeDialog);return a.modal?(0,C.jsx)(ut,{present:r||a.open,children:(0,C.jsx)(er,{...i,ref:t})}):null},`DialogOverlay`)),$n=fe(`DialogOverlay.RemoveScroll`),er=l.forwardRef(G(function(e,t){let{__scopeDialog:n,...r}=e,i=K(Zn,n),a=S(t,ze());return(0,C.jsx)(jn,{as:$n,allowPinchZoom:!0,shards:[i.contentRef],children:(0,C.jsx)(De.div,{"data-state":fr(i.open),...r,ref:a,style:{pointerEvents:`auto`,...r.style}})})},`DialogOverlayImpl`)),tr=`DialogContent`,nr=l.forwardRef(G(function(e,t){let n=Yn(tr,e.__scopeDialog),{forceMount:r=n.forceMount,...i}=e,a=K(tr,e.__scopeDialog);return(0,C.jsx)(ut,{present:r||a.open,children:a.modal?(0,C.jsx)(rr,{...i,ref:t}):(0,C.jsx)(ir,{...i,ref:t})})},`DialogContent`)),rr=l.forwardRef(G(function(e,t){let n=K(tr,e.__scopeDialog),r=l.useRef(null),i=S(t,n.contentRef,r);return l.useEffect(()=>{let e=r.current;if(e)return Bn(e)},[]),(0,C.jsx)(ar,{...e,ref:i,trapFocus:n.open,disableOutsidePointerEvents:n.open,onCloseAutoFocus:m(e.onCloseAutoFocus,e=>{e.preventDefault(),n.triggerRef.current?.focus()}),onPointerDownOutside:m(e.onPointerDownOutside,e=>{let t=e.detail.originalEvent,n=t.button===0&&t.ctrlKey===!0;(t.button===2||n)&&e.preventDefault()}),onFocusOutside:m(e.onFocusOutside,e=>e.preventDefault())})},`DialogContentModal`)),ir=l.forwardRef(G(function(e,t){let n=K(tr,e.__scopeDialog),r=l.useRef(!1),i=l.useRef(!1);return(0,C.jsx)(ar,{...e,ref:t,trapFocus:!1,disableOutsidePointerEvents:!1,onCloseAutoFocus:t=>{e.onCloseAutoFocus?.(t),t.defaultPrevented||(r.current||n.triggerRef.current?.focus(),t.preventDefault()),r.current=!1,i.current=!1},onInteractOutside:t=>{e.onInteractOutside?.(t),t.defaultPrevented||(r.current=!0,t.detail.originalEvent.type===`pointerdown`&&(i.current=!0));let a=t.target;n.triggerRef.current?.contains(a)&&t.preventDefault(),t.detail.originalEvent.type===`focusin`&&i.current&&t.preventDefault()}})},`DialogContentNonModal`)),ar=l.forwardRef(G(function(e,t){let{__scopeDialog:n,trapFocus:r,onOpenAutoFocus:i,onCloseAutoFocus:a,...o}=e,s=K(tr,n);return xt(),(0,C.jsx)(C.Fragment,{children:(0,C.jsx)(Ye,{asChild:!0,loop:!0,trapped:r,onMountAutoFocus:i,onUnmountAutoFocus:a,children:(0,C.jsx)(Re,{role:`dialog`,id:s.contentId,"aria-describedby":s.descriptionPresent?s.descriptionId:void 0,"aria-labelledby":s.titlePresent?s.titleId:void 0,"data-state":fr(s.open),...o,ref:t,deferPointerDownOutside:!0,onDismiss:()=>s.onOpenChange(!1)})})})},`DialogContentImpl`)),or=`DialogTitle`,sr=l.forwardRef(G(function(e,t){let{__scopeDialog:n,...r}=e,i=K(or,n),{setTitleCount:a}=i;return k(()=>(a(e=>e+1),()=>a(e=>e-1)),[a]),(0,C.jsx)(De.h2,{id:i.titleId,...r,ref:t})},`DialogTitle`)),cr=`DialogDescription`,lr=l.forwardRef(G(function(e,t){let{__scopeDialog:n,...r}=e,i=K(cr,n),{setDescriptionCount:a}=i;return k(()=>(a(e=>e+1),()=>a(e=>e-1)),[a]),(0,C.jsx)(De.p,{id:i.descriptionId,...r,ref:t})},`DialogDescription`)),ur=`DialogClose`,dr=l.forwardRef(G(function(e,t){let{__scopeDialog:n,...r}=e,i=K(ur,n);return(0,C.jsx)(De.button,{type:`button`,...r,ref:t,onClick:m(e.onClick,()=>i.onOpenChange(!1))})},`DialogClose`));function fr(e){return e?`open`:`closed`}G(fr,`getState`);function pr(e){var t,n,r=``;if(typeof e==`string`||typeof e==`number`)r+=e;else if(typeof e==`object`)if(Array.isArray(e)){var i=e.length;for(t=0;t{let n=Array(e.length+t.length);for(let t=0;t({classGroupId:e,validator:t}),_r=(e=new Map,t=null,n)=>({nextPart:e,validators:t,classGroupId:n}),vr=`-`,yr=[],br=`arbitrary..`,xr=e=>{let t=wr(e),{conflictingClassGroups:n,conflictingClassGroupModifiers:r}=e;return{getClassGroupId:e=>{if(e.startsWith(`[`)&&e.endsWith(`]`))return Cr(e);let n=e.split(vr);return Sr(n,+(n[0]===``&&n.length>1),t)},getConflictingClassGroupIds:(e,t)=>{if(t){let t=r[e],i=n[e];return t?i?hr(i,t):t:i||yr}return n[e]||yr}}},Sr=(e,t,n)=>{if(e.length-t===0)return n.classGroupId;let r=e[t],i=n.nextPart.get(r);if(i){let n=Sr(e,t+1,i);if(n)return n}let a=n.validators;if(a===null)return;let o=t===0?e.join(vr):e.slice(t).join(vr),s=a.length;for(let e=0;ee.slice(1,-1).indexOf(`:`)===-1?void 0:(()=>{let t=e.slice(1,-1),n=t.indexOf(`:`),r=t.slice(0,n);return r?br+r:void 0})(),wr=e=>{let{theme:t,classGroups:n}=e;return Tr(n,t)},Tr=(e,t)=>{let n=_r();for(let r in e){let i=e[r];Er(i,n,r,t)}return n},Er=(e,t,n,r)=>{let i=e.length;for(let a=0;a{if(typeof e==`string`){Or(e,t,n);return}if(typeof e==`function`){kr(e,t,n,r);return}Ar(e,t,n,r)},Or=(e,t,n)=>{let r=e===``?t:jr(t,e);r.classGroupId=n},kr=(e,t,n,r)=>{if(Mr(e)){Er(e(r),t,n,r);return}t.validators===null&&(t.validators=[]),t.validators.push(gr(n,e))},Ar=(e,t,n,r)=>{let i=Object.entries(e),a=i.length;for(let e=0;e{let n=e,r=t.split(vr),i=r.length;for(let e=0;e`isThemeGetter`in e&&e.isThemeGetter===!0,Nr=e=>{if(e<1)return{get:()=>void 0,set:()=>{}};let t=0,n=Object.create(null),r=Object.create(null),i=(i,a)=>{n[i]=a,t++,t>e&&(t=0,r=n,n=Object.create(null))};return{get(e){let t=n[e];if(t!==void 0)return t;if((t=r[e])!==void 0)return i(e,t),t},set(e,t){e in n?n[e]=t:i(e,t)}}},Pr=`!`,Fr=`:`,Ir=[],Lr=(e,t,n,r,i)=>({modifiers:e,hasImportantModifier:t,baseClassName:n,maybePostfixModifierPosition:r,isExternal:i}),Rr=e=>{let{prefix:t,experimentalParseClassName:n}=e,r=e=>{let t=[],n=0,r=0,i=0,a,o=e.length;for(let s=0;si?a-i:void 0;return Lr(t,l,c,u)};if(t){let e=t+Fr,n=r;r=t=>t.startsWith(e)?n(t.slice(e.length)):Lr(Ir,!1,t,void 0,!0)}if(n){let e=r;r=t=>n({className:t,parseClassName:e})}return r},zr=e=>{let t=new Map;return e.orderSensitiveModifiers.forEach((e,n)=>{t.set(e,1e6+n)}),e=>{let n=[],r=[];for(let i=0;i0&&(r.sort(),n.push(...r),r=[]),n.push(a)):r.push(a)}return r.length>0&&(r.sort(),n.push(...r)),n}},Br=e=>({cache:Nr(e.cacheSize),parseClassName:Rr(e),sortModifiers:zr(e),postfixLookupClassGroupIds:Vr(e),...xr(e)}),Vr=e=>{let t=Object.create(null),n=e.postfixLookupClassGroups;if(n)for(let e=0;e{let{parseClassName:n,getClassGroupId:r,getConflictingClassGroupIds:i,sortModifiers:a,postfixLookupClassGroupIds:o}=t,s=[],c=e.trim().split(Hr),l=``;for(let e=c.length-1;e>=0;--e){let t=c[e],{isExternal:u,modifiers:d,hasImportantModifier:f,baseClassName:p,maybePostfixModifierPosition:m}=n(t);if(u){l=t+(l.length>0?` `+l:l);continue}let h=!!m,g;if(h){g=r(p.substring(0,m));let e=g&&o[g]?r(p):void 0;e&&e!==g&&(g=e,h=!1)}else g=r(p);if(!g){if(!h){l=t+(l.length>0?` `+l:l);continue}if(g=r(p),!g){l=t+(l.length>0?` `+l:l);continue}h=!1}let ee=d.length===0?``:d.length===1?d[0]:a(d).join(`:`),_=f?ee+Pr:ee,v=_+g;if(s.indexOf(v)>-1)continue;s.push(v);let y=i(g,h);for(let e=0;e0?` `+l:l)}return l},Wr=(...e)=>{let t=0,n,r,i=``;for(;t{if(typeof e==`string`)return e;let t,n=``;for(let r=0;r{let n,r,i,a,o=o=>(n=Br(t.reduce((e,t)=>t(e),e())),r=n.cache.get,i=n.cache.set,a=s,s(o)),s=e=>{let t=r(e);if(t)return t;let a=Ur(e,n);return i(e,a),a};return a=o,(...e)=>a(Wr(...e))},qr=[],q=e=>{let t=t=>t[e]||qr;return t.isThemeGetter=!0,t},Jr=/^\[(?:(\w[\w-]*):)?(.+)\]$/i,Yr=/^\((?:(\w[\w-]*):)?(.+)\)$/i,Xr=/^\d+(?:\.\d+)?\/\d+(?:\.\d+)?$/,Zr=/^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$/,Qr=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/,$r=/^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color-mix)\(.+\)$/,ei=/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/,ti=/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/,J=e=>Xr.test(e),Y=e=>!!e&&!Number.isNaN(Number(e)),X=e=>!!e&&Number.isInteger(Number(e)),ni=e=>e.endsWith(`%`)&&Y(e.slice(0,-1)),Z=e=>Zr.test(e),ri=()=>!0,ii=e=>Qr.test(e)&&!$r.test(e),ai=()=>!1,oi=e=>ei.test(e),si=e=>ti.test(e),ci=e=>!Q(e)&&!$(e),li=e=>e.startsWith(`@container`)&&(e[10]===`/`&&e[11]!==void 0||e[11]===`s`&&e[16]!==void 0&&e.startsWith(`-size/`,10)||e[11]===`n`&&e[18]!==void 0&&e.startsWith(`-normal/`,10)),ui=e=>Ti(e,ki,ai),Q=e=>Jr.test(e),di=e=>Ti(e,Ai,ii),fi=e=>Ti(e,ji,Y),pi=e=>Ti(e,Ni,ri),mi=e=>Ti(e,Mi,ai),hi=e=>Ti(e,Di,ai),gi=e=>Ti(e,Oi,si),_i=e=>Ti(e,Pi,oi),$=e=>Yr.test(e),vi=e=>Ei(e,Ai),yi=e=>Ei(e,Mi),bi=e=>Ei(e,Di),xi=e=>Ei(e,ki),Si=e=>Ei(e,Oi),Ci=e=>Ei(e,Pi,!0),wi=e=>Ei(e,Ni,!0),Ti=(e,t,n)=>{let r=Jr.exec(e);return r?r[1]?t(r[1]):n(r[2]):!1},Ei=(e,t,n=!1)=>{let r=Yr.exec(e);return r?r[1]?t(r[1]):n:!1},Di=e=>e===`position`||e===`percentage`,Oi=e=>e===`image`||e===`url`,ki=e=>e===`length`||e===`size`||e===`bg-size`,Ai=e=>e===`length`,ji=e=>e===`number`,Mi=e=>e===`family-name`,Ni=e=>e===`number`||e===`weight`,Pi=e=>e===`shadow`,Fi=Kr(()=>{let e=q(`color`),t=q(`font`),n=q(`text`),r=q(`font-weight`),i=q(`tracking`),a=q(`leading`),o=q(`breakpoint`),s=q(`container`),c=q(`spacing`),l=q(`radius`),u=q(`shadow`),d=q(`inset-shadow`),f=q(`text-shadow`),p=q(`drop-shadow`),m=q(`blur`),h=q(`perspective`),g=q(`aspect`),ee=q(`ease`),_=q(`animate`),v=()=>[`auto`,`avoid`,`all`,`avoid-page`,`page`,`left`,`right`,`column`],y=()=>[`center`,`top`,`bottom`,`left`,`right`,`top-left`,`left-top`,`top-right`,`right-top`,`bottom-right`,`right-bottom`,`bottom-left`,`left-bottom`],b=()=>[...y(),$,Q],x=()=>[`auto`,`hidden`,`clip`,`visible`,`scroll`],S=()=>[`auto`,`contain`,`none`],C=()=>[$,Q,c],w=()=>[J,`full`,`auto`,...C()],T=()=>[X,`none`,`subgrid`,$,Q],E=()=>[`auto`,{span:[`full`,X,$,Q]},X,$,Q],D=()=>[X,`auto`,$,Q],O=()=>[`auto`,`min`,`max`,`fr`,$,Q],k=()=>[`start`,`end`,`center`,`between`,`around`,`evenly`,`stretch`,`baseline`,`center-safe`,`end-safe`],A=()=>[`start`,`end`,`center`,`stretch`,`center-safe`,`end-safe`],j=()=>[`auto`,...C()],M=()=>[J,`auto`,`full`,`dvw`,`dvh`,`lvw`,`lvh`,`svw`,`svh`,`min`,`max`,`fit`,...C()],N=()=>[J,`screen`,`full`,`dvw`,`lvw`,`svw`,`min`,`max`,`fit`,...C()],P=()=>[J,`screen`,`full`,`lh`,`dvh`,`lvh`,`svh`,`min`,`max`,`fit`,...C()],F=()=>[e,$,Q],te=()=>[...y(),bi,hi,{position:[$,Q]}],ne=()=>[`no-repeat`,{repeat:[``,`x`,`y`,`space`,`round`]}],re=()=>[`auto`,`cover`,`contain`,xi,ui,{size:[$,Q]}],ie=()=>[ni,vi,di],I=()=>[``,`none`,`full`,l,$,Q],L=()=>[``,Y,vi,di],ae=()=>[`solid`,`dashed`,`dotted`,`double`],oe=()=>[`normal`,`multiply`,`screen`,`overlay`,`darken`,`lighten`,`color-dodge`,`color-burn`,`hard-light`,`soft-light`,`difference`,`exclusion`,`hue`,`saturation`,`color`,`luminosity`],R=()=>[Y,ni,bi,hi],se=()=>[``,`none`,m,$,Q],ce=()=>[`none`,Y,$,Q],le=()=>[`none`,Y,$,Q],ue=()=>[Y,$,Q],de=()=>[J,`full`,...C()];return{cacheSize:500,theme:{animate:[`spin`,`ping`,`pulse`,`bounce`],aspect:[`video`],blur:[Z],breakpoint:[Z],color:[ri],container:[Z],"drop-shadow":[Z],ease:[`in`,`out`,`in-out`],font:[ci],"font-weight":[`thin`,`extralight`,`light`,`normal`,`medium`,`semibold`,`bold`,`extrabold`,`black`],"inset-shadow":[Z],leading:[`none`,`tight`,`snug`,`normal`,`relaxed`,`loose`],perspective:[`dramatic`,`near`,`normal`,`midrange`,`distant`,`none`],radius:[Z],shadow:[Z],spacing:[`px`,Y],text:[Z],"text-shadow":[Z],tracking:[`tighter`,`tight`,`normal`,`wide`,`wider`,`widest`]},classGroups:{aspect:[{aspect:[`auto`,`square`,J,Q,$,g]}],container:[`container`],"container-type":[{"@container":[``,`normal`,`size`,$,Q]}],"container-named":[li],columns:[{columns:[Y,Q,$,s]}],"break-after":[{"break-after":v()}],"break-before":[{"break-before":v()}],"break-inside":[{"break-inside":[`auto`,`avoid`,`avoid-page`,`avoid-column`]}],"box-decoration":[{"box-decoration":[`slice`,`clone`]}],box:[{box:[`border`,`content`]}],display:[`block`,`inline-block`,`inline`,`flex`,`inline-flex`,`table`,`inline-table`,`table-caption`,`table-cell`,`table-column`,`table-column-group`,`table-footer-group`,`table-header-group`,`table-row-group`,`table-row`,`flow-root`,`grid`,`inline-grid`,`contents`,`list-item`,`hidden`],sr:[`sr-only`,`not-sr-only`],float:[{float:[`right`,`left`,`none`,`start`,`end`]}],clear:[{clear:[`left`,`right`,`both`,`none`,`start`,`end`]}],isolation:[`isolate`,`isolation-auto`],"object-fit":[{object:[`contain`,`cover`,`fill`,`none`,`scale-down`]}],"object-position":[{object:b()}],overflow:[{overflow:x()}],"overflow-x":[{"overflow-x":x()}],"overflow-y":[{"overflow-y":x()}],overscroll:[{overscroll:S()}],"overscroll-x":[{"overscroll-x":S()}],"overscroll-y":[{"overscroll-y":S()}],position:[`static`,`fixed`,`absolute`,`relative`,`sticky`],inset:[{inset:w()}],"inset-x":[{"inset-x":w()}],"inset-y":[{"inset-y":w()}],start:[{"inset-s":w(),start:w()}],end:[{"inset-e":w(),end:w()}],"inset-bs":[{"inset-bs":w()}],"inset-be":[{"inset-be":w()}],top:[{top:w()}],right:[{right:w()}],bottom:[{bottom:w()}],left:[{left:w()}],visibility:[`visible`,`invisible`,`collapse`],z:[{z:[X,`auto`,$,Q]}],basis:[{basis:[J,`full`,`auto`,s,...C()]}],"flex-direction":[{flex:[`row`,`row-reverse`,`col`,`col-reverse`]}],"flex-wrap":[{flex:[`nowrap`,`wrap`,`wrap-reverse`]}],flex:[{flex:[Y,J,`auto`,`initial`,`none`,Q]}],grow:[{grow:[``,Y,$,Q]}],shrink:[{shrink:[``,Y,$,Q]}],order:[{order:[X,`first`,`last`,`none`,$,Q]}],"grid-cols":[{"grid-cols":T()}],"col-start-end":[{col:E()}],"col-start":[{"col-start":D()}],"col-end":[{"col-end":D()}],"grid-rows":[{"grid-rows":T()}],"row-start-end":[{row:E()}],"row-start":[{"row-start":D()}],"row-end":[{"row-end":D()}],"grid-flow":[{"grid-flow":[`row`,`col`,`dense`,`row-dense`,`col-dense`]}],"auto-cols":[{"auto-cols":O()}],"auto-rows":[{"auto-rows":O()}],gap:[{gap:C()}],"gap-x":[{"gap-x":C()}],"gap-y":[{"gap-y":C()}],"justify-content":[{justify:[...k(),`normal`]}],"justify-items":[{"justify-items":[...A(),`normal`]}],"justify-self":[{"justify-self":[`auto`,...A()]}],"align-content":[{content:[`normal`,...k()]}],"align-items":[{items:[...A(),{baseline:[``,`last`]}]}],"align-self":[{self:[`auto`,...A(),{baseline:[``,`last`]}]}],"place-content":[{"place-content":k()}],"place-items":[{"place-items":[...A(),`baseline`]}],"place-self":[{"place-self":[`auto`,...A()]}],p:[{p:C()}],px:[{px:C()}],py:[{py:C()}],ps:[{ps:C()}],pe:[{pe:C()}],pbs:[{pbs:C()}],pbe:[{pbe:C()}],pt:[{pt:C()}],pr:[{pr:C()}],pb:[{pb:C()}],pl:[{pl:C()}],m:[{m:j()}],mx:[{mx:j()}],my:[{my:j()}],ms:[{ms:j()}],me:[{me:j()}],mbs:[{mbs:j()}],mbe:[{mbe:j()}],mt:[{mt:j()}],mr:[{mr:j()}],mb:[{mb:j()}],ml:[{ml:j()}],"space-x":[{"space-x":C()}],"space-x-reverse":[`space-x-reverse`],"space-y":[{"space-y":C()}],"space-y-reverse":[`space-y-reverse`],size:[{size:M()}],"inline-size":[{inline:[`auto`,...N()]}],"min-inline-size":[{"min-inline":[`auto`,...N()]}],"max-inline-size":[{"max-inline":[`none`,...N()]}],"block-size":[{block:[`auto`,...P()]}],"min-block-size":[{"min-block":[`auto`,...P()]}],"max-block-size":[{"max-block":[`none`,...P()]}],w:[{w:[s,`screen`,...M()]}],"min-w":[{"min-w":[s,`screen`,`none`,...M()]}],"max-w":[{"max-w":[s,`screen`,`none`,`prose`,{screen:[o]},...M()]}],h:[{h:[`screen`,`lh`,...M()]}],"min-h":[{"min-h":[`screen`,`lh`,`none`,...M()]}],"max-h":[{"max-h":[`screen`,`lh`,...M()]}],"font-size":[{text:[`base`,n,vi,di]}],"font-smoothing":[`antialiased`,`subpixel-antialiased`],"font-style":[`italic`,`not-italic`],"font-weight":[{font:[r,wi,pi]}],"font-stretch":[{"font-stretch":[`ultra-condensed`,`extra-condensed`,`condensed`,`semi-condensed`,`normal`,`semi-expanded`,`expanded`,`extra-expanded`,`ultra-expanded`,ni,Q]}],"font-family":[{font:[yi,mi,t]}],"font-features":[{"font-features":[Q]}],"fvn-normal":[`normal-nums`],"fvn-ordinal":[`ordinal`],"fvn-slashed-zero":[`slashed-zero`],"fvn-figure":[`lining-nums`,`oldstyle-nums`],"fvn-spacing":[`proportional-nums`,`tabular-nums`],"fvn-fraction":[`diagonal-fractions`,`stacked-fractions`],tracking:[{tracking:[i,$,Q]}],"line-clamp":[{"line-clamp":[Y,`none`,$,fi]}],leading:[{leading:[a,...C()]}],"list-image":[{"list-image":[`none`,$,Q]}],"list-style-position":[{list:[`inside`,`outside`]}],"list-style-type":[{list:[`disc`,`decimal`,`none`,$,Q]}],"text-alignment":[{text:[`left`,`center`,`right`,`justify`,`start`,`end`]}],"placeholder-color":[{placeholder:F()}],"text-color":[{text:F()}],"text-decoration":[`underline`,`overline`,`line-through`,`no-underline`],"text-decoration-style":[{decoration:[...ae(),`wavy`]}],"text-decoration-thickness":[{decoration:[Y,`from-font`,`auto`,$,di]}],"text-decoration-color":[{decoration:F()}],"underline-offset":[{"underline-offset":[Y,`auto`,$,Q]}],"text-transform":[`uppercase`,`lowercase`,`capitalize`,`normal-case`],"text-overflow":[`truncate`,`text-ellipsis`,`text-clip`],"text-wrap":[{text:[`wrap`,`nowrap`,`balance`,`pretty`]}],indent:[{indent:C()}],"tab-size":[{tab:[X,$,Q]}],"vertical-align":[{align:[`baseline`,`top`,`middle`,`bottom`,`text-top`,`text-bottom`,`sub`,`super`,$,Q]}],whitespace:[{whitespace:[`normal`,`nowrap`,`pre`,`pre-line`,`pre-wrap`,`break-spaces`]}],break:[{break:[`normal`,`words`,`all`,`keep`]}],wrap:[{wrap:[`break-word`,`anywhere`,`normal`]}],hyphens:[{hyphens:[`none`,`manual`,`auto`]}],content:[{content:[`none`,$,Q]}],"bg-attachment":[{bg:[`fixed`,`local`,`scroll`]}],"bg-clip":[{"bg-clip":[`border`,`padding`,`content`,`text`]}],"bg-origin":[{"bg-origin":[`border`,`padding`,`content`]}],"bg-position":[{bg:te()}],"bg-repeat":[{bg:ne()}],"bg-size":[{bg:re()}],"bg-image":[{bg:[`none`,{linear:[{to:[`t`,`tr`,`r`,`br`,`b`,`bl`,`l`,`tl`]},X,$,Q],radial:[``,$,Q],conic:[X,$,Q]},Si,gi]}],"bg-color":[{bg:F()}],"gradient-from-pos":[{from:ie()}],"gradient-via-pos":[{via:ie()}],"gradient-to-pos":[{to:ie()}],"gradient-from":[{from:F()}],"gradient-via":[{via:F()}],"gradient-to":[{to:F()}],rounded:[{rounded:I()}],"rounded-s":[{"rounded-s":I()}],"rounded-e":[{"rounded-e":I()}],"rounded-t":[{"rounded-t":I()}],"rounded-r":[{"rounded-r":I()}],"rounded-b":[{"rounded-b":I()}],"rounded-l":[{"rounded-l":I()}],"rounded-ss":[{"rounded-ss":I()}],"rounded-se":[{"rounded-se":I()}],"rounded-ee":[{"rounded-ee":I()}],"rounded-es":[{"rounded-es":I()}],"rounded-tl":[{"rounded-tl":I()}],"rounded-tr":[{"rounded-tr":I()}],"rounded-br":[{"rounded-br":I()}],"rounded-bl":[{"rounded-bl":I()}],"border-w":[{border:L()}],"border-w-x":[{"border-x":L()}],"border-w-y":[{"border-y":L()}],"border-w-s":[{"border-s":L()}],"border-w-e":[{"border-e":L()}],"border-w-bs":[{"border-bs":L()}],"border-w-be":[{"border-be":L()}],"border-w-t":[{"border-t":L()}],"border-w-r":[{"border-r":L()}],"border-w-b":[{"border-b":L()}],"border-w-l":[{"border-l":L()}],"divide-x":[{"divide-x":L()}],"divide-x-reverse":[`divide-x-reverse`],"divide-y":[{"divide-y":L()}],"divide-y-reverse":[`divide-y-reverse`],"border-style":[{border:[...ae(),`hidden`,`none`]}],"divide-style":[{divide:[...ae(),`hidden`,`none`]}],"border-color":[{border:F()}],"border-color-x":[{"border-x":F()}],"border-color-y":[{"border-y":F()}],"border-color-s":[{"border-s":F()}],"border-color-e":[{"border-e":F()}],"border-color-bs":[{"border-bs":F()}],"border-color-be":[{"border-be":F()}],"border-color-t":[{"border-t":F()}],"border-color-r":[{"border-r":F()}],"border-color-b":[{"border-b":F()}],"border-color-l":[{"border-l":F()}],"divide-color":[{divide:F()}],"outline-style":[{outline:[...ae(),`none`,`hidden`]}],"outline-offset":[{"outline-offset":[Y,$,Q]}],"outline-w":[{outline:[``,Y,vi,di]}],"outline-color":[{outline:F()}],shadow:[{shadow:[``,`none`,u,Ci,_i]}],"shadow-color":[{shadow:F()}],"inset-shadow":[{"inset-shadow":[`none`,d,Ci,_i]}],"inset-shadow-color":[{"inset-shadow":F()}],"ring-w":[{ring:L()}],"ring-w-inset":[`ring-inset`],"ring-color":[{ring:F()}],"ring-offset-w":[{"ring-offset":[Y,di]}],"ring-offset-color":[{"ring-offset":F()}],"inset-ring-w":[{"inset-ring":L()}],"inset-ring-color":[{"inset-ring":F()}],"text-shadow":[{"text-shadow":[`none`,f,Ci,_i]}],"text-shadow-color":[{"text-shadow":F()}],opacity:[{opacity:[Y,$,Q]}],"mix-blend":[{"mix-blend":[...oe(),`plus-darker`,`plus-lighter`]}],"bg-blend":[{"bg-blend":oe()}],"mask-clip":[{"mask-clip":[`border`,`padding`,`content`,`fill`,`stroke`,`view`]},`mask-no-clip`],"mask-composite":[{mask:[`add`,`subtract`,`intersect`,`exclude`]}],"mask-image-linear-pos":[{"mask-linear":[Y]}],"mask-image-linear-from-pos":[{"mask-linear-from":R()}],"mask-image-linear-to-pos":[{"mask-linear-to":R()}],"mask-image-linear-from-color":[{"mask-linear-from":F()}],"mask-image-linear-to-color":[{"mask-linear-to":F()}],"mask-image-t-from-pos":[{"mask-t-from":R()}],"mask-image-t-to-pos":[{"mask-t-to":R()}],"mask-image-t-from-color":[{"mask-t-from":F()}],"mask-image-t-to-color":[{"mask-t-to":F()}],"mask-image-r-from-pos":[{"mask-r-from":R()}],"mask-image-r-to-pos":[{"mask-r-to":R()}],"mask-image-r-from-color":[{"mask-r-from":F()}],"mask-image-r-to-color":[{"mask-r-to":F()}],"mask-image-b-from-pos":[{"mask-b-from":R()}],"mask-image-b-to-pos":[{"mask-b-to":R()}],"mask-image-b-from-color":[{"mask-b-from":F()}],"mask-image-b-to-color":[{"mask-b-to":F()}],"mask-image-l-from-pos":[{"mask-l-from":R()}],"mask-image-l-to-pos":[{"mask-l-to":R()}],"mask-image-l-from-color":[{"mask-l-from":F()}],"mask-image-l-to-color":[{"mask-l-to":F()}],"mask-image-x-from-pos":[{"mask-x-from":R()}],"mask-image-x-to-pos":[{"mask-x-to":R()}],"mask-image-x-from-color":[{"mask-x-from":F()}],"mask-image-x-to-color":[{"mask-x-to":F()}],"mask-image-y-from-pos":[{"mask-y-from":R()}],"mask-image-y-to-pos":[{"mask-y-to":R()}],"mask-image-y-from-color":[{"mask-y-from":F()}],"mask-image-y-to-color":[{"mask-y-to":F()}],"mask-image-radial":[{"mask-radial":[$,Q]}],"mask-image-radial-from-pos":[{"mask-radial-from":R()}],"mask-image-radial-to-pos":[{"mask-radial-to":R()}],"mask-image-radial-from-color":[{"mask-radial-from":F()}],"mask-image-radial-to-color":[{"mask-radial-to":F()}],"mask-image-radial-shape":[{"mask-radial":[`circle`,`ellipse`]}],"mask-image-radial-size":[{"mask-radial":[{closest:[`side`,`corner`],farthest:[`side`,`corner`]}]}],"mask-image-radial-pos":[{"mask-radial-at":y()}],"mask-image-conic-pos":[{"mask-conic":[Y]}],"mask-image-conic-from-pos":[{"mask-conic-from":R()}],"mask-image-conic-to-pos":[{"mask-conic-to":R()}],"mask-image-conic-from-color":[{"mask-conic-from":F()}],"mask-image-conic-to-color":[{"mask-conic-to":F()}],"mask-mode":[{mask:[`alpha`,`luminance`,`match`]}],"mask-origin":[{"mask-origin":[`border`,`padding`,`content`,`fill`,`stroke`,`view`]}],"mask-position":[{mask:te()}],"mask-repeat":[{mask:ne()}],"mask-size":[{mask:re()}],"mask-type":[{"mask-type":[`alpha`,`luminance`]}],"mask-image":[{mask:[`none`,$,Q]}],filter:[{filter:[``,`none`,$,Q]}],blur:[{blur:se()}],brightness:[{brightness:[Y,$,Q]}],contrast:[{contrast:[Y,$,Q]}],"drop-shadow":[{"drop-shadow":[``,`none`,p,Ci,_i]}],"drop-shadow-color":[{"drop-shadow":F()}],grayscale:[{grayscale:[``,Y,$,Q]}],"hue-rotate":[{"hue-rotate":[Y,$,Q]}],invert:[{invert:[``,Y,$,Q]}],saturate:[{saturate:[Y,$,Q]}],sepia:[{sepia:[``,Y,$,Q]}],"backdrop-filter":[{"backdrop-filter":[``,`none`,$,Q]}],"backdrop-blur":[{"backdrop-blur":se()}],"backdrop-brightness":[{"backdrop-brightness":[Y,$,Q]}],"backdrop-contrast":[{"backdrop-contrast":[Y,$,Q]}],"backdrop-grayscale":[{"backdrop-grayscale":[``,Y,$,Q]}],"backdrop-hue-rotate":[{"backdrop-hue-rotate":[Y,$,Q]}],"backdrop-invert":[{"backdrop-invert":[``,Y,$,Q]}],"backdrop-opacity":[{"backdrop-opacity":[Y,$,Q]}],"backdrop-saturate":[{"backdrop-saturate":[Y,$,Q]}],"backdrop-sepia":[{"backdrop-sepia":[``,Y,$,Q]}],"border-collapse":[{border:[`collapse`,`separate`]}],"border-spacing":[{"border-spacing":C()}],"border-spacing-x":[{"border-spacing-x":C()}],"border-spacing-y":[{"border-spacing-y":C()}],"table-layout":[{table:[`auto`,`fixed`]}],caption:[{caption:[`top`,`bottom`]}],transition:[{transition:[``,`all`,`colors`,`opacity`,`shadow`,`transform`,`none`,$,Q]}],"transition-behavior":[{transition:[`normal`,`discrete`]}],duration:[{duration:[Y,`initial`,$,Q]}],ease:[{ease:[`linear`,`initial`,ee,$,Q]}],delay:[{delay:[Y,$,Q]}],animate:[{animate:[`none`,_,$,Q]}],backface:[{backface:[`hidden`,`visible`]}],perspective:[{perspective:[h,$,Q]}],"perspective-origin":[{"perspective-origin":b()}],rotate:[{rotate:ce()}],"rotate-x":[{"rotate-x":ce()}],"rotate-y":[{"rotate-y":ce()}],"rotate-z":[{"rotate-z":ce()}],scale:[{scale:le()}],"scale-x":[{"scale-x":le()}],"scale-y":[{"scale-y":le()}],"scale-z":[{"scale-z":le()}],"scale-3d":[`scale-3d`],skew:[{skew:ue()}],"skew-x":[{"skew-x":ue()}],"skew-y":[{"skew-y":ue()}],transform:[{transform:[$,Q,``,`none`,`gpu`,`cpu`]}],"transform-origin":[{origin:b()}],"transform-style":[{transform:[`3d`,`flat`]}],translate:[{translate:de()}],"translate-x":[{"translate-x":de()}],"translate-y":[{"translate-y":de()}],"translate-z":[{"translate-z":de()}],"translate-none":[`translate-none`],zoom:[{zoom:[X,$,Q]}],accent:[{accent:F()}],appearance:[{appearance:[`none`,`auto`]}],"caret-color":[{caret:F()}],"color-scheme":[{scheme:[`normal`,`dark`,`light`,`light-dark`,`only-dark`,`only-light`]}],cursor:[{cursor:[`auto`,`default`,`pointer`,`wait`,`text`,`move`,`help`,`not-allowed`,`none`,`context-menu`,`progress`,`cell`,`crosshair`,`vertical-text`,`alias`,`copy`,`no-drop`,`grab`,`grabbing`,`all-scroll`,`col-resize`,`row-resize`,`n-resize`,`e-resize`,`s-resize`,`w-resize`,`ne-resize`,`nw-resize`,`se-resize`,`sw-resize`,`ew-resize`,`ns-resize`,`nesw-resize`,`nwse-resize`,`zoom-in`,`zoom-out`,$,Q]}],"field-sizing":[{"field-sizing":[`fixed`,`content`]}],"pointer-events":[{"pointer-events":[`auto`,`none`]}],resize:[{resize:[`none`,``,`y`,`x`]}],"scroll-behavior":[{scroll:[`auto`,`smooth`]}],"scrollbar-thumb-color":[{"scrollbar-thumb":F()}],"scrollbar-track-color":[{"scrollbar-track":F()}],"scrollbar-gutter":[{"scrollbar-gutter":[`auto`,`stable`,`both`]}],"scrollbar-w":[{scrollbar:[`auto`,`thin`,`none`]}],"scroll-m":[{"scroll-m":C()}],"scroll-mx":[{"scroll-mx":C()}],"scroll-my":[{"scroll-my":C()}],"scroll-ms":[{"scroll-ms":C()}],"scroll-me":[{"scroll-me":C()}],"scroll-mbs":[{"scroll-mbs":C()}],"scroll-mbe":[{"scroll-mbe":C()}],"scroll-mt":[{"scroll-mt":C()}],"scroll-mr":[{"scroll-mr":C()}],"scroll-mb":[{"scroll-mb":C()}],"scroll-ml":[{"scroll-ml":C()}],"scroll-p":[{"scroll-p":C()}],"scroll-px":[{"scroll-px":C()}],"scroll-py":[{"scroll-py":C()}],"scroll-ps":[{"scroll-ps":C()}],"scroll-pe":[{"scroll-pe":C()}],"scroll-pbs":[{"scroll-pbs":C()}],"scroll-pbe":[{"scroll-pbe":C()}],"scroll-pt":[{"scroll-pt":C()}],"scroll-pr":[{"scroll-pr":C()}],"scroll-pb":[{"scroll-pb":C()}],"scroll-pl":[{"scroll-pl":C()}],"snap-align":[{snap:[`start`,`end`,`center`,`align-none`]}],"snap-stop":[{snap:[`normal`,`always`]}],"snap-type":[{snap:[`none`,`x`,`y`,`both`]}],"snap-strictness":[{snap:[`mandatory`,`proximity`]}],touch:[{touch:[`auto`,`none`,`manipulation`]}],"touch-x":[{"touch-pan":[`x`,`left`,`right`]}],"touch-y":[{"touch-pan":[`y`,`up`,`down`]}],"touch-pz":[`touch-pinch-zoom`],select:[{select:[`none`,`text`,`all`,`auto`]}],"will-change":[{"will-change":[`auto`,`scroll`,`contents`,`transform`,$,Q]}],fill:[{fill:[`none`,...F()]}],"stroke-w":[{stroke:[Y,vi,di,fi]}],stroke:[{stroke:[`none`,...F()]}],"forced-color-adjust":[{"forced-color-adjust":[`auto`,`none`]}]},conflictingClassGroups:{"container-named":[`container-type`],overflow:[`overflow-x`,`overflow-y`],overscroll:[`overscroll-x`,`overscroll-y`],inset:[`inset-x`,`inset-y`,`inset-bs`,`inset-be`,`start`,`end`,`top`,`right`,`bottom`,`left`],"inset-x":[`right`,`left`],"inset-y":[`top`,`bottom`],flex:[`basis`,`grow`,`shrink`],gap:[`gap-x`,`gap-y`],p:[`px`,`py`,`ps`,`pe`,`pbs`,`pbe`,`pt`,`pr`,`pb`,`pl`],px:[`pr`,`pl`],py:[`pt`,`pb`],m:[`mx`,`my`,`ms`,`me`,`mbs`,`mbe`,`mt`,`mr`,`mb`,`ml`],mx:[`mr`,`ml`],my:[`mt`,`mb`],size:[`w`,`h`],"font-size":[`leading`],"fvn-normal":[`fvn-ordinal`,`fvn-slashed-zero`,`fvn-figure`,`fvn-spacing`,`fvn-fraction`],"fvn-ordinal":[`fvn-normal`],"fvn-slashed-zero":[`fvn-normal`],"fvn-figure":[`fvn-normal`],"fvn-spacing":[`fvn-normal`],"fvn-fraction":[`fvn-normal`],"line-clamp":[`display`,`overflow`],rounded:[`rounded-s`,`rounded-e`,`rounded-t`,`rounded-r`,`rounded-b`,`rounded-l`,`rounded-ss`,`rounded-se`,`rounded-ee`,`rounded-es`,`rounded-tl`,`rounded-tr`,`rounded-br`,`rounded-bl`],"rounded-s":[`rounded-ss`,`rounded-es`],"rounded-e":[`rounded-se`,`rounded-ee`],"rounded-t":[`rounded-tl`,`rounded-tr`],"rounded-r":[`rounded-tr`,`rounded-br`],"rounded-b":[`rounded-br`,`rounded-bl`],"rounded-l":[`rounded-tl`,`rounded-bl`],"border-spacing":[`border-spacing-x`,`border-spacing-y`],"border-w":[`border-w-x`,`border-w-y`,`border-w-s`,`border-w-e`,`border-w-bs`,`border-w-be`,`border-w-t`,`border-w-r`,`border-w-b`,`border-w-l`],"border-w-x":[`border-w-r`,`border-w-l`],"border-w-y":[`border-w-t`,`border-w-b`],"border-color":[`border-color-x`,`border-color-y`,`border-color-s`,`border-color-e`,`border-color-bs`,`border-color-be`,`border-color-t`,`border-color-r`,`border-color-b`,`border-color-l`],"border-color-x":[`border-color-r`,`border-color-l`],"border-color-y":[`border-color-t`,`border-color-b`],translate:[`translate-x`,`translate-y`,`translate-none`],"translate-none":[`translate`,`translate-x`,`translate-y`,`translate-z`],"scroll-m":[`scroll-mx`,`scroll-my`,`scroll-ms`,`scroll-me`,`scroll-mbs`,`scroll-mbe`,`scroll-mt`,`scroll-mr`,`scroll-mb`,`scroll-ml`],"scroll-mx":[`scroll-mr`,`scroll-ml`],"scroll-my":[`scroll-mt`,`scroll-mb`],"scroll-p":[`scroll-px`,`scroll-py`,`scroll-ps`,`scroll-pe`,`scroll-pbs`,`scroll-pbe`,`scroll-pt`,`scroll-pr`,`scroll-pb`,`scroll-pl`],"scroll-px":[`scroll-pr`,`scroll-pl`],"scroll-py":[`scroll-pt`,`scroll-pb`],touch:[`touch-x`,`touch-y`,`touch-pz`],"touch-x":[`touch`],"touch-y":[`touch`],"touch-pz":[`touch`]},conflictingClassGroupModifiers:{"font-size":[`leading`]},postfixLookupClassGroups:[`container-type`],orderSensitiveModifiers:[`*`,`**`,`after`,`backdrop`,`before`,`details-content`,`file`,`first-letter`,`first-line`,`marker`,`placeholder`,`selection`]}});function Ii(...e){return Fi(mr(e))}function Li(e,t){return[...e].sort().join(`\0`)===[...t].sort().join(`\0`)}var Ri=Kn;function zi(e){let t=(0,u.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Ii(`fixed inset-0 z-50 bg-[rgb(23_33_45/0.34)] backdrop-blur-[1px]`,`data-[state=open]:animate-in data-[state=closed]:animate-out`,`data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,C.jsx)(Qn,{className:i,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Bi({children:e,className:t,closeDisabled:n=!1,closeLabel:r=`Close dialog`,variant:i=`modal`,...a}){return(0,C.jsxs)(Xn,{children:[(0,C.jsx)(zi,{}),(0,C.jsxs)(nr,{className:Ii(i===`sheet`?`fixed inset-y-0 right-0 z-50 flex h-full w-[420px] max-w-full flex-col border-l border-edge bg-card shadow-[0_0_48px_rgb(23_33_45/0.18)]`:`fixed top-[12vh] left-1/2 z-50 w-[calc(100%-2rem)] max-w-[520px] -translate-x-1/2 rounded-[10px] border border-edge bg-card shadow-[0_16px_48px_rgb(23_33_45/0.24)]`,`focus:outline-none data-[state=open]:animate-in data-[state=closed]:animate-out`,i===`sheet`?`data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right`:`data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95`,t),...a,children:[e,(0,C.jsx)(dr,{"aria-label":r,disabled:n,className:`absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-md text-faint hover:bg-bg hover:text-ink disabled:pointer-events-none disabled:opacity-45`,children:(0,C.jsx)(c,{"aria-hidden":!0,className:`size-4`})})]})]})}function Vi(e){let t=(0,u.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Ii(`text-[15px] font-[650] text-ink`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,C.jsx)(sr,{className:i,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Hi(e){let t=(0,u.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Ii(`text-[13px] text-mut`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,C.jsx)(lr,{className:i,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Ui(e){let t=(0,u.c)(47),{title:n,body:r,confirmLabel:i,danger:a,needsReason:o,reasonLabel:s,busy:c,error:d,onConfirm:f,onCancel:p}=e,[m,h]=(0,l.useState)(``),g;t[0]!==o||t[1]!==m?(g=o&&m.trim()===``,t[0]=o,t[1]=m,t[2]=g):g=t[2];let ee=g,_;t[3]===p?_=t[4]:(_=e=>!e&&p(),t[3]=p,t[4]=_);let v,y,b;t[5]===c?(v=t[6],y=t[7],b=t[8]):(v=e=>c&&e.preventDefault(),y=e=>c&&e.preventDefault(),b=e=>c&&e.preventDefault(),t[5]=c,t[6]=v,t[7]=y,t[8]=b);let x=a?`text-bad`:`text-ink`,S;t[9]!==x||t[10]!==n?(S=(0,C.jsx)(Vi,{className:x,children:n}),t[9]=x,t[10]=n,t[11]=S):S=t[11];let w;t[12]===r?w=t[13]:(w=(0,C.jsx)(Hi,{asChild:!0,children:(0,C.jsx)(`div`,{className:`mt-2 text-[13px] text-mut`,children:r})}),t[12]=r,t[13]=w);let T;t[14]!==o||t[15]!==m||t[16]!==s?(T=o&&(0,C.jsxs)(`label`,{className:`mt-3 block`,children:[(0,C.jsx)(`span`,{className:`text-[12.5px] font-medium`,children:s??`Reason`}),(0,C.jsx)(`input`,{value:m,onChange:e=>h(e.target.value),placeholder:`why — this is what every screen will show`,className:`mt-1 w-full rounded-lg border border-edge bg-[#FBFBFC] px-2.5 py-1.5 text-[13px]`})]}),t[14]=o,t[15]=m,t[16]=s,t[17]=T):T=t[17];let E;t[18]===d?E=t[19]:(E=d&&(0,C.jsx)(`div`,{className:`mt-3 rounded-lg border border-bad-edge bg-bad-bg px-3 py-2 text-[12.5px] text-bad`,children:d}),t[18]=d,t[19]=E);let D=c||ee,O;t[20]!==f||t[21]!==m?(O=()=>f(m.trim()),t[20]=f,t[21]=m,t[22]=O):O=t[22];let k=`rounded-lg px-4 py-1.5 text-[13px] font-semibold text-white disabled:opacity-45 ${a?`bg-bad hover:brightness-110`:`bg-ink hover:bg-[#2E3C4E]`}`,A=c?`Working…`:i,j;t[23]!==D||t[24]!==O||t[25]!==k||t[26]!==A?(j=(0,C.jsx)(`button`,{type:`button`,disabled:D,onClick:O,className:k,children:A}),t[23]=D,t[24]=O,t[25]=k,t[26]=A,t[27]=j):j=t[27];let M;t[28]!==c||t[29]!==p?(M=(0,C.jsx)(`button`,{type:`button`,disabled:c,onClick:p,className:`rounded-lg border border-edge px-4 py-1.5 text-[13px] font-semibold text-mut disabled:opacity-45`,children:`Cancel`}),t[28]=c,t[29]=p,t[30]=M):M=t[30];let N;t[31]!==j||t[32]!==M?(N=(0,C.jsxs)(`div`,{className:`mt-4 flex items-center gap-2.5`,children:[j,M]}),t[31]=j,t[32]=M,t[33]=N):N=t[33];let P;t[34]!==c||t[35]!==E||t[36]!==N||t[37]!==v||t[38]!==y||t[39]!==b||t[40]!==S||t[41]!==w||t[42]!==T?(P=(0,C.jsxs)(Bi,{className:`p-5`,closeDisabled:c,onInteractOutside:v,onPointerDownOutside:y,onEscapeKeyDown:b,children:[S,w,T,E,N]}),t[34]=c,t[35]=E,t[36]=N,t[37]=v,t[38]=y,t[39]=b,t[40]=S,t[41]=w,t[42]=T,t[43]=P):P=t[43];let F;return t[44]!==P||t[45]!==_?(F=(0,C.jsx)(Ri,{open:!0,onOpenChange:_,children:P}),t[44]=P,t[45]=_,t[46]=F):F=t[46],F}function Wi(){let e=(0,u.c)(7),t=(0,l.useRef)(null),[n,r]=(0,l.useState)(!1),[o,s]=(0,l.useState)(null),c,d;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(c=()=>()=>{let e=t.current;t.current=null,e?.abort()},d=[],e[0]=c,e[1]=d):(c=e[0],d=e[1]),(0,l.useEffect)(c,d);let f;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(f=function(e,n){let o=n===void 0?{}:n;t.current?.abort();let c=new AbortController;t.current=c,r(!0),s(null),i(e.pipe(a({onFailure:Ki,onSuccess:Gi})),{signal:c.signal}).catch(e=>{c.signal.aborted||(console.error(`Unexpected dashboard operation defect`,e),s(e instanceof Error?`Unexpected dashboard failure: ${e.message}`:`Unexpected dashboard failure`))}).then(e=>{c.signal.aborted||!e||(e.ok?o.onSuccess?.(e.value):(s(e.failure.message),o.onFailure?.(e.failure)))}).finally(()=>{t.current===c&&(t.current=null,r(!1),o.onFinally?.())})},e[2]=f):f=e[2];let p=f,m;e[3]===Symbol.for(`react.memo_cache_sentinel`)?(m=()=>s(null),e[3]=m):m=e[3];let h=m,g;return e[4]!==o||e[5]!==n?(g={run:p,running:n,error:o,clearError:h},e[4]=o,e[5]=n,e[6]=g):g=e[6],g}function Gi(e){return{ok:!0,value:e}}function Ki(e){return{ok:!1,failure:e}}export{Hi as a,Bi as i,Ui as n,Vi as o,Ri as r,Li as s,Wi as t}; \ No newline at end of file diff --git a/internal/serve/dist/index.html b/internal/serve/dist/index.html new file mode 100644 index 00000000..68b440a7 --- /dev/null +++ b/internal/serve/dist/index.html @@ -0,0 +1,13 @@ + + + + + + Code Review Queue + + + + +
+ + diff --git a/internal/serve/enroll.go b/internal/serve/enroll.go new file mode 100644 index 00000000..81e37a18 --- /dev/null +++ b/internal/serve/enroll.go @@ -0,0 +1,252 @@ +package serve + +import ( + "context" + "net/http" + "sort" + "strings" + "sync" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +// Enrollment is one repository's answer to "does crq review this project", as +// resolved by internal/crq. serve does not decide it: the precedence between a +// shared record and a host's env file is a queue rule, and two answers to it +// would be one too many. +type Enrollment struct { + Source string `json:"source"` // state|env|excluded|scope|off + Enabled bool `json:"enabled"` + EnvConflict bool `json:"env_conflict,omitempty"` + ClearEnables bool `json:"clear_enables,omitempty"` + Reason string `json:"reason,omitempty"` + By string `json:"by,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// EnrollFor resolves one repository against a loaded state. +type EnrollFor func(st state.State, repo string) Enrollment + +// Candidate is one repository the picker offers. Everything here comes from the +// repository listing itself; whether crq already knows it is filled in locally. +type Candidate struct { + Repo string `json:"repo"` + Private bool `json:"private"` + Archived bool `json:"archived"` + Fork bool `json:"fork"` + // Issues is GitHub's open_issues_count, which counts issues AND pull + // requests. Labelled as what it is rather than as an open-PR count, which + // would cost one search per repository to know. + Issues int `json:"issues"` + PushedAt *time.Time `json:"pushed_at,omitempty"` + Language string `json:"language,omitempty"` + // Enrollment is nil for a repository crq has no answer about yet. + Enrollment *Enrollment `json:"enrollment,omitempty"` +} + +// EnrollImpact is what enrolling a repository would do, before it is done. +type EnrollImpact struct { + Rev int64 `json:"rev"` + Repo string `json:"repo"` + Open int `json:"open"` + Eligible int `json:"eligible"` + Skipped map[string]int `json:"skipped,omitempty"` + Metered int `json:"metered"` + Low float64 `json:"low"` + High float64 `json:"high"` + Unpriced int `json:"unpriced,omitempty"` + Unexamined int `json:"unexamined,omitempty"` + Summary string `json:"summary"` + PricesCheckedAt string `json:"prices_checked_at"` +} + +// Previewer answers what enrolling a repository would cost. Separate from the +// Actor because it reads GitHub per open pull request: it is what a dialog asks +// when it opens, not something a list can carry. +type Previewer interface { + PreviewEnroll(ctx context.Context, repo string) (EnrollImpact, error) +} + +// Listing is one scope walk: the repositories it found, and the owners it could +// not finish. Truncation travels with the rows because a picker that shows a +// bounded list as if it were the whole of one is how a repository becomes +// impossible to add without anyone being told why. +type Listing struct { + Repos []Candidate `json:"repos"` + // Truncated names the owners whose listing hit the bound. + Truncated []string `json:"truncated,omitempty"` +} + +// Discoverer lists the repositories in the configured scope. It is a separate +// interface from Observer because it is the one call in the dashboard that is +// expensive enough to cache aggressively and to never make on a page load. +type Discoverer interface { + Discover(ctx context.Context) (Listing, error) +} + +// discoverCache holds the scope listing. Repositories appear when someone +// creates one, which is rare, and the picker has a Refresh button — so a long +// TTL costs a stale row and saves a multi-page REST walk on every open. +type discoverCache struct { + mu sync.Mutex + at time.Time + listing Listing + err error + flight chan struct{} +} + +const ( + discoverTTL = 10 * time.Minute + discoverTimeout = 60 * time.Second +) + +func (c *discoverCache) get(ctx context.Context, d Discoverer, now time.Time, force bool) (Listing, error) { + c.mu.Lock() + if !force && c.at.After(now.Add(-discoverTTL)) && c.err == nil { + listing := c.listing + c.mu.Unlock() + return listing, nil + } + if c.flight != nil { + flight := c.flight + c.mu.Unlock() + select { + case <-ctx.Done(): + return Listing{}, ctx.Err() + case <-flight: + c.mu.Lock() + listing, err := c.listing, c.err + c.mu.Unlock() + return listing, err + } + } + c.flight = make(chan struct{}) + flight := c.flight + c.mu.Unlock() + + go c.fill(d, now, flight) + select { + case <-ctx.Done(): + return Listing{}, ctx.Err() + case <-flight: + c.mu.Lock() + listing, err := c.listing, c.err + c.mu.Unlock() + return listing, err + } +} + +func (c *discoverCache) fill(d Discoverer, now time.Time, flight chan struct{}) { + // The fetch belongs to every caller sharing this flight, not to whichever + // browser happened to start it. A closed tab must not cancel discovery for + // the other waiters. + ctx, cancel := context.WithTimeout(context.Background(), discoverTimeout) + defer cancel() + listing, err := d.Discover(ctx) + c.mu.Lock() + c.at, c.listing, c.err = now, listing, err + c.flight = nil + close(flight) + c.mu.Unlock() +} + +// handleDiscover answers the repository picker. It never blocks the rest of the +// dashboard: nothing else calls it, and a failure here is reported as itself +// rather than as a broken page. +func (s *Server) handleDiscover(w http.ResponseWriter, r *http.Request) { + if !s.allowDashboardRead(w, r) { + return + } + if s.opts.Discoverer == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "this dashboard has no repository discovery configured", + }) + return + } + ctx, cancel := context.WithTimeout(r.Context(), discoverTimeout) + defer cancel() + + listing, err := s.discovered.get(ctx, s.opts.Discoverer, s.opts.Now(), r.URL.Query().Get("refresh") == "1") + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + rows := listing.Repos + + // Annotate with what crq already knows, from the snapshot the server holds + // — the picker's whole job is separating "not added yet" from "added, and + // here is where that answer came from". + s.mu.RLock() + st := s.lastState + loaded := s.loaded + s.mu.RUnlock() + out := make([]Candidate, 0, len(rows)) + for _, c := range rows { + if loaded && s.opts.EnrollFor != nil { + e := s.opts.EnrollFor(st, c.Repo) + c.Enrollment = &e + } + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { + a, b := out[i], out[j] + // Recently pushed first: the repository somebody wants to add is + // overwhelmingly the one they were just working in. + switch { + case a.PushedAt != nil && b.PushedAt != nil && !a.PushedAt.Equal(*b.PushedAt): + return a.PushedAt.After(*b.PushedAt) + case a.PushedAt != nil && b.PushedAt == nil: + return true + case a.PushedAt == nil && b.PushedAt != nil: + return false + } + return strings.ToLower(a.Repo) < strings.ToLower(b.Repo) + }) + writeJSON(w, http.StatusOK, map[string]any{"repos": out, "truncated": listing.Truncated}) +} + +// handleEnrollPreview answers the add-repo dialog. It is the one click in the +// product that can spend real money — a repository with a dozen open pull +// requests becomes a dozen metered reviews on the next pass — so the dialog +// asks before offering it, in the terms the bill arrives in. +func (s *Server) handleEnrollPreview(w http.ResponseWriter, r *http.Request) { + if !s.allowDashboardRead(w, r) { + return + } + if s.opts.Previewer == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "this dashboard cannot price an enrollment", + }) + return + } + repo := strings.TrimSpace(r.URL.Query().Get("repo")) + if repo == "" || !strings.Contains(repo, "/") { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "repo must be owner/name"}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) + defer cancel() + impact, err := s.opts.Previewer.PreviewEnroll(ctx, repo) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, impact) +} + +// allowDashboardRead protects GETs whose implementation spends authenticated +// GitHub quota. They are reads to the browser, but not side-effect free for the +// fleet: a hostile page could otherwise force refreshes without reading the +// response and starve every queue user. +func (s *Server) allowDashboardRead(w http.ResponseWriter, r *http.Request) bool { + if r.Header.Get("X-CRQ-Dashboard") != "1" { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "missing dashboard header"}) + return false + } + if err := s.addressedHere(r); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + return false + } + return true +} diff --git a/internal/serve/enroll_test.go b/internal/serve/enroll_test.go new file mode 100644 index 00000000..c317ab67 --- /dev/null +++ b/internal/serve/enroll_test.go @@ -0,0 +1,56 @@ +package serve + +import ( + "context" + "errors" + "testing" + "time" +) + +type discovererFunc func(context.Context) (Listing, error) + +func (f discovererFunc) Discover(ctx context.Context) (Listing, error) { + return f(ctx) +} + +func TestDiscoverFlightOutlivesItsFirstCaller(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + discoverer := discovererFunc(func(ctx context.Context) (Listing, error) { + close(started) + select { + case <-ctx.Done(): + return Listing{}, ctx.Err() + case <-release: + return Listing{Repos: []Candidate{{Repo: "o/r"}}}, nil + } + }) + var cache discoverCache + now := time.Now().UTC() + + leaderCtx, cancelLeader := context.WithCancel(t.Context()) + leader := make(chan error, 1) + go func() { + _, err := cache.get(leaderCtx, discoverer, now, false) + leader <- err + }() + <-started + + waiter := make(chan error, 1) + go func() { + listing, err := cache.get(t.Context(), discoverer, now, false) + if err == nil && (len(listing.Repos) != 1 || listing.Repos[0].Repo != "o/r") { + err = errors.New("shared discovery returned the wrong listing") + } + waiter <- err + }() + + cancelLeader() + if err := <-leader; !errors.Is(err, context.Canceled) { + t.Fatalf("first caller error = %v, want cancellation", err) + } + close(release) + if err := <-waiter; err != nil { + t.Fatalf("remaining waiter lost the shared discovery: %v", err) + } +} diff --git a/internal/serve/events.go b/internal/serve/events.go new file mode 100644 index 00000000..212ca8be --- /dev/null +++ b/internal/serve/events.go @@ -0,0 +1,274 @@ +package serve + +import ( + "fmt" + "sync" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +// Event is one thing that happened, derived by comparing consecutive state +// revisions. +// +// This is deliberately NOT an audit log, and the UI says so. Only the process +// running `crq watch` emits real events; everything else — the daemon, the CLI, +// the other host — changes state silently. Diffing revisions is the only way to +// see all of it, and it costs two honest limitations: the feed starts when this +// server starts, and anything that happens and reverts between two polls is +// never seen. +type Event struct { + At time.Time `json:"at"` + Kind string `json:"kind"` + Level string `json:"level"` // ok|warn|bad|info + Repo string `json:"repo,omitempty"` + PR int `json:"pr,omitempty"` + Head string `json:"head,omitempty"` + Text string `json:"text"` + Detail string `json:"detail,omitempty"` +} + +// eventLog is a bounded ring. Old events are dropped rather than paged: this is +// a "what just happened" panel, and pretending to hold history would invite +// someone to rely on it. +type eventLog struct { + mu sync.Mutex + events []Event + max int +} + +func newEventLog(max int) *eventLog { return &eventLog{max: max} } + +func (l *eventLog) add(events ...Event) { + if len(events) == 0 { + return + } + l.mu.Lock() + defer l.mu.Unlock() + l.events = append(l.events, events...) + if len(l.events) > l.max { + l.events = l.events[len(l.events)-l.max:] + } +} + +// list returns newest first. +func (l *eventLog) list() []Event { + l.mu.Lock() + defer l.mu.Unlock() + out := make([]Event, 0, len(l.events)) + for i := len(l.events) - 1; i >= 0; i-- { + out = append(out, l.events[i]) + } + return out +} + +// diffStates reports what changed between two loaded revisions. The server +// suppresses this call for its first successful load: a fresh process has no +// basis to call the existing queue "new", while revision zero remains a valid +// predecessor after that load. +func diffStates(prev, next state.State, now time.Time) []Event { + if prev.Rev == next.Rev { + return nil + } + var out []Event + ev := func(kind, level, text string, r state.Round, detail string) { + out = append(out, Event{At: now, Kind: kind, Level: level, Repo: r.Repo, PR: r.PR, + Head: r.Head, Text: text, Detail: detail}) + } + + for key, cur := range next.Rounds { + old, existed := prev.Rounds[key] + switch { + case !existed: + what := "Enqueued" + if cur.CoOnly { + what = "Enqueued for co-review only" + } + ev("enqueued", "info", what, cur, cur.Note) + case old.Head != cur.Head: + ev("head", "info", "New head pushed", cur, + fmt.Sprintf("was %s — the previous round was superseded", old.Head)) + case old.Phase != cur.Phase: + level, text := "info", string(cur.Phase) + switch cur.Phase { + case state.PhaseFired: + level, text = "ok", "Review requested" + case state.PhaseReviewing: + level, text = "ok", "Acknowledged — the fire slot was released" + case state.PhaseCompleted: + level, text = "ok", "Round completed" + case state.PhaseAwaitingRetry: + level, text = "warn", "Parked for retry" + case state.PhaseReserved: + text = "Took the fire slot" + } + ev("phase", level, text, cur, cur.Note) + } + } + + // A round that vanished from Rounds ended; the archive says how. + archived := map[string]state.Round{} + for _, r := range next.Archive { + archived[state.Key(r.Repo, r.PR)] = r + } + for key, old := range prev.Rounds { + if _, still := next.Rounds[key]; still { + continue + } + if r, ok := archived[key]; ok { + ev("ended", "info", "Round ended — "+string(r.Phase), r, r.Note) + continue + } + ev("ended", "info", "Round cleared", old, old.Note) + } + + for key, h := range next.Holds { + if _, had := prev.Holds[key]; had { + continue + } + repo, pr := splitKey(key) + out = append(out, Event{At: now, Kind: "hold", Level: "bad", Repo: repo, PR: pr, + Text: "Held by " + h.By, Detail: h.Reason}) + } + for key := range prev.Holds { + if _, still := next.Holds[key]; still { + continue + } + repo, pr := splitKey(key) + out = append(out, Event{At: now, Kind: "unhold", Level: "ok", Repo: repo, PR: pr, + Text: "Hold lifted — it rejoins the queue"}) + } + + for key, d := range next.Dispatches { + if _, had := prev.Dispatches[key]; had { + continue + } + repo, pr := splitKey(key) + out = append(out, Event{At: now, Kind: "fixing", Level: "ok", Repo: repo, PR: pr, + Text: "Fix session started on " + hostOf(d.Host), + Detail: fmt.Sprintf("attempt %d", d.Attempts)}) + } + for key := range prev.Dispatches { + if _, still := next.Dispatches[key]; still { + continue + } + repo, pr := splitKey(key) + out = append(out, Event{At: now, Kind: "fixed", Level: "info", Repo: repo, PR: pr, + Text: "Fix session finished"}) + } + + // Quota is the fleet's most consequential state, so both edges are events. + prevBlocked := prev.Account.BlockedUntil != nil && prev.Account.BlockedUntil.After(now) + nextBlocked := next.Account.BlockedUntil != nil && next.Account.BlockedUntil.After(now) + switch { + case !prevBlocked && nextBlocked: + out = append(out, Event{At: now, Kind: "quota", Level: "warn", + Text: "CodeRabbit quota blocked", + Detail: "reopens " + next.Account.BlockedUntil.Format("15:04") + " — co-review and autofix continue"}) + case prevBlocked && !nextBlocked: + out = append(out, Event{At: now, Kind: "quota", Level: "ok", + Text: "Quota window reopened — metered reviews can fire again"}) + } + + for host, cur := range next.AutofixByHost { + old := prev.AutofixByHost[host] + if cur.ConsecutiveFailures > old.ConsecutiveFailures { + out = append(out, Event{At: now, Kind: "autofix", Level: "bad", + Text: fmt.Sprintf("Fix session failed on %s (%d in a row)", host, cur.ConsecutiveFailures), + Detail: cur.LastError}) + } + } + + for _, repo := range changedSettings(prev.Repos, next.Repos, + func(v state.RepoReviewers) *time.Time { return v.UpdatedAt }) { + cur := next.Repos[repo] + out = append(out, Event{At: now, Kind: "settings", Level: "info", Repo: repo, + Text: "Reviewer override changed", Detail: "by " + cur.By}) + } + for _, repo := range changedSettings(prev.RepoAutofix, next.RepoAutofix, + func(v state.RepoAutofixSwitch) *time.Time { return v.UpdatedAt }) { + cur := next.RepoAutofix[repo] + text := "Autofix turned off" + if cur.Enabled { + text = "Autofix turned on" + } + out = append(out, Event{At: now, Kind: "settings", Level: "info", Repo: repo, + Text: text, Detail: cur.Reason}) + } + if !sameTime(prev.Fleet.UpdatedAt, next.Fleet.UpdatedAt) { + text, detail := "Fleet defaults changed", "by "+next.Fleet.By + if next.Fleet.UpdatedAt == nil { + text, detail = "Fleet defaults cleared", "hosts use their own configuration again" + } + out = append(out, Event{At: now, Kind: "settings", Level: "info", + Text: text, Detail: detail}) + } + for _, repo := range changedSettings(prev.Enrolled, next.Enrolled, + func(v state.RepoEnrollment) *time.Time { return v.UpdatedAt }) { + cur := next.Enrolled[repo] + text := "Repository removed from review" + if cur.Enabled { + text = "Repository enrolled for review" + } + out = append(out, Event{At: now, Kind: "settings", Level: "info", Repo: repo, + Text: text, Detail: cur.Reason}) + } + for _, repo := range changedSettings(prev.RepoSolver, next.RepoSolver, + func(v state.SolverSettings) *time.Time { return v.UpdatedAt }) { + cur := next.RepoSolver[repo] + out = append(out, Event{At: now, Kind: "settings", Level: "info", Repo: repo, + Text: "Fix-session settings changed", Detail: "by " + cur.By}) + } + + // Clearing an override is as much a change as setting one — it hands the + // repo back to the fleet default, which is rarely what was there before. + for _, repo := range clearedSettings(prev.Repos, next.Repos) { + out = append(out, Event{At: now, Kind: "settings", Level: "info", Repo: repo, + Text: "Reviewer override cleared — back to the fleet default"}) + } + for _, repo := range clearedSettings(prev.RepoAutofix, next.RepoAutofix) { + out = append(out, Event{At: now, Kind: "settings", Level: "info", Repo: repo, + Text: "Autofix override cleared — back to the fleet default"}) + } + for _, repo := range clearedSettings(prev.Enrolled, next.Enrolled) { + out = append(out, Event{At: now, Kind: "settings", Level: "info", Repo: repo, + Text: "Enrollment decision cleared — back to this host's configuration"}) + } + for _, repo := range clearedSettings(prev.RepoSolver, next.RepoSolver) { + out = append(out, Event{At: now, Kind: "settings", Level: "info", Repo: repo, + Text: "Fix-session settings cleared — back to the fleet default"}) + } + + return out +} + +func changedSettings[T any](prev, next map[string]T, updatedAt func(T) *time.Time) []string { + var changed []string + for key, cur := range next { + old, had := prev[key] + if !had || !sameTime(updatedAt(old), updatedAt(cur)) { + changed = append(changed, key) + } + } + return changed +} + +func clearedSettings[T any](prev, next map[string]T) []string { + var cleared []string + for key := range prev { + if _, still := next[key]; !still { + cleared = append(cleared, key) + } + } + return cleared +} + +func sameTime(a, b *time.Time) bool { + switch { + case a == nil && b == nil: + return true + case a == nil || b == nil: + return false + } + return a.Equal(*b) +} diff --git a/internal/serve/events_test.go b/internal/serve/events_test.go new file mode 100644 index 00000000..7d8e072c --- /dev/null +++ b/internal/serve/events_test.go @@ -0,0 +1,75 @@ +package serve + +import ( + "testing" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +func TestDiffStatesReportsSharedSettingsAndClearEdges(t *testing.T) { + at := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + prev := state.New() + prev.Rev = 1 + next := prev + next.Rev = 2 + next.Fleet.MinInterval = "2m" + next.Fleet.By, next.Fleet.UpdatedAt = "atlas", &at + next.Enrolled = map[string]state.RepoEnrollment{ + "o/enrolled": {Enabled: true, By: "atlas", UpdatedAt: &at}, + } + next.RepoSolver = map[string]state.SolverSettings{ + "o/solver": {Prompt: "use bun", By: "atlas", UpdatedAt: &at}, + } + + events := diffStates(prev, next, at) + for _, want := range []string{ + "Fleet defaults changed", + "Repository enrolled for review", + "Fix-session settings changed", + } { + if !hasEventText(events, want) { + t.Errorf("events = %+v, want %q", events, want) + } + } + + cleared := next + cleared.Rev = 3 + cleared.Fleet = state.FleetDefaults{} + cleared.Enrolled = nil + cleared.RepoSolver = nil + events = diffStates(next, cleared, at.Add(time.Minute)) + for _, want := range []string{ + "Fleet defaults cleared", + "Enrollment decision cleared — back to this host's configuration", + "Fix-session settings cleared — back to the fleet default", + } { + if !hasEventText(events, want) { + t.Errorf("clear events = %+v, want %q", events, want) + } + } +} + +func TestDiffStatesReportsTheFirstMutationAfterARevisionZeroLoad(t *testing.T) { + at := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + prev := state.New() + next := prev + next.Rev = 1 + next.Enrolled = map[string]state.RepoEnrollment{ + "o/enrolled": {Enabled: true, By: "atlas", UpdatedAt: &at}, + } + + events := diffStates(prev, next, at) + if !hasEventText(events, "Repository enrolled for review") { + t.Fatalf("events = %+v, want the revision-zero predecessor to produce a diff", events) + } +} + +func hasEventText(events []Event, want string) bool { + for _, event := range events { + if event.Text == want { + return true + } + } + return false +} diff --git a/internal/serve/explain.go b/internal/serve/explain.go new file mode 100644 index 00000000..ea21a6d0 --- /dev/null +++ b/internal/serve/explain.go @@ -0,0 +1,212 @@ +package serve + +import ( + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +// splitKey undoes state.Key. A malformed key keeps its whole text as the repo +// rather than being dropped: a row with a strange name is debuggable, a missing +// row is not. +func splitKey(key string) (string, int) { + i := strings.LastIndex(key, "#") + if i < 0 { + return key, 0 + } + pr, err := strconv.Atoi(key[i+1:]) + if err != nil { + return key, 0 + } + return key[:i], pr +} + +// hostOf reduces "host=mbp pid=42 run=abc" to "mbp". The pid and run id matter +// when reading a state dump; on screen they are noise. +func hostOf(by string) string { + return state.WriterHost(by) +} + +// nextForRound says what happens next in words. The phase vocabulary stays +// visible in the UI, but nobody should need it to understand the row. +func nextForRound(r state.Round, row RoundRow) string { + var waiting []string + for _, b := range row.Bots { + switch b.Mark { + case "commanded": + if b.Primary { + waiting = append(waiting, b.Name) + } + case "claimed": + waiting = append(waiting, b.Name) + } + } + switch r.Phase { + case state.PhaseReserved: + return "Reserved the fire slot; the review command goes out next." + case state.PhaseFired: + return "Review requested; waiting for the bot to acknowledge it." + case state.PhaseReviewing: + if len(waiting) > 0 { + return fmt.Sprintf("Acknowledged and the slot is released; waiting on %s.", + joinWords(waiting)) + } + return "Acknowledged and the slot is released; waiting for the review to land." + } + return "" +} + +// nextForQueued turns Queue()'s why-reason into a sentence. It never invents a +// start time for a round behind the front — that is exactly what Queue refuses +// to claim, and the UI must not claim it either. +func nextForQueued(row QueueRow) string { + quota := "" + if row.CoOnly { + quota = " Co-reviewers only, so it spends no quota." + } + switch row.Why { + case "": + return "Ready — fires on the daemon's next pass." + quota + case state.WaitAccountBlocked: + return "Waits for the CodeRabbit quota window, then its turn in the queue." + case state.WaitSlotBusy: + return "Another PR holds the fire slot; this one moves when that finishes." + case state.WaitPacing: + return "Pacing between fires — the queue spaces reviews out." + quota + case state.WaitCoolingDown: + return fmt.Sprintf("Cooling down after %s; it retries when the window opens.", + plural(row.Attempts, "attempt")) + case state.WaitBehind: + return "Ready, but queued behind an earlier round." + quota + } + return "" +} + +// headline follows the Markdown dashboard's precedence exactly. Two dashboards +// disagreeing about what matters most would be worse than either alone. +func headline(st state.State, now time.Time, ov Overview) Headline { + if s := stranded(st, now); s != "" { + return Headline{Kind: "stranded", Subject: s, + Text: "Stranded reservation on " + s, + Detail: "It holds a reservation with no fire slot behind it, and will not clear on its own."} + } + if b := st.Account.BlockedUntil; b != nil && b.After(now) { + return Headline{Kind: "blocked", + Text: "CodeRabbit quota blocked", + Detail: "Only the metered lane is paused — co-review and autofix continue."} + } + if r := st.SlotRound(); r != nil { + key := state.Key(r.Repo, r.PR) + return Headline{Kind: "reviewing", Subject: key, Text: "Reviewing " + key} + } + if len(ov.InFlight) > 0 { + return Headline{Kind: "awaiting", + Text: fmt.Sprintf("Awaiting feedback on %s", plural(len(ov.InFlight), "pull request"))} + } + if len(ov.Queue) > 0 { + return Headline{Kind: "queued", Text: fmt.Sprintf("%s waiting", plural(len(ov.Queue), "round"))} + } + if len(ov.Held) > 0 { + return Headline{Kind: "held", Text: fmt.Sprintf("%s held", plural(len(ov.Held), "pull request"))} + } + return Headline{Kind: "idle", Text: "Idle", Detail: "Nothing in flight, nothing queued."} +} + +// attention collects the things that will not fix themselves, worst first. +func attention(st state.State, now time.Time, ov Overview) []Attention { + out := []Attention{} + if s := stranded(st, now); s != "" { + repo, pr := splitKey(s) + out = append(out, Attention{Kind: "stranded", Level: "bad", Subject: s, + Text: "Stranded reservation on " + s, + Detail: "Cancel the round to release it, or wait for the daemon to normalise.", + Link: prLink(repo, pr), LinkText: "Open the pull request"}) + } + for _, h := range ov.Autofix.Hosts { + if h.Health == "unhealthy" { + out = append(out, Attention{Kind: "host", Level: "bad", Subject: h.Name, + Text: fmt.Sprintf("Autofix failing on %s — %s in a row", + h.Name, plural(h.Failures, "attempt")), + Detail: h.LastError, Link: "#/setup", LinkText: "Open hosts"}) + } + } + if ov.Leader == nil { + out = append(out, Attention{Kind: "leader", Level: "warn", + Text: "No daemon holds the leader lease", + Detail: "Enqueued work will not fire on its own until one starts.", + Link: "#/setup", LinkText: "Open setup"}) + } else if ov.Leader.Expired { + out = append(out, Attention{Kind: "leader", Level: "warn", Subject: ov.Leader.Host, + Text: "The leader lease has expired", + Detail: "The last daemon was " + ov.Leader.Host + "; nothing is driving the queue.", + Link: "#/setup", LinkText: "Open setup"}) + } + // The weekly fair-use threshold does not stop reviews, it slows every one + // of them to about one an hour — an ~80% collapse that used to arrive with + // no warning at all. It earns a place here only once it is close. + if fu := ov.Quota.FairUse; fu.Limit > 0 && fu.Level != "ok" { + level := "warn" + text := fmt.Sprintf("%d of %d metered reviews used this week", fu.Fires, fu.Limit) + if fu.Level == "over" { + level = "bad" + text = fmt.Sprintf("Past the weekly fair-use threshold (%d of %d)", fu.Fires, fu.Limit) + } + out = append(out, Attention{Kind: "fairuse", Level: level, Text: text, Detail: fu.Note, + Link: "#/settings", LinkText: "Fleet settings"}) + } + if st.Warn != "" { + out = append(out, Attention{Kind: "state", Level: "warn", Text: st.Warn}) + } + return out +} + +// stranded is the reserved-round-with-no-slot case: it never clears itself, so +// it outranks everything else on the screen. +func stranded(st state.State, now time.Time) string { + if st.SlotHeld(now) { + return "" + } + keys := make([]string, 0, len(st.Rounds)) + for key, r := range st.Rounds { + if r.Phase == state.PhaseReserved { + keys = append(keys, key) + } + } + if len(keys) == 0 { + return "" + } + sort.Strings(keys) + return keys[0] +} + +func plural(n int, word string) string { + if n == 1 { + return "1 " + word + } + return strconv.Itoa(n) + " " + word + "s" +} + +func joinWords(items []string) string { + switch len(items) { + case 0: + return "" + case 1: + return items[0] + case 2: + return items[0] + " and " + items[1] + } + return strings.Join(items[:len(items)-1], ", ") + " and " + items[len(items)-1] +} + +// prLink is the dashboard's own route for a pull request, so an attention item +// can point at the page that can act on it rather than only describing it. +func prLink(repo string, pr int) string { + if repo == "" || pr <= 0 { + return "" + } + return fmt.Sprintf("#/pr/%s/%d", repo, pr) +} diff --git a/internal/serve/fleet.go b/internal/serve/fleet.go new file mode 100644 index 00000000..7a02ae45 --- /dev/null +++ b/internal/serve/fleet.go @@ -0,0 +1,1004 @@ +package serve + +import ( + "os/exec" + "sort" + "strconv" + "strings" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +// FleetConfig is the configuration the dashboard displays. It is a copy rather +// than a reference to crq.Config so this package never imports the orchestrator +// (which would make the dependency graph a cycle) and so what the UI can see is +// an explicit, reviewable list. +type FleetConfig struct { + GateRepo string `json:"gate_repo"` + StateRef string `json:"state_ref"` + DashboardIssue int `json:"dashboard_issue,omitempty"` + CalibrationPR int `json:"calibration_pr,omitempty"` + Scope []string `json:"scope,omitempty"` + AllowRepos []string `json:"allow_repos,omitempty"` + ExcludeRepos []string `json:"exclude_repos,omitempty"` + SkipAuthors []string `json:"skip_authors,omitempty"` + SkipMarker string `json:"skip_marker,omitempty"` + + MinInterval Dur `json:"min_interval"` + InflightTimeout Dur `json:"inflight_timeout"` + WatchInterval Dur `json:"watch_interval"` + + Reviewers []ReviewerCfg `json:"reviewers"` + + AutofixCommand []string `json:"autofix_command,omitempty"` + AutofixMaxAttempts int `json:"autofix_max_attempts,omitempty"` + AutofixConcurrency int `json:"autofix_concurrency,omitempty"` + AutofixForks bool `json:"autofix_forks,omitempty"` + WorkspaceRoot string `json:"workspace_root,omitempty"` +} + +// Dur renders as the duration string a person would type into the env file, +// not as nanoseconds. +type Dur time.Duration + +func (d Dur) MarshalJSON() ([]byte, error) { + return []byte(`"` + time.Duration(d).String() + `"`), nil +} + +type ReviewerCfg struct { + Login string `json:"login"` + Name string `json:"name"` + Primary bool `json:"primary"` + Required bool `json:"required"` + Metered bool `json:"metered"` + Command string `json:"command,omitempty"` + Trigger string `json:"trigger,omitempty"` + Grace Dur `json:"grace,omitempty"` +} + +// Snapshot is everything the dashboard reads, reduced once per state change. +// One payload keeps every page consistent: two endpoints could otherwise +// disagree about the same revision. +type Snapshot struct { + Overview Overview `json:"overview"` + Repos []RepoRow `json:"repos"` + Bots []BotCard `json:"bots"` + Setup SetupView `json:"setup"` + Settings SettingsView `json:"settings"` + // Events is live-only, since this server started. See events.go. + Events []Event `json:"events"` + // Stale says this snapshot is the last one that loaded and the state ref has + // not been readable since. Everything else here is a past that may already + // have moved, and a dashboard presenting it as live is the one failure a + // live dashboard must not have. + Stale *Staleness `json:"stale,omitempty"` +} + +// Staleness is why the snapshot stopped being current, and since when. +type Staleness struct { + Error string `json:"error"` + Since time.Time `json:"since"` +} + +// RepoRow is one repository as the Repos page lists it. +type RepoRow struct { + Repo string `json:"repo"` + // Enrollment is where the decision comes from, not merely whether it is on: + // a repo forced in by a host's env file cannot be turned off from here, and + // saying "managed" would invite someone to try. + Enrollment string `json:"enrollment"` // state|env|excluded|scope|off + EnvHost string `json:"env_host,omitempty"` + // Reviewed is the resolved answer; Enrollment is only where it came from. + Reviewed bool `json:"reviewed"` + EnvConflict bool `json:"env_conflict,omitempty"` + ClearEnables bool `json:"clear_enables,omitempty"` + EnrollReason string `json:"enroll_reason,omitempty"` + EnrollBy string `json:"enroll_by,omitempty"` + EnrollAt *time.Time `json:"enroll_at,omitempty"` + + // Reviewers/Required are the RESOLVED sets — what will actually run here, + // not the raw override. PrimaryOff is called out separately because it is + // the one absence a reader would otherwise misread as a fleet without a + // metered reviewer at all. + Reviewers []string `json:"reviewers"` + Required []string `json:"required"` + PrimaryOff bool `json:"primary_off,omitempty"` + Override bool `json:"override"` + OverrideBy string `json:"override_by,omitempty"` + OverrideAt *time.Time `json:"override_at,omitempty"` + + Autofix string `json:"autofix"` // default|on|off + AutofixReason string `json:"autofix_reason,omitempty"` + AutofixBy string `json:"autofix_by,omitempty"` + AutofixAt *time.Time `json:"autofix_at,omitempty"` + + // Solver is how a fix session runs here, resolved through env → fleet → + // this repository, with a source per setting. + Solver *RepoSolver `json:"solver,omitempty"` + + ActiveRounds int `json:"active_rounds"` + QueuedRounds int `json:"queued_rounds"` + HeldPRs int `json:"held_prs"` + Fixing int `json:"fixing"` +} + +// RepoSolver mirrors crq.SolverView on the wire. +type RepoSolver struct { + Overridden bool `json:"overridden"` + Agent string `json:"agent,omitempty"` + Models []string `json:"models"` + ModelChoices []string `json:"model_choices"` + Model string `json:"model,omitempty"` + Effort string `json:"effort,omitempty"` + Prompt string `json:"prompt,omitempty"` + MaxAttempts int `json:"max_attempts"` + Severities []string `json:"severities"` + AskMode string `json:"ask_mode"` + Forks bool `json:"forks"` + SkipAuthors []string `json:"skip_authors"` + Sources map[string]string `json:"sources"` + By string `json:"by,omitempty"` + Lagging []string `json:"lagging_hosts,omitempty"` + // AgentOn says, per host, whether the configured fix agent is reachable + // there. Capability, not policy: a repository can be set to a model no + // host can run, and the settings alone would never say so. + AgentOn []HostHas `json:"agent_on,omitempty"` +} + +// HostHas is one host's answer to "can you run this". +type HostHas struct { + Host string `json:"host"` + // Has is nil when that host has never reported, which is not the same as + // "no" — and saying no would blame a machine for crq's own blind spot. + Has *bool `json:"has,omitempty"` + Path string `json:"path,omitempty"` + Stale bool `json:"stale,omitempty"` +} + +// SolverFor resolves one repository's fix-session settings. Supplied by the +// command layer for the same reason the reviewer resolver is: the layering +// belongs to internal/crq, and two answers to it would be one too many. +type SolverFor func(st state.State, repo string) RepoSolver + +// HostTools is one machine's self-report. +type HostTools struct { + Host string `json:"host"` + Agent string `json:"agent,omitempty"` + Version string `json:"version,omitempty"` + Caps int `json:"caps,omitempty"` + Roles []string `json:"roles,omitempty"` + Tools []ToolSeen `json:"tools"` + At *time.Time `json:"at,omitempty"` + // Stale says the host has not reported recently, so everything above is + // what it LAST said rather than what is true now. + Stale bool `json:"stale,omitempty"` + // Behind marks a host running an older crq than the newest reporting one — + // the single most common cause of "that setting did nothing". + Behind bool `json:"behind,omitempty"` +} + +type ToolSeen struct { + Name string `json:"name"` + Path string `json:"path,omitempty"` + Version string `json:"version,omitempty"` +} + +// BotCard is one reviewer on the Bots page. "Last seen" is deliberately what +// crq itself recorded — a trigger it posted or a claim it observed — rather +// than a vendor status we would have to guess at. +type BotCard struct { + Login string `json:"login"` + Name string `json:"name"` + Primary bool `json:"primary"` + Metered bool `json:"metered"` + Enabled bool `json:"enabled"` + Required bool `json:"required"` + Configurable bool `json:"configurable"` + Command string `json:"command,omitempty"` + Trigger string `json:"trigger,omitempty"` + Grace Dur `json:"grace,omitempty"` + // LastSeen is when crq observed this bot ANSWER; LastAsked is when crq last + // posted its trigger. A bot that has been asked and never answered is the + // case worth surfacing, and needs both to state it. + LastSeen *time.Time `json:"last_seen,omitempty"` + LastAsked *time.Time `json:"last_asked,omitempty"` + SeenOn string `json:"seen_on,omitempty"` + RepoCount int `json:"repo_count"` + + // Status is what crq can honestly say about setup, from its OWN records — + // a trigger it posted, a claim it saw — never a status read from the vendor: + // + // working crq saw it answer within the week + // quiet it answered once, but not lately + // silent crq has asked it and never seen an answer — the case that + // matters, and the whole reason answering is tracked apart + // from asking + // unverified enabled, but crq has never even asked it here + // off not enabled on this fleet + Status string `json:"status"` + + // The guide half. Everything below describes the vendor rather than crq's + // configuration of it, and none of it affects a decision crq makes. + Site string `json:"site,omitempty"` + Docs string `json:"docs,omitempty"` + Pitch string `json:"pitch,omitempty"` + Cost string `json:"cost,omitempty"` + Setup []string `json:"setup,omitempty"` + SuitedTo string `json:"suited_to,omitempty"` + // PricesCheckedAt dates the cost line, because an undated price is a price + // that has quietly stopped being true. + PricesCheckedAt string `json:"prices_checked_at,omitempty"` + // Suggested says crq has a REASON to recommend this bot here, and Because + // is that reason. A badge with no stated criterion is an advertisement. + Suggested bool `json:"suggested,omitempty"` + Because string `json:"because,omitempty"` +} + +type SetupView struct { + Checks []Check `json:"checks"` + Tools []Tool `json:"tools"` + Hosts []HostInfo `json:"hosts"` + // Fleet is every host's own report of what it can reach — the answer to + // "is claude installed" that is actually useful, since it differs per host + // and, on any one host, between the shell and the service. + Fleet []HostTools `json:"fleet,omitempty"` + // Ready/Attention/Optional summarise the checks, so the page opens with a + // verdict instead of a list to count. + Ready int `json:"ready"` + Attention int `json:"attention"` + Optional int `json:"optional"` + // ToolsHost names the machine the tool list describes. crq stores no tool + // inventory for other hosts, so claiming a fleet-wide view would be a lie. + ToolsHost string `json:"tools_host"` +} + +type Check struct { + Key string `json:"key"` + Label string `json:"label"` + Status string `json:"status"` // ok|warn|bad|unknown + Detail string `json:"detail,omitempty"` +} + +type Tool struct { + Name string `json:"name"` + Purpose string `json:"purpose"` + Required bool `json:"required"` + Found bool `json:"found"` + Path string `json:"path,omitempty"` + // Fix is what to run when it is missing. A checklist that reports a + // problem and leaves you to search for the remedy is a checklist that has + // done the easy half. + Fix []string `json:"fix,omitempty"` +} + +type HostInfo struct { + Name string `json:"name"` + Roles []string `json:"roles,omitempty"` + Health string `json:"health,omitempty"` + LastSeen *time.Time `json:"last_seen,omitempty"` + Failures int `json:"failures,omitempty"` + LastError string `json:"last_error,omitempty"` + // Caps is the capability bitmask the writer reported. A host on an older + // binary reports fewer bits and silently ignores settings it cannot honor. + Caps int `json:"caps,omitempty"` +} + +type SettingsView struct { + Config FleetConfig `json:"config"` + Quota Quota `json:"quota"` + Plumbing []KV `json:"plumbing"` + // Env is every individual setting with its effective value and the layer + // that decided it. This is what makes "I see env all over the dashboard" + // actionable rather than just true. + Env []EnvSetting `json:"env,omitempty"` + // Fleet is the editable half: the defaults recorded for the whole fleet, + // with a source per setting so a reader can tell which values changing here + // would actually change everywhere, and which are this host's env alone. + Fleet *FleetSettings `json:"fleet,omitempty"` +} + +// EnvSetting is one configuration setting as the dashboard shows it. +type EnvSetting struct { + Key string `json:"key"` + Kind string `json:"kind"` + Group string `json:"group"` + Label string `json:"label"` + Help string `json:"help"` + // PerHost and Identity say WHY a setting is not editable here, which is a + // more useful answer than a disabled control with no explanation. + PerHost bool `json:"per_host,omitempty"` + Identity bool `json:"identity,omitempty"` + // ReviewImpact marks settings whose save can reopen completed rounds. Those + // go through the same live preview and revision-bound confirmation as the + // fleet reviewer editor. + ReviewImpact bool `json:"review_impact,omitempty"` + + Value string `json:"value"` + Source string `json:"source"` // fleet | env | default + // HostValue is what this machine's own environment says, shown only when a + // fleet record is overriding it — otherwise the override is invisible. + HostValue string `json:"host_value,omitempty"` +} + +// FleetSettings mirrors crq.FleetView on the wire. +type FleetSettings struct { + Recorded bool `json:"recorded"` + Reviewers []FleetReviewer `json:"reviewers"` + MinInterval string `json:"min_interval"` + WeeklyLimit int `json:"weekly_limit"` + AutofixDefault bool `json:"autofix_default"` + Sources map[string]string `json:"sources"` + Overriding []string `json:"overriding,omitempty"` + By string `json:"by,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Lagging []string `json:"lagging_hosts,omitempty"` +} + +type FleetReviewer struct { + Login string `json:"login"` + Budget string `json:"budget"` + Required bool `json:"required"` + Trigger string `json:"trigger,omitempty"` +} + +// FleetImpact is what a proposed fleet change would do, shown before it is made. +type FleetImpact struct { + Rev int64 `json:"rev"` + Repos int `json:"repos"` + Reopened int `json:"reopened"` + Overridden int `json:"overridden"` + Changes []string `json:"changes"` + Summary string `json:"summary"` +} + +type KV struct { + Key string `json:"key"` + Value string `json:"value"` + Detail string `json:"detail,omitempty"` +} + +// BuildFleet reduces everything the non-overview pages read. +func BuildFleet(st state.State, cfg FleetConfig, ov Overview, tools []Tool, toolsHost string, now time.Time, botsFor BotsFor, enrollFor EnrollFor, fleet *FleetSettings, solverFor SolverFor, env []EnvSetting) Snapshot { + return Snapshot{ + Overview: ov, + Repos: repoRows(st, cfg, now, botsFor, enrollFor, solverFor), + Bots: botCards(st, cfg, botsFor, now), + Setup: setupView(st, cfg, ov, tools, toolsHost), + Settings: SettingsView{Config: cfg, Quota: ov.Quota, Plumbing: plumbing(st, cfg), Fleet: fleet, Env: env}, + } +} + +func repoRows(st state.State, cfg FleetConfig, now time.Time, botsFor BotsFor, enrollFor EnrollFor, solverFor SolverFor) []RepoRow { + rows := map[string]*RepoRow{} + get := func(repo string) *RepoRow { + key := strings.ToLower(repo) + if r, ok := rows[key]; ok { + return r + } + r := &RepoRow{Repo: repo, Enrollment: "off", Autofix: "default"} + rows[key] = r + return r + } + + // Every source that can mention a repo contributes a row. A repo that only + // appears as a hold still belongs in the list: a hold is an operator + // decision, and dropping it would hide one. + for _, r := range st.Rounds { + row := get(r.Repo) + switch r.Phase { + case state.PhaseQueued, state.PhaseAwaitingRetry: + row.QueuedRounds++ + row.ActiveRounds++ + case state.PhaseReserved, state.PhaseFired, state.PhaseReviewing: + row.ActiveRounds++ + } + } + for key := range st.Holds { + repo, _ := splitKey(key) + get(repo).HeldPRs++ + } + for key, d := range st.Dispatches { + // Live claims only, as the sessions table counts them: a crashed watcher's + // claim outlives its process and must not leave a repository reading as + // permanently under repair. + if !d.Live(now) { + continue + } + repo, _ := splitKey(key) + get(repo).Fixing++ + } + for repo, rv := range st.Repos { + row := get(repo) + row.Override = rv.SetCoBots || rv.SetRequired || rv.PrimaryOff + row.OverrideBy, row.OverrideAt = rv.By, rv.UpdatedAt + row.PrimaryOff = rv.PrimaryOff + } + for repo, sw := range st.RepoAutofix { + row := get(repo) + if sw.Enabled { + row.Autofix = "on" + } else { + row.Autofix = "off" + } + row.AutofixReason, row.AutofixBy, row.AutofixAt = sw.Reason, sw.By, sw.UpdatedAt + } + for repo := range cfg.allowSet() { + get(repo) + } + // A repository turned off from here has no rounds, no holds and no env + // mention, so nothing above would list it — and an "off" nobody can see is + // how a project quietly stops being reviewed. + for _, repo := range st.EnrolledRepos() { + get(repo) + } + // Solver settings are accepted for any repository, including one recorded + // before it had a round or an enrollment. Its row is where that record is + // read and cleared, so leaving it out hides an override nothing else shows. + for _, repo := range st.SolverRepos() { + get(repo) + } + + // Resolved, not merged here: an override names co-reviewers by login while + // the fleet default names them by short name, and half a repository's + // answer (say, only its required set) still inherits the other half. Asking + // the resolver for every row is the only way the list means one thing. + for _, row := range rows { + row.Reviewers, row.Required = nil, nil + for _, b := range botsFor(row.Repo) { + row.Reviewers = append(row.Reviewers, b.Name) + if b.Required { + row.Required = append(row.Required, b.Name) + } + } + if enrollFor != nil { + e := enrollFor(st, row.Repo) + row.Enrollment, row.Reviewed, row.EnvConflict, row.ClearEnables = e.Source, e.Enabled, e.EnvConflict, e.ClearEnables + row.EnrollReason, row.EnrollBy, row.EnrollAt = e.Reason, e.By, e.UpdatedAt + } else { + row.Enrollment = cfg.enrollmentOf(row.Repo) + row.Reviewed = row.Enrollment == "env" || row.Enrollment == "scope" + } + if solverFor != nil { + sv := solverFor(st, row.Repo) + row.Solver = &sv + } + } + + out := make([]RepoRow, 0, len(rows)) + for _, r := range rows { + out = append(out, *r) + } + sort.Slice(out, func(i, j int) bool { return out[i].Repo < out[j].Repo }) + return out +} + +func (c FleetConfig) allowSet() map[string]bool { + set := map[string]bool{} + for _, r := range c.AllowRepos { + set[r] = true + } + return set +} + +func (c FleetConfig) enrollmentOf(repo string) string { + lower := strings.ToLower(repo) + for _, r := range c.ExcludeRepos { + if strings.ToLower(r) == lower { + return "excluded" + } + } + for _, r := range c.AllowRepos { + if strings.ToLower(r) == lower { + return "env" + } + } + if len(c.AllowRepos) == 0 { + // With no allowlist the whole scope is in play, so a repo crq has seen + // is being reviewed by virtue of its owner. + return "scope" + } + return "unknown" +} + +func botCards(st state.State, cfg FleetConfig, botsFor BotsFor, now time.Time) []BotCard { + fleetBots := botsFor("") + seen := map[string]*time.Time{} + asked := map[string]*time.Time{} + where := map[string]string{} + noteAsked := func(login string, at *time.Time) { + if at == nil { + return + } + key := dialect.NormalizeBotName(login) + if cur, ok := asked[key]; !ok || cur == nil || at.After(*cur) { + asked[key] = at + } + } + note := func(login string, at *time.Time, repo string, pr int) { + if at == nil { + return + } + key := dialect.NormalizeBotName(login) + if cur, ok := seen[key]; !ok || cur == nil || at.After(*cur) { + seen[key] = at + where[key] = state.Key(repo, pr) + } + } + // Two different facts, kept apart on purpose. AnsweredAt is the BOT: crq + // observed it review this head. CommandedAt/ClaimedAt are crq: what it + // posted and what it claimed the right to post. Reading the second as the + // first is how a bot nobody has an account for reads as working — crq asks, + // records that it asked, and nothing ever answers. + // Whether the answer log has anything in it AT ALL, for any reviewer. It is + // written only when crq observes a round, so on a fleet that has just + // upgraded it is empty — and "no answer recorded" then means "not looked + // yet", not "never answered". Claiming the second would accuse a working bot + // of being unconfigured on the strength of a field introduced five minutes + // ago. The primary counts towards it like any other reviewer now that its + // answer is recorded rather than inferred from the phase. + answerLog := false + effectivePrimary := cfg.primaryLogin() + for _, bot := range fleetBots { + if bot.Primary { + effectivePrimary = bot.Login + break + } + } + scan := func(r state.Round) { + for login, co := range r.CoBots { + if co.AnsweredAt != nil { + answerLog = true + note(login, co.AnsweredAt, r.Repo, r.PR) + } + if at := co.CommandedAt; at != nil { + noteAsked(login, at) + } else if co.ClaimedAt != nil { + noteAsked(login, co.ClaimedAt) + } + } + // The primary is different: its round holds no per-bot entry, so the two + // facts come from two fields. FiredAt is crq's command going out — what + // it ASKED. PrimaryAnsweredAt is what crq observed the primary do. + // + // The phase cannot stand in for the second. A required set that omits the + // primary completes as soon as its co-reviewers answer and the primary + // acknowledges the metered command, so reading a completed round as + // review evidence labelled a reviewer as working on the strength of its + // own acknowledgement. + if r.FiredAt != nil && !r.CoOnly { + by := effectivePrimary + for _, posted := range r.PostedCommands { + if posted.ID == r.CommandID && posted.Bot != "" { + by = posted.Bot + break + } + } + noteAsked(by, r.FiredAt) + } + if r.PrimaryAnsweredAt != nil { + answerLog = true + // Whoever the primary was WHEN it answered, not whoever this + // process calls its primary now. A fleet that changes CRQ_BOT would + // otherwise hand the retired bot's evidence to the new one, showing + // the one that has never run as working and the one that did as + // silent. Rounds recorded before the login was stored have none, and + // the running primary is the best guess left for those. + by := r.PrimaryAnsweredBy + if by == "" { + by = effectivePrimary + } + note(by, r.PrimaryAnsweredAt, r.Repo, r.PR) + } + } + for _, r := range st.Rounds { + scan(r) + } + for _, r := range st.Archive { + scan(r) + } + + repoCount := map[string]int{} + repoBots := map[string]BotName{} + repoRequired := map[string]bool{} + repos := make([]string, 0, len(st.Repos)) + for repo := range st.Repos { + repos = append(repos, repo) + } + sort.Strings(repos) + for _, repo := range repos { + for _, b := range botsFor(repo) { + key := dialect.NormalizeBotName(b.Login) + repoCount[key]++ + repoBots[key] = b + repoRequired[key] = repoRequired[key] || b.Required + } + } + + // The EFFECTIVE reviewer set, which is env plus whatever the fleet recorded + // — not cfg.Reviewers, which is this server's startup environment and would + // keep showing the old answer after a fleet default changed it. + running := map[string]BotName{} + for _, b := range fleetBots { + running[dialect.NormalizeBotName(b.Login)] = b + } + + // Every registry bot gets a card, running here or not. A page that lists + // only the enabled ones cannot answer "why is Bugbot not reviewing this", + // and it cannot offer the switch that would change the answer. + out := make([]BotCard, 0, len(cfg.Reviewers)+len(dialect.KnownCoReviewers())) + add := func(login, name string, primary, metered bool, from *ReviewerCfg) { + key := dialect.NormalizeBotName(login) + b, on := running[key] + if !on { + b, on = repoBots[key] + } + _, configurable := dialect.CoReviewerByName(login) + card := BotCard{ + Login: login, Name: name, Primary: primary, Metered: metered, + Enabled: on, Required: on && (b.Required || repoRequired[key]), Configurable: configurable, + LastSeen: seen[key], LastAsked: asked[key], SeenOn: where[key], + RepoCount: repoCount[key], + Status: botStatus(on, seen[key], asked[key], now, answerLog), + } + if v, ok := dialect.VendorFor(login); ok { + card.Site, card.Docs = v.Site, v.Docs + card.Pitch, card.Cost, card.Setup, card.SuitedTo = v.Pitch, v.Cost, v.Setup, v.SuitedTo + card.PricesCheckedAt = dialect.PricesCheckedAt + // A suggestion is only worth making when crq can name the evidence. + // The local signal is the honest one it has: a CLI on a host means + // an account behind it, which is the thing that decides whether a + // bot will ever answer. + // Anything crq has not seen working. That covers the two cases worth + // a nudge: a bot switched off that you evidently have an account + // for, and one switched ON that has never answered — where the CLI + // being present says the account is fine and the setup is not. + if card.Status != "working" { + if host, path := whereTool(st, name); path != "" { + card.Suggested = true + card.Because = "the " + name + " CLI is on " + host + + ", so the account behind it is probably already yours" + } + } + } + if on { + card.Command, card.Trigger, card.Grace = b.Command, b.Trigger, b.Grace + } else if from != nil { + card.Command, card.Trigger, card.Grace = from.Command, from.Trigger, from.Grace + } + out = append(out, card) + } + seenCard := map[string]bool{} + primaryKey := dialect.NormalizeBotName(effectivePrimary) + // Start with the effective fleet set, including its current primary. The + // startup config is metadata and fallback only; it must not keep the retired + // primary marked primary after shared settings replace it. + for _, b := range fleetBots { + key := dialect.NormalizeBotName(b.Login) + seenCard[key] = true + from := &ReviewerCfg{ + Login: b.Login, Name: b.Name, Primary: b.Primary, Required: b.Required, + Metered: b.Primary, Command: b.Command, Trigger: b.Trigger, Grace: b.Grace, + } + add(b.Login, b.Name, b.Primary, b.Primary, from) + } + for i := range cfg.Reviewers { + r := cfg.Reviewers[i] + key := dialect.NormalizeBotName(r.Login) + if seenCard[key] { + continue + } + seenCard[key] = true + primary := key == primaryKey + add(r.Login, r.Name, primary, primary && r.Metered, &r) + } + for _, co := range dialect.KnownCoReviewers() { + if seenCard[dialect.NormalizeBotName(co.Login)] { + continue + } + add(co.Login, co.Name, false, false, nil) + } + return out +} + +func (c FleetConfig) primaryLogin() string { + for _, r := range c.Reviewers { + if r.Primary { + return r.Login + } + } + return c.GateRepo // never matches a bot; keeps note() harmless +} + +func setupView(st state.State, cfg FleetConfig, ov Overview, tools []Tool, toolsHost string) SetupView { + v := SetupView{Tools: tools, ToolsHost: toolsHost, Checks: []Check{}, Hosts: []HostInfo{}} + v.Fleet = hostTools(st, ov.Now) + + add := func(key, label, status, detail string) { + v.Checks = append(v.Checks, Check{Key: key, Label: label, Status: status, Detail: detail}) + } + add("state", "Queue home", "ok", + cfg.GateRepo+" · ref "+cfg.StateRef+" · rev "+itoa(ov.Rev)) + if cfg.DashboardIssue > 0 { + add("dashboard", "Markdown dashboard", "ok", "issue #"+itoa(int64(cfg.DashboardIssue))) + } else { + add("dashboard", "Markdown dashboard", "warn", "not configured (CRQ_ISSUE)") + } + if cfg.CalibrationPR > 0 { + add("calibration", "Quota calibration", "ok", "PR #"+itoa(int64(cfg.CalibrationPR))) + } else { + add("calibration", "Quota calibration", "warn", "no calibration PR — quota is guessed from notices") + } + switch { + case ov.Leader == nil: + add("leader", "Review daemon", "bad", "no host holds the leader lease") + case ov.Leader.Expired: + add("leader", "Review daemon", "warn", "lease expired · last held by "+ov.Leader.Host) + default: + add("leader", "Review daemon", "ok", "leader "+ov.Leader.Host) + } + missing := 0 + for _, t := range tools { + if t.Required && !t.Found { + missing++ + } + } + if missing == 0 { + add("tools", "Required tools", "ok", "present on "+toolsHost) + } else { + add("tools", "Required tools", "bad", itoa(int64(missing))+" missing on "+toolsHost) + } + if len(ov.Autofix.Hosts) == 0 { + add("autofix", "Autofix", "unknown", "no host has reported a fix session") + } else { + bad := 0 + for _, h := range ov.Autofix.Hosts { + if h.Health == "unhealthy" { + bad++ + } + } + if bad > 0 { + add("autofix", "Autofix", "bad", itoa(int64(bad))+" host(s) failing") + } else { + add("autofix", "Autofix", "ok", itoa(int64(len(ov.Autofix.Hosts)))+" host(s) reporting") + } + } + + // Hosts merge three sources: who writes state, who runs autofix, and who + // holds the lease. + hosts := map[string]*HostInfo{} + get := func(name string) *HostInfo { + if h, ok := hosts[name]; ok { + return h + } + h := &HostInfo{Name: name} + hosts[name] = h + return h + } + for id, w := range st.Writers { + h := get(hostOf(id)) + if h.LastSeen == nil || w.At.After(*h.LastSeen) { + at := w.At + h.LastSeen = &at + h.Caps = w.Caps + } + } + for _, ah := range ov.Autofix.Hosts { + h := get(ah.Name) + h.Health, h.Failures, h.LastError = ah.Health, ah.Failures, ah.LastError + h.Roles = append(h.Roles, "autofix") + } + if ov.Leader != nil && !ov.Leader.Expired { + get(ov.Leader.Host).Roles = append(get(ov.Leader.Host).Roles, "leader") + } + for _, h := range hosts { + sort.Strings(h.Roles) + v.Hosts = append(v.Hosts, *h) + } + sort.Slice(v.Hosts, func(i, j int) bool { return v.Hosts[i].Name < v.Hosts[j].Name }) + for _, c := range v.Checks { + switch c.Status { + case "ok": + v.Ready++ + case "bad", "warn": + v.Attention++ + } + } + for _, t := range v.Tools { + switch { + case t.Found: + v.Ready++ + case t.Required: + v.Attention++ + default: + v.Optional++ + } + } + return v +} + +func plumbing(st state.State, cfg FleetConfig) []KV { + out := []KV{ + {Key: "Gate repo", Value: cfg.GateRepo, Detail: "holds the state ref, dashboard issue and calibration PR"}, + {Key: "State ref", Value: cfg.StateRef, Detail: "schema v" + itoa(int64(st.Version))}, + {Key: "Revision", Value: itoa(st.Rev)}, + } + if st.UpdatedAt != nil { + out = append(out, KV{Key: "Last written", Value: st.UpdatedAt.Format(time.RFC3339)}) + } + if st.Leader != nil { + out = append(out, KV{Key: "Leader lease", Value: hostOf(st.Leader.Owner), + Detail: "expires " + st.Leader.ExpiresAt.Format(time.RFC3339)}) + } + if cfg.DashboardIssue > 0 { + out = append(out, KV{Key: "Markdown dashboard", Value: "issue #" + itoa(int64(cfg.DashboardIssue)), + Detail: "still updated — the web dashboard is additive"}) + } + if cfg.CalibrationPR > 0 { + out = append(out, KV{Key: "Calibration PR", Value: "#" + itoa(int64(cfg.CalibrationPR))}) + } + out = append(out, KV{Key: "Writers seen", Value: itoa(int64(len(st.Writers))), Detail: "24h window"}) + return out +} + +// LocalTools probes this machine only. crq keeps no tool inventory for other +// hosts, and inventing one from a version string would be worse than saying so. +func LocalTools() []Tool { + want := []struct { + name, purpose string + required bool + fix []string + }{ + {"crq", "the binary itself — every host must run the same version", true, + []string{"go build -o ~/.local/bin/crq ./cmd/crq", "crq doctor # check for a second, older install"}}, + {"git", "repository mirrors and worktrees for fix sessions", true, nil}, + {"gh", "GitHub CLI — where the token comes from", true, + []string{"gh auth login", "crq doctor"}}, + {"claude", "fix agent — writes the fixes. Not a reviewer.", false, + []string{"npm i -g @anthropic-ai/claude-code", "crq autofix install # point the service at it"}}, + // Named the same as the review bot and completely unrelated to it: the + // Codex REVIEWER is a GitHub app and needs nothing installed here. A + // host without this CLI reviews perfectly well. + {"codex", "fix agent — unrelated to the Codex reviewer, which is a GitHub app", false, + []string{"npm i -g @openai/codex", "crq autofix install --agent \"$(command -v codex)\""}}, + {"coderabbit", "local preflight before pushing — unrelated to the CodeRabbit reviewer", false, + []string{"curl -fsSL https://cli.coderabbit.ai/install.sh | sh"}}, + {"macroscope", "local preflight before pushing — unrelated to the Macroscope reviewer", false, + []string{"see https://docs.macroscope.com for the CLI"}}, + } + out := make([]Tool, 0, len(want)) + for _, w := range want { + t := Tool{Name: w.name, Purpose: w.purpose, Required: w.required, Fix: w.fix} + if path, err := exec.LookPath(w.name); err == nil { + t.Found, t.Path = true, path + } + out = append(out, t) + } + return out +} + +func itoa(n int64) string { + return strconv.FormatInt(n, 10) +} + +// botStatus is what crq can say about a bot's setup WITHOUT asking the vendor, +// which it has no way to do for any of them. +// +// "Enabled" and "working" are different claims and the page must not merge +// them: a bot enabled by a default nobody chose, on an account nobody has, is +// exactly the case that looks configured and reviews nothing. +func botStatus(enabled bool, lastSeen, lastAsked *time.Time, now time.Time, logWarm bool) string { + switch { + case !enabled: + return "off" + case lastSeen == nil && !logWarm: + // Nothing has been observed since crq started recording answers, so + // there is no basis to say this bot has never answered. + return "unverified" + case lastSeen == nil && lastAsked != nil: + // Asked and never answered. The most useful thing the page can say, and + // the reason answering is tracked separately from asking at all. + return "silent" + case lastSeen == nil: + return "unverified" + case now.Sub(*lastSeen) <= 7*24*time.Hour: + return "working" + default: + return "quiet" + } +} + +// hostTools turns every host's self-report into the matrix the setup page +// shows: one row per host, one column per tool. +// +// A stale report is kept and marked rather than dropped. "atlas last said it +// had claude, two days ago" is a more useful thing to read than an empty row, +// and a host that has stopped reporting is itself the finding. +func hostTools(st state.State, now time.Time) []HostTools { + reports := st.HostReportList() + if len(reports) == 0 { + return nil + } + newest := "" + for _, r := range reports { + if newerVersion(r.Version, newest) { + newest = r.Version + } + } + out := make([]HostTools, 0, len(reports)) + for _, r := range reports { + at := r.At + row := HostTools{ + Host: hostOf(r.Host), Version: r.Version, Caps: r.Caps, Roles: r.Roles, Agent: r.Agent, + At: &at, Stale: now.Sub(r.At) > state.HostReportTTL, + // Behind is "not the newest crq in the fleet", so it only has to be + // as right as newest is. That is why newest is picked numerically: + // as strings, 2.9.0 outranks 2.10.0 and the table inverts — the + // upgraded hosts get the warning and the stale ones read as current. + Behind: r.Version != "" && newest != "" && r.Version != newest, + Tools: []ToolSeen{}, + } + for _, t := range r.Tools { + row.Tools = append(row.Tools, ToolSeen{Name: t.Name, Path: t.Path, Version: t.Version}) + } + out = append(out, row) + } + return out +} + +// newerVersion reports whether a is a later crq than b, comparing the +// dot-separated components as numbers. Text order is wrong the first time the +// fleet crosses a digit boundary — "2.9.0" sorts above "2.10.0" — and picking +// the wrong newest inverts the whole table. A component that is not a number +// falls back to text order, so an unexpected shape is still ordered rather than +// ignored. +func newerVersion(a, b string) bool { + if a == "" || a == b { + return false + } + if b == "" { + return true + } + as, bs := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; i < len(as) || i < len(bs); i++ { + x, y := versionPart(as, i), versionPart(bs, i) + if x == y { + continue + } + xn, xerr := strconv.Atoi(x) + yn, yerr := strconv.Atoi(y) + if xerr == nil && yerr == nil { + return xn > yn + } + return x > y + } + return false +} + +// versionPart is one component of a version, or "0" past its end: "2.1" and +// "2.1.0" are the same release. +func versionPart(parts []string, i int) string { + if i < len(parts) { + return parts[i] + } + return "0" +} + +// whereTool finds a host that reports having a tool, and where. Used for bot +// suggestions: a CLI present on a machine is evidence of an account behind it, +// which is what decides whether that bot will ever answer. +// +// It reports the FIRST host that has it rather than all of them, because the +// suggestion only needs to be true somewhere — and naming one machine reads as +// evidence where naming three reads as a survey. +func whereTool(st state.State, tool string) (string, string) { + for _, r := range st.HostReportList() { + for _, t := range r.Tools { + if t.Name == tool && t.Path != "" { + return hostOf(r.Host), t.Path + } + } + } + return "", "" +} diff --git a/internal/serve/icons.go b/internal/serve/icons.go new file mode 100644 index 00000000..1366f496 --- /dev/null +++ b/internal/serve/icons.go @@ -0,0 +1,357 @@ +package serve + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// Icons resolves the small images the dashboard shows: a repository's own +// favicon, and a review bot's GitHub avatar. +// +// Both are fetched lazily and cached, including misses. A repository without a +// favicon is the common case, and re-discovering that on every render would +// spend the shared REST budget on nothing. The browser never talks to GitHub +// itself — that would leak the token or fail on private repos. +type Icons struct { + token string + lookupToken func(context.Context) string + client *http.Client + + mu sync.Mutex + cache map[string]*icon +} + +type icon struct { + body []byte + contentType string + expires time.Time + // missing distinguishes "we looked and there is nothing" from "not looked + // at yet", so a miss is cached rather than retried on every paint. + missing bool +} + +const ( + iconTTL = 6 * time.Hour + missTTL = 1 * time.Hour + maxIcons = 512 +) + +// candidatePaths are where projects actually keep a favicon, most likely first. +// Each miss costs one request, so the list is ordered by how often it hits +// across a real fleet rather than by completeness. +var candidatePaths = []string{ + "favicon.png", + "favicon.svg", + "public/favicon.png", + "public/favicon.svg", + "app/public/icon.svg", + "app/public/favicon.png", + "static/favicon.png", + "assets/favicon.png", + "web/public/favicon.png", + "docs/favicon.png", + "icon.png", + "Logo/128.png", + "src-tauri/icons/128x128.png", + "assets/icons/favicon.png", + ".github/logo.png", +} + +func NewIcons(token string, lookupToken func(context.Context) string) *Icons { + return &Icons{ + token: token, + lookupToken: lookupToken, + client: &http.Client{Timeout: 10 * time.Second}, + cache: map[string]*icon{}, + } +} + +func (ic *Icons) get(key string) (*icon, bool) { + ic.mu.Lock() + defer ic.mu.Unlock() + got, ok := ic.cache[key] + if !ok || time.Now().After(got.expires) { + return nil, false + } + return got, true +} + +func (ic *Icons) put(key string, got *icon) { + ic.mu.Lock() + defer ic.mu.Unlock() + now := time.Now() + for k, cached := range ic.cache { + if now.After(cached.expires) { + delete(ic.cache, k) + } + } + if len(ic.cache) >= maxIcons { + var oldestKey string + var oldest time.Time + for k, cached := range ic.cache { + if oldestKey == "" || cached.expires.Before(oldest) { + oldestKey, oldest = k, cached.expires + } + } + delete(ic.cache, oldestKey) + } + ic.cache[key] = got +} + +// Repo returns a repository's favicon, or nil when it has none. +func (ic *Icons) Repo(ctx context.Context, repo string) *icon { + owner, name, ok := strings.Cut(repo, "/") + if !ok || !plainName(owner) || !plainName(name) || strings.Contains(name, "/") { + return nil + } + key := "repo:" + strings.ToLower(repo) + if got, ok := ic.get(key); ok { + return got + } + for _, path := range candidatePaths { + // raw.githubusercontent honours the token, so private repos work and we + // avoid the contents API's base64 envelope. + segments := []string{url.PathEscape(owner), url.PathEscape(name), "HEAD"} + for _, segment := range strings.Split(path, "/") { + segments = append(segments, url.PathEscape(segment)) + } + raw := "https://raw.githubusercontent.com/" + strings.Join(segments, "/") + if body, ctype, ok := ic.fetch(ctx, raw); ok { + got := &icon{body: body, contentType: ctype, expires: time.Now().Add(iconTTL)} + ic.put(key, got) + return got + } + } + ic.put(key, &icon{missing: true, expires: time.Now().Add(missTTL)}) + return nil +} + +// Bot returns a reviewer's avatar. Bot logins are ordinary GitHub accounts for +// OAuth apps, but a GitHub App's avatar lives under /in/ and is only +// reachable through the users API, so both routes are tried. +func (ic *Icons) Bot(ctx context.Context, login string) *icon { + if !plainBotLogin(login) { + return nil + } + key := "bot:" + strings.ToLower(login) + if got, ok := ic.get(key); ok { + return got + } + for _, candidate := range []string{login, strings.TrimSuffix(login, "[bot]")} { + avatar := ic.avatarURL(ctx, candidate) + if avatar == "" { + continue + } + avatarURL, err := url.Parse(avatar) + if err != nil { + continue + } + query := avatarURL.Query() + query.Set("s", "96") + avatarURL.RawQuery = query.Encode() + if body, ctype, ok := ic.fetch(ctx, avatarURL.String()); ok { + got := &icon{body: body, contentType: ctype, expires: time.Now().Add(iconTTL)} + ic.put(key, got) + return got + } + } + ic.put(key, &icon{missing: true, expires: time.Now().Add(missTTL)}) + return nil +} + +func (ic *Icons) avatarURL(ctx context.Context, login string) string { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + "https://api.github.com/users/"+url.PathEscape(login), nil) + if err != nil { + return "" + } + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := ic.do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "" + } + var user struct { + AvatarURL string `json:"avatar_url"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&user); err != nil { + return "" + } + return user.AvatarURL +} + +func (ic *Icons) fetch(ctx context.Context, rawURL string) ([]byte, string, bool) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, "", false + } + resp, err := ic.do(req) + if err != nil { + return nil, "", false + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, "", false + } + // A favicon is small; anything large is not one, and streaming it would + // only tie up memory. + const maxIconBytes = 512 << 10 + body, err := io.ReadAll(io.LimitReader(resp.Body, maxIconBytes+1)) + if err != nil || len(body) == 0 || len(body) > maxIconBytes { + return nil, "", false + } + ctype := resp.Header.Get("Content-Type") + if ctype == "" || strings.HasPrefix(ctype, "text/plain") { + ctype = sniff(rawURL, body) + } + if !strings.HasPrefix(ctype, "image/") { + return nil, "", false + } + return body, ctype, true +} + +func (ic *Icons) auth(req *http.Request) { + if req.URL.Host == "api.github.com" || req.URL.Host == "raw.githubusercontent.com" { + token := ic.currentToken(req.Context()) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + } + req.Header.Set("User-Agent", "crq-dashboard") +} + +func (ic *Icons) currentToken(ctx context.Context) string { + ic.mu.Lock() + token, lookup := ic.token, ic.lookupToken + ic.mu.Unlock() + if token != "" || lookup == nil { + return token + } + token = lookup(ctx) + if token == "" { + return "" + } + ic.mu.Lock() + if ic.token == "" { + ic.token = token + } + token = ic.token + ic.mu.Unlock() + return token +} + +func (ic *Icons) do(req *http.Request) (*http.Response, error) { + ic.auth(req) + usedToken := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ") + resp, err := ic.client.Do(req) + if err != nil || resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusForbidden || + ic.lookupToken == nil { + return resp, err + } + token := ic.lookupToken(req.Context()) + if token == "" || token == usedToken { + return resp, nil + } + resp.Body.Close() + ic.mu.Lock() + ic.token = token + ic.mu.Unlock() + retry := req.Clone(req.Context()) + retry.Header = req.Header.Clone() + retry.Header.Del("Authorization") + ic.auth(retry) + return ic.client.Do(retry) +} + +func plainName(name string) bool { + if name == "" || name == "." || name == ".." { + return false + } + for _, r := range name { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || + r == '-' || r == '_' || r == '.' { + continue + } + return false + } + return true +} + +func plainBotLogin(login string) bool { + login = strings.TrimSuffix(login, "[bot]") + return plainName(login) +} + +// sniff decides a content type when the origin serves everything as text, +// which raw.githubusercontent does. +func sniff(path string, body []byte) string { + switch { + case strings.HasSuffix(path, ".svg"): + return "image/svg+xml" + case strings.HasSuffix(path, ".ico"): + return "image/x-icon" + } + return http.DetectContentType(body) +} + +// handleIcon serves both kinds. A miss is a 404 so the browser falls back to +// the letter tile without another round trip. +func (s *Server) handleIcon(w http.ResponseWriter, r *http.Request) { + // Unlike the other credential-backed reads, icons are loaded by , so + // they cannot carry the dashboard's custom header. Fetch Metadata still + // distinguishes the dashboard's same-origin image load from a hostile page + // embedding arbitrarily many cache misses. + if r.Header.Get("Sec-Fetch-Site") != "same-origin" { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "icon requests must come from this dashboard"}) + return + } + if err := s.addressedHere(r); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + return + } + if s.icons == nil { + http.NotFound(w, r) + return + } + kind := r.PathValue("kind") + name := strings.Trim(r.PathValue("name"), "/") + if name == "" { + http.NotFound(w, r) + return + } + + var got *icon + switch kind { + case "repo": + got = s.icons.Repo(r.Context(), name) + case "bot": + got = s.icons.Bot(r.Context(), name) + default: + http.NotFound(w, r) + return + } + if got == nil || got.missing { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", got.contentType) + // These bytes come from somebody else's repository, and an SVG is not a + // picture — navigated to directly, /api/icon/repo// is a + // DOCUMENT on the dashboard's own origin, so a scripted favicon.svg could + // call the mutation endpoints and set the header handleAction checks for. + // The sandbox directive drops it into an opaque origin with scripting off, + // which the the dashboard actually renders never needed anyway. + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "public, max-age=3600") + _, _ = w.Write(got.body) +} diff --git a/internal/serve/icons_test.go b/internal/serve/icons_test.go new file mode 100644 index 00000000..30e91365 --- /dev/null +++ b/internal/serve/icons_test.go @@ -0,0 +1,201 @@ +package serve + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func TestIconAuthStaysOnTrustedGitHubHosts(t *testing.T) { + icons := NewIcons("secret", nil) + for _, rawURL := range []string{ + "https://api.github.com/users/bot", + "https://raw.githubusercontent.com/o/r/HEAD/icon.png", + "https://avatars.githubusercontent.com/u/1?v=4", + "https://example.test/icon.png", + } { + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, rawURL, nil) + if err != nil { + t.Fatal(err) + } + icons.auth(req) + want := strings.Contains(rawURL, "api.github.com") || strings.Contains(rawURL, "raw.githubusercontent.com") + if got := req.Header.Get("Authorization") != ""; got != want { + t.Errorf("Authorization on %s = %t, want %t", rawURL, got, want) + } + } +} + +func TestIconFetchRetriesWithARefreshedToken(t *testing.T) { + calls := 0 + icons := NewIcons("old", func(context.Context) string { return "new" }) + icons.client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + status := http.StatusUnauthorized + body := "" + contentType := "" + if req.Header.Get("Authorization") == "Bearer new" { + status = http.StatusOK + body = "\x89PNG\r\n\x1a\n" + contentType = "image/png" + } + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{contentType}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + }) + + if _, _, ok := icons.fetch(t.Context(), "https://raw.githubusercontent.com/o/r/HEAD/icon.png"); !ok { + t.Fatal("fetch did not retry successfully with the refreshed token") + } + if calls != 2 { + t.Fatalf("requests = %d, want one initial request and one retry", calls) + } +} + +func TestIconFetchCanAcquireATokenAfterAnUnauthenticatedResponse(t *testing.T) { + lookups := 0 + icons := NewIcons("", func(context.Context) string { + lookups++ + if lookups == 1 { + return "" + } + return "new" + }) + calls := 0 + icons.client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + status := http.StatusUnauthorized + body := "unauthorized" + if req.Header.Get("Authorization") == "Bearer new" { + status = http.StatusOK + body = "\x89PNG\r\n\x1a\n" + } + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"image/png"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + }) + + if _, _, ok := icons.fetch(t.Context(), "https://raw.githubusercontent.com/o/r/HEAD/icon.png"); !ok { + t.Fatal("fetch did not retry after the token became available") + } + if calls != 2 { + t.Fatalf("requests = %d, want unauthenticated request and authenticated retry", calls) + } +} + +func TestIconFetchRejectsAnOversizedBody(t *testing.T) { + icons := NewIcons("", nil) + icons.client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"image/png"}}, + Body: io.NopCloser(strings.NewReader(strings.Repeat("x", (512<<10)+1))), + Request: req, + }, nil + }) + + body, _, ok := icons.fetch(t.Context(), "https://example.test/oversized.png") + if ok || body != nil { + t.Fatalf("fetch = (%d bytes, %t), want the oversized response rejected", len(body), ok) + } +} + +func TestIconUnauthorizedBodyStaysReadableWithoutARefreshToken(t *testing.T) { + icons := NewIcons("", func(context.Context) string { return "" }) + icons.client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(strings.NewReader("still readable")), + Request: req, + }, nil + }) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, + "https://raw.githubusercontent.com/o/r/HEAD/icon.png", nil) + if err != nil { + t.Fatal(err) + } + resp, err := icons.do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("original response body was closed: %v", err) + } + if string(body) != "still readable" { + t.Fatalf("body = %q", body) + } +} + +func TestMalformedIconNamesAreRejectedBeforeFetching(t *testing.T) { + icons := NewIcons("", nil) + icons.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("malformed input reached the HTTP client") + return nil, nil + }) + if got := icons.Repo(t.Context(), "../private"); got != nil { + t.Fatalf("Repo returned %#v for traversal input", got) + } + if got := icons.Bot(t.Context(), "bot/../../token"); got != nil { + t.Fatalf("Bot returned %#v for delimiter input", got) + } +} + +func TestAvatarURLDecodesOrdinaryJSONFormatting(t *testing.T) { + icons := NewIcons("", nil) + icons.client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "login": "review-bot", + "avatar_url": "https:\/\/avatars.githubusercontent.com\/u\/42?v=4" + }`)), + Request: req, + }, nil + }) + const want = "https://avatars.githubusercontent.com/u/42?v=4" + if got := icons.avatarURL(t.Context(), "review-bot"); got != want { + t.Fatalf("avatar URL = %q, want %q", got, want) + } +} + +func TestIconRequestsRequireSameOriginFetchMetadata(t *testing.T) { + icons := NewIcons("", nil) + icons.put("bot:codex", &icon{ + body: []byte("\x89PNG\r\n\x1a\n"), contentType: "image/png", + expires: time.Now().Add(time.Hour), + }) + srv := &Server{opts: Options{Addr: "127.0.0.1:7777"}, icons: icons} + + request := func(fetchSite string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:7777/api/icon/bot/codex", nil) + req.Header.Set("Sec-Fetch-Site", fetchSite) + req.SetPathValue("kind", "bot") + req.SetPathValue("name", "codex") + srv.handleIcon(rec, req) + return rec + } + + if rec := request("cross-site"); rec.Code != http.StatusForbidden { + t.Fatalf("cross-site icon = %d, want 403", rec.Code) + } + if rec := request("same-origin"); rec.Code != http.StatusOK { + t.Fatalf("same-origin icon = %d: %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/serve/log.go b/internal/serve/log.go new file mode 100644 index 00000000..3752329f --- /dev/null +++ b/internal/serve/log.go @@ -0,0 +1,66 @@ +package serve + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + "time" +) + +// LogTail is a bounded suffix of a fix session log. +type LogTail struct { + Text string `json:"text"` + Size int64 `json:"size"` + Truncated bool `json:"truncated"` + Host string `json:"host,omitempty"` +} + +func (s *Server) handleAutofixLog(w http.ResponseWriter, r *http.Request) { + if !s.allowDashboardRead(w, r) { + return + } + if s.opts.TailLog == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "this dashboard cannot read autofix logs"}) + return + } + pr, err := strconv.Atoi(r.PathValue("pr")) + if err != nil || pr <= 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "a positive pull request number is required"}) + return + } + repo := r.PathValue("owner") + "/" + r.PathValue("name") + s.mu.RLock() + st := s.lastState + s.mu.RUnlock() + var path, host string + for key, dispatch := range st.Dispatches { + gotRepo, gotPR := splitKey(key) + if strings.EqualFold(gotRepo, repo) && gotPR == pr && dispatch.Live(s.opts.Now().UTC()) { + path, host = dispatch.Log, hostOf(dispatch.Host) + break + } + } + if path == "" { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "no live autofix log for this pull request"}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + tail, err := s.opts.TailLog(ctx, repo, path, 128<<10) + if err != nil { + status := http.StatusNotFound + if errors.Is(err, context.DeadlineExceeded) { + status = http.StatusGatewayTimeout + } + message := err.Error() + if host != "" && !strings.EqualFold(host, s.opts.Host) { + message = "log is on autofix host " + host + ", not dashboard host " + s.opts.Host + ": " + message + } + writeJSON(w, status, map[string]string{"error": message}) + return + } + tail.Host = host + writeJSON(w, http.StatusOK, tail) +} diff --git a/internal/serve/log_test.go b/internal/serve/log_test.go new file mode 100644 index 00000000..990d83f5 --- /dev/null +++ b/internal/serve/log_test.go @@ -0,0 +1,88 @@ +package serve + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +func TestAutofixLogRequiresDashboardRequestAndResolvesTheLiveSession(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + called := false + srv := New(&stubLoader{}, Options{ + Addr: "127.0.0.1:7777", + Host: "atlas", + Now: func() time.Time { return now }, + TailLog: func(ctx context.Context, repo, path string, max int64) (LogTail, error) { + called = true + deadline, ok := ctx.Deadline() + if !ok || time.Until(deadline) > 30*time.Second { + t.Fatalf("tail context deadline = %v, %t; want a bounded 30s timeout", deadline, ok) + } + if repo != "o/r" || path != "/safe/session.log" || max != 128<<10 { + t.Fatalf("tail args = %q %q %d", repo, path, max) + } + return LogTail{Text: "working\n", Size: 8}, nil + }, + }) + srv.lastState.Dispatches = map[string]state.DispatchClaim{ + "o/r#7": {Host: "host=atlas pid=1", At: now.Add(-time.Minute), Heartbeat: now, Log: "/safe/session.log"}, + } + + request := func(header bool) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://127.0.0.1:7777/api/autofix-log/o/r/7", nil) + req.SetPathValue("owner", "o") + req.SetPathValue("name", "r") + req.SetPathValue("pr", "7") + if header { + req.Header.Set("X-CRQ-Dashboard", "1") + } + srv.handleAutofixLog(rec, req) + return rec + } + if rec := request(false); rec.Code != http.StatusForbidden { + t.Fatalf("untrusted log read = %d, want 403", rec.Code) + } + rec := request(true) + var tail LogTail + if err := json.Unmarshal(rec.Body.Bytes(), &tail); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusOK || tail.Text != "working\n" || tail.Size != 8 || tail.Host != "atlas" { + t.Fatalf("trusted log read = %d %+v", rec.Code, tail) + } + if !called { + t.Fatal("trusted request never reached the bounded tail reader") + } +} + +func TestAutofixLogReturnsGatewayTimeout(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + srv := New(&stubLoader{}, Options{ + Addr: "127.0.0.1:7777", + Host: "atlas", + Now: func() time.Time { return now }, + TailLog: func(context.Context, string, string, int64) (LogTail, error) { + return LogTail{}, context.DeadlineExceeded + }, + }) + srv.lastState.Dispatches = map[string]state.DispatchClaim{ + "o/r#7": {Host: "host=atlas pid=1", At: now.Add(-time.Minute), Heartbeat: now, Log: "/safe/session.log"}, + } + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://127.0.0.1:7777/api/autofix-log/o/r/7", nil) + req.SetPathValue("owner", "o") + req.SetPathValue("name", "r") + req.SetPathValue("pr", "7") + req.Header.Set("X-CRQ-Dashboard", "1") + srv.handleAutofixLog(rec, req) + if rec.Code != http.StatusGatewayTimeout { + t.Fatalf("timed-out log read = %d, want 504", rec.Code) + } +} diff --git a/internal/serve/overview.go b/internal/serve/overview.go new file mode 100644 index 00000000..44f4f6fd --- /dev/null +++ b/internal/serve/overview.go @@ -0,0 +1,505 @@ +// Package serve renders the live web dashboard: an HTTP surface over the same +// state the Markdown issue dashboard is built from, plus the embedded SPA. +// +// It reads state and never decides anything. Every fire/hold/cancel rule stays +// in engine and crq — this package's whole job is to reduce a State into the +// shape a browser can paint, and to push a fresh reduction when the state ref +// moves. Anything that needs a GitHub round-trip (findings, convergence) is not +// here; it belongs on the per-PR endpoint that fetches on demand. +package serve + +import ( + "sort" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +// Overview is everything the main screen paints, already reduced. It is a view +// model on purpose: the browser should not have to re-implement Queue()'s +// epistemics or the dashboard's headline precedence to draw a table. +type Overview struct { + Now time.Time `json:"now"` + Rev int64 `json:"rev"` + WroteAt *time.Time `json:"wrote_at,omitempty"` + Headline Headline `json:"headline"` + Quota Quota `json:"quota"` + Slot Slot `json:"slot"` + Leader *Leader `json:"leader,omitempty"` + Counts Counts `json:"counts"` + + Attention []Attention `json:"attention"` + InFlight []RoundRow `json:"in_flight"` + Queue []QueueRow `json:"queue"` + Held []HeldRow `json:"held"` + Autofix AutofixView `json:"autofix"` + Finished []DoneRow `json:"finished"` + + // Stale mirrors Snapshot.Stale, so a caller reading only the overview can + // still tell a live queue from the last one that loaded. Set by the handler, + // not by BuildOverview: it describes the READ, not the state. + Stale *Staleness `json:"stale,omitempty"` +} + +// Headline is the one-line status, following the same precedence ladder the +// Markdown dashboard uses so the two can never disagree. +type Headline struct { + Kind string `json:"kind"` // stranded|blocked|reviewing|awaiting|queued|held|idle + Text string `json:"text"` + Detail string `json:"detail,omitempty"` + Subject string `json:"subject,omitempty"` // repo#pr when the headline names one +} + +type Quota struct { + Scope string `json:"scope,omitempty"` + // Remaining is a pointer because "unknown" and "zero left" are different + // answers and must not render alike. + Remaining *int `json:"remaining,omitempty"` + BlockedUntil *time.Time `json:"blocked_until,omitempty"` + Source string `json:"source,omitempty"` + CheckedAt *time.Time `json:"checked_at,omitempty"` + LastFired *time.Time `json:"last_fired,omitempty"` + // FairUse is the rolling-week count against the vendor's weekly throttle. + // A different scarcity from the hourly block above: this one does not stop + // reviews, it slows every one of them to a crawl, and crq only ever saw it + // arrive. See state/firelog.go. + FairUse state.WeeklyUsage `json:"fair_use"` +} + +type Slot struct { + Held bool `json:"held"` + Key string `json:"key,omitempty"` + Since *time.Time `json:"since,omitempty"` + HoldUntil *time.Time `json:"hold_until,omitempty"` +} + +type Leader struct { + Owner string `json:"owner"` + Host string `json:"host"` + ExpiresAt time.Time `json:"expires_at"` + Expired bool `json:"expired"` +} + +type Counts struct { + InFlight int `json:"in_flight"` + Queued int `json:"queued"` + Held int `json:"held"` + Fixing int `json:"fixing"` +} + +// Attention is a problem that will not resolve itself. Ranked, most urgent +// first; each names its subject so the UI never has to guess a title. +// Attention is one thing worth acting on. Link points at the page that can act +// on it: an item that says a host is broken and leaves you to find the host +// page is a notification, not a control. +type Attention struct { + Kind string `json:"kind"` // stranded|host|leader|lagging|state + Level string `json:"level"` + Subject string `json:"subject,omitempty"` + Text string `json:"text"` + Detail string `json:"detail,omitempty"` + Link string `json:"link,omitempty"` + LinkText string `json:"link_text,omitempty"` +} + +// Bot is one reviewer's state on a round: commanded, claimed, or neither. +type Bot struct { + Login string `json:"login"` + Name string `json:"name"` + Mark string `json:"mark"` // commanded|claimed|pending + At *time.Time `json:"at,omitempty"` + Primary bool `json:"primary,omitempty"` + Required bool `json:"required,omitempty"` +} + +type RoundRow struct { + Key string `json:"key"` + Title string `json:"title,omitempty"` + Repo string `json:"repo"` + PR int `json:"pr"` + Head string `json:"head"` + Phase string `json:"phase"` + FiredAt *time.Time `json:"fired_at,omitempty"` + Deadline *time.Time `json:"deadline,omitempty"` + Bots []Bot `json:"bots"` + Host string `json:"host,omitempty"` + Note string `json:"note,omitempty"` + Next string `json:"next,omitempty"` // the plain-language "what happens next" + Fixing bool `json:"fixing,omitempty"` +} + +type QueueRow struct { + Key string `json:"key"` + Title string `json:"title,omitempty"` + Repo string `json:"repo"` + PR int `json:"pr"` + Head string `json:"head"` + Position int `json:"position,omitempty"` // only the front gets one + ReadyAt *time.Time `json:"ready_at,omitempty"` // only the front gets a time + Why string `json:"why,omitempty"` + Attempts int `json:"attempts,omitempty"` + Host string `json:"host,omitempty"` + CoOnly bool `json:"co_only,omitempty"` + Next string `json:"next,omitempty"` +} + +type HeldRow struct { + Key string `json:"key"` + Title string `json:"title,omitempty"` + Repo string `json:"repo"` + PR int `json:"pr"` + Head string `json:"head,omitempty"` + Reason string `json:"reason,omitempty"` + By string `json:"by,omitempty"` + At time.Time `json:"at"` +} + +type AutofixView struct { + Sessions []Session `json:"sessions"` + Hosts []Host `json:"hosts"` +} + +type Session struct { + Key string `json:"key"` + Repo string `json:"repo"` + PR int `json:"pr"` + Head string `json:"head,omitempty"` + Host string `json:"host,omitempty"` + Model string `json:"model,omitempty"` + Attempt int `json:"attempt,omitempty"` + // MaxAttempts is the budget this repository allows, so "attempt 2" can be + // read as nearly-done or barely-started. + MaxAttempts int `json:"max_attempts,omitempty"` + Findings int `json:"findings,omitempty"` + Log string `json:"log,omitempty"` + Since time.Time `json:"since"` + Heartbeat *time.Time `json:"heartbeat,omitempty"` +} + +// Host health is deliberately three-valued: a host that has reported nothing is +// unknown, not healthy, and calling it healthy is how a dead watcher goes +// unnoticed. +type Host struct { + Name string `json:"name"` + Health string `json:"health"` // healthy|unhealthy|unknown + Failures int `json:"failures,omitempty"` + LastError string `json:"last_error,omitempty"` + LastFailure *time.Time `json:"last_failure,omitempty"` + LastSuccess *time.Time `json:"last_success,omitempty"` +} + +type DoneRow struct { + Key string `json:"key"` + Title string `json:"title,omitempty"` + Repo string `json:"repo"` + PR int `json:"pr"` + Head string `json:"head"` + Outcome string `json:"outcome"` + Note string `json:"note,omitempty"` + At *time.Time `json:"at,omitempty"` +} + +// BuildOverview reduces a loaded State. minInterval comes from config because +// Queue() folds the pacing gate in, and reviewers names the bots so the UI can +// label marks without knowing any bot's identity itself. +func BuildOverview(st state.State, now time.Time, minInterval, inflight time.Duration, botsFor BotsFor, weeklyLimit int, maxAttempts func(repo string) int) Overview { + ov := Overview{ + Now: now, + Rev: st.Rev, + WroteAt: st.UpdatedAt, + Quota: Quota{ + Scope: st.Account.Scope, + Remaining: st.Account.Remaining, + Source: st.Account.Source, + BlockedUntil: st.Account.BlockedUntil, + CheckedAt: st.Account.CheckedAt, + LastFired: st.LastFired, + FairUse: st.FairUse(now, weeklyLimit), + }, + Attention: []Attention{}, + InFlight: []RoundRow{}, + Queue: []QueueRow{}, + Held: []HeldRow{}, + Finished: []DoneRow{}, + Autofix: AutofixView{Sessions: []Session{}, Hosts: []Host{}}, + } + + if slot := st.FireSlot; slot != nil { + ov.Slot = Slot{Held: st.SlotHeld(now), Key: slot.Key, Since: &slot.Since, HoldUntil: slot.HoldUntil} + } else { + ov.Slot = Slot{Held: st.SlotHeld(now)} + } + if l := st.Leader; l != nil { + ov.Leader = &Leader{Owner: l.Owner, Host: hostOf(l.Owner), ExpiresAt: l.ExpiresAt, + Expired: !l.ExpiresAt.After(now)} + } + + ov.InFlight = inFlight(st, now, inflight, botsFor) + ov.Queue = queueRows(st, now, minInterval, botsFor) + ov.Held = heldRows(st) + ov.Autofix = autofixView(st, now, maxAttempts) + ov.Finished = finishedRows(st) + ov.Counts = Counts{len(ov.InFlight), len(ov.Queue), len(ov.Held), len(ov.Autofix.Sessions)} + ov.Attention = attention(st, now, ov) + ov.Headline = headline(st, now, ov) + return ov +} + +// BotName carries a reviewer's identity from config so this package never +// hardcodes one. Primary is the metered reviewer. +type BotName struct { + Login string + Name string + Primary bool + // Required says this bot gates convergence here. Runs is implied by + // membership: a bot that does not review a repository is simply absent from + // its list, because showing it as "off" on every row of every repository is + // noise about a decision already made. + Required bool + Command string + Trigger string + Grace Dur +} + +// BotsFor answers "which reviewers actually run on this repository". The +// resolution itself lives in internal/crq (Config.ForRepo), and is passed in +// rather than reimplemented, so the dashboard cannot grow a second answer that +// disagrees with the one the queue decides from. +type BotsFor func(repo string) []BotName + +func inFlight(st state.State, now time.Time, inflight time.Duration, botsFor BotsFor) []RoundRow { + out := []RoundRow{} + for key, r := range st.Rounds { + switch r.Phase { + case state.PhaseReserved, state.PhaseFired, state.PhaseReviewing: + default: + continue + } + row := RoundRow{ + Key: key, Title: r.Title, Repo: r.Repo, PR: r.PR, Head: r.Head, Phase: string(r.Phase), + FiredAt: r.FiredAt, Host: hostOf(r.ByHost), Note: r.Note, + Bots: botMarks(r, botsFor(r.Repo)), + } + if r.FiredAt != nil && inflight > 0 { + d := r.FiredAt.Add(inflight) + row.Deadline = &d + } + if r.WaitDeadline != nil { + row.Deadline = r.WaitDeadline + } + // Live, not merely present: a claim a dead watcher never released stays + // in Dispatches, and scheduling already treats it as free. Marking the + // row would report a fixer that stopped existing. + if d, fixing := st.Dispatches[key]; fixing && d.Live(now) { + row.Fixing = true + } + row.Next = nextForRound(r, row) + out = append(out, row) + } + sort.Slice(out, func(i, j int) bool { + a, b := out[i].FiredAt, out[j].FiredAt + switch { + case a == nil && b != nil: + return false + case a != nil && b == nil: + return true + case a != nil && b != nil && !a.Equal(*b): + return a.Before(*b) + default: + return out[i].Key < out[j].Key + } + }) + return out +} + +// botMarks describes each reviewer's trigger bookkeeping for one round. bots is +// already the set that RUNS on this repository, so the empty mark means "not +// asked for this head yet", never "disabled" — a bot a repository turned off is +// absent from the row entirely rather than shown greyed out on every one. +func botMarks(r state.Round, bots []BotName) []Bot { + out := make([]Bot, 0, len(bots)) + for _, b := range bots { + mark := Bot{Login: b.Login, Name: b.Name, Mark: "pending", Primary: b.Primary, Required: b.Required} + if b.Primary { + // The primary is commanded by the fire itself, not by a co-bot entry. + if !r.CoOnly && (r.CommandID != 0 || r.FiredAt != nil) { + mark.Mark, mark.At = "commanded", r.FiredAt + } + out = append(out, mark) + continue + } + co, ok := r.CoBots[dialect.NormalizeBotName(b.Login)] + switch { + case !ok: + case co.CommandID != 0: + mark.Mark = "commanded" + if co.CommandedAt != nil { + mark.At = co.CommandedAt + } + case co.ClaimedAt != nil: + mark.Mark, mark.At = "claimed", co.ClaimedAt + } + out = append(out, mark) + } + return out +} + +func queueRows(st state.State, now time.Time, minInterval time.Duration, botsFor BotsFor) []QueueRow { + entries := st.Queue(now, minInterval) + hasPrimary := func(repo string) bool { + for _, bot := range botsFor(repo) { + if bot.Primary { + return true + } + } + return false + } + for i := range entries { + if !hasPrimary(entries[i].Repo) && + (entries[i].Why == state.WaitAccountBlocked || + entries[i].Why == state.WaitPacing || + entries[i].Why == state.WaitSlotBusy) { + entries[i].Why = "" + entries[i].ReadyAt = time.Time{} + } + } + sort.SliceStable(entries, func(i, j int) bool { + iReady := entries[i].Why == "" && entries[i].ReadyAt.IsZero() + jReady := entries[j].Why == "" && entries[j].ReadyAt.IsZero() + if iReady != jReady { + return iReady + } + return entries[i].Seq < entries[j].Seq + }) + out := make([]QueueRow, 0, len(entries)) + for i, e := range entries { + row := QueueRow{ + Key: state.Key(e.Repo, e.PR), Repo: e.Repo, PR: e.PR, Head: e.Head, + Title: titleOf(st, e.Repo, e.PR), + Why: e.Why, Attempts: e.Attempts, Host: hostOf(e.ByHost), CoOnly: e.CoOnly, + } + // Only a round that is genuinely eligible now gets a number and a time — + // anything behind it starts when the front finishes, which is unknowable. + if i == 0 && e.Why == "" { + row.Position = 1 + } + if !e.ReadyAt.IsZero() { + at := e.ReadyAt + row.ReadyAt = &at + } + row.Next = nextForQueued(row) + out = append(out, row) + } + return out +} + +func heldRows(st state.State) []HeldRow { + out := make([]HeldRow, 0, len(st.Holds)) + for key, h := range st.Holds { + repo, pr := splitKey(key) + row := HeldRow{Key: key, Repo: repo, PR: pr, Reason: h.Reason, By: h.By, At: h.At} + if r, ok := st.Rounds[key]; ok { + row.Head, row.Title = r.Head, r.Title + } + out = append(out, row) + } + sort.Slice(out, func(i, j int) bool { return out[i].At.After(out[j].At) }) + return out +} + +func autofixView(st state.State, now time.Time, maxAttempts func(repo string) int) AutofixView { + v := AutofixView{Sessions: []Session{}, Hosts: []Host{}} + for key, d := range st.Dispatches { + // A claim past its TTL is a watcher that died without releasing it. + // Scheduling already treats it as free; rendering it as a running session + // left a dead "Fixing" on the page until something happened to replace the + // entry, which on a quiet repository is never. + if !d.Live(now) { + continue + } + repo, pr := splitKey(key) + s := Session{Key: key, Repo: repo, PR: pr, Host: hostOf(d.Host), Model: d.Model, Attempt: d.Attempts, + Findings: d.Findings, Log: d.Log, Since: d.At} + if maxAttempts != nil { + s.MaxAttempts = maxAttempts(repo) + } + if r, ok := st.Rounds[key]; ok { + s.Head = r.Head + } + // A session can push and supersede its round while its top-level claim + // intentionally stays live to finish thread cleanup. The archived claim + // still names the checkout/head that session owns. + for i := len(st.Archive) - 1; i >= 0; i-- { + r := st.Archive[i] + if state.Key(r.Repo, r.PR) == key && r.Dispatch != nil && r.Dispatch.Token == d.Token { + s.Head = r.Head + break + } + } + if !d.Heartbeat.IsZero() { + hb := d.Heartbeat + s.Heartbeat = &hb + } + v.Sessions = append(v.Sessions, s) + } + sort.Slice(v.Sessions, func(i, j int) bool { return v.Sessions[i].Since.Before(v.Sessions[j].Since) }) + + for name, h := range st.AutofixByHost { + health := h + host := Host{Name: name, Failures: h.ConsecutiveFailures, LastError: h.LastError, + LastFailure: h.LastFailureAt, LastSuccess: h.LastSuccessAt} + switch { + case health.Unhealthy(): + host.Health = "unhealthy" + case h.LastSuccessAt == nil && h.ConsecutiveFailures == 0: + host.Health = "unknown" + default: + host.Health = "healthy" + } + v.Hosts = append(v.Hosts, host) + } + sort.Slice(v.Hosts, func(i, j int) bool { return v.Hosts[i].Name < v.Hosts[j].Name }) + return v +} + +func finishedRows(st state.State) []DoneRow { + out := []DoneRow{} + add := func(r state.Round) { + row := DoneRow{Key: state.Key(r.Repo, r.PR), Title: r.Title, Repo: r.Repo, PR: r.PR, + Head: r.Head, Outcome: string(r.Phase), Note: r.Note} + if r.FiredAt != nil { + row.At = r.FiredAt + } + out = append(out, row) + } + for _, r := range st.Rounds { + if r.Phase == state.PhaseCompleted { + add(r) + } + } + for _, r := range st.Archive { + add(r) + } + sort.Slice(out, func(i, j int) bool { + a, b := out[i].At, out[j].At + if a == nil || b == nil { + return b == nil && a != nil + } + return a.After(*b) + }) + if len(out) > 12 { + out = out[:12] + } + return out +} + +// titleOf is the pull request's title from its round, when one is recorded. +// Queue entries come from State.Queue, which reduces a round to its scheduling +// facts; the title lives on the round itself. +func titleOf(st state.State, repo string, pr int) string { + if r, ok := st.Rounds[state.Key(repo, pr)]; ok { + return r.Title + } + return "" +} diff --git a/internal/serve/overview_test.go b/internal/serve/overview_test.go new file mode 100644 index 00000000..d1e50b7d --- /dev/null +++ b/internal/serve/overview_test.go @@ -0,0 +1,75 @@ +package serve + +import ( + "testing" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +func TestAutofixSessionKeepsTheHeadOwnedByItsClaim(t *testing.T) { + now := time.Date(2026, 7, 29, 17, 0, 0, 0, time.UTC) + key := state.Key("o/repo", 1) + claim := state.DispatchClaim{Host: "atlas", Token: "session-1", Model: "fallback-model", At: now, Heartbeat: now} + st := state.New() + st.Rounds[key] = state.Round{Repo: "o/repo", PR: 1, Head: "new-head"} + st.Archive = []state.Round{{ + Repo: "O/Repo", PR: 1, Head: "old-head", Dispatch: &claim, + }} + st.Dispatches = map[string]state.DispatchClaim{key: claim} + + view := autofixView(st, now, nil) + if len(view.Sessions) != 1 || + view.Sessions[0].Head != "old-head" || view.Sessions[0].Model != "fallback-model" { + t.Fatalf("sessions = %+v, want the live session's archived head and selected fallback model", view.Sessions) + } +} + +func TestPrimaryOffQueueRowIgnoresMeteredQuotaGates(t *testing.T) { + now := time.Date(2026, 7, 29, 17, 0, 0, 0, time.UTC) + blocked := now.Add(time.Hour) + fired := now + st := state.New() + st.Account.BlockedUntil = &blocked + st.LastFired = &fired + st.Rounds[state.Key("o/free", 1)] = state.Round{ + Repo: "o/free", PR: 1, Head: "free-head", Phase: state.PhaseQueued, Seq: 1, EnqueuedAt: now, + } + st.Rounds[state.Key("o/metered", 2)] = state.Round{ + Repo: "o/metered", PR: 2, Head: "metered-head", Phase: state.PhaseReserved, + Seq: 2, EnqueuedAt: now.Add(-time.Minute), ReservedAt: &now, Token: "slot-token", + } + st.FireSlot = &state.FireSlot{Key: state.Key("o/metered", 2), Token: "slot-token", Since: now} + botsFor := func(string) []BotName { + return []BotName{{Login: "chatgpt-codex-connector[bot]", Name: "Codex"}} + } + + ov := BuildOverview(st, now, 90*time.Second, time.Minute, botsFor, 0, nil) + if len(ov.Queue) != 1 { + t.Fatalf("queue = %+v, want one row", ov.Queue) + } + row := ov.Queue[0] + if row.Why != "" || row.ReadyAt != nil || row.Position != 1 { + t.Fatalf("primary-off row = %+v, want ready despite account block and pacing", row) + } +} + +func TestPRSessionKeepsTheArchivedHeadOwnedByItsClaim(t *testing.T) { + now := time.Date(2026, 7, 29, 17, 0, 0, 0, time.UTC) + key := state.Key("o/repo", 1) + claim := state.DispatchClaim{Host: "atlas", Token: "session-1", Model: "fallback-model", At: now, Heartbeat: now} + st := state.New() + st.Rounds[key] = state.Round{ + Repo: "o/repo", PR: 1, Head: "new-head", Phase: state.PhaseQueued, EnqueuedAt: now, + } + st.Archive = []state.Round{{ + Repo: "O/Repo", PR: 1, Head: "old-head", Dispatch: &claim, + }} + st.Dispatches = map[string]state.DispatchClaim{key: claim} + + view := buildPRView(st, "o/repo", 1, nil, time.Minute, now, nil) + if view.Round == nil || view.Round.Fixing == nil || + view.Round.Fixing.Head != "old-head" || view.Round.Fixing.Model != "fallback-model" { + t.Fatalf("PR fixing session = %+v, want its archived head and selected fallback model", view.Round) + } +} diff --git a/internal/serve/pr.go b/internal/serve/pr.go new file mode 100644 index 00000000..cb18535c --- /dev/null +++ b/internal/serve/pr.go @@ -0,0 +1,483 @@ +package serve + +import ( + "context" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +// Observer is the expensive half of a PR view: everything that needs a round +// trip to GitHub. It is an interface so this package keeps working without one +// — the cheap state layer still renders, and the findings arrive when they can. +type Observer interface { + Observe(ctx context.Context, repo string, pr int) (Observation, error) +} + +// Observation is what one GitHub read tells us about a pull request. +type Observation struct { + Head string `json:"head"` + Converged bool `json:"converged"` + Status string `json:"status,omitempty"` + Reason string `json:"reason,omitempty"` + ReviewedBy map[string]bool `json:"reviewed_by,omitempty"` + Findings []dialect.Finding `json:"findings"` + Dismissed int `json:"dismissed,omitempty"` + CheckedAt time.Time `json:"checked_at"` +} + +// Coster estimates what one more round on a pull request would cost. Separate +// from Observer because it is a different question with a different failure +// mode: a page can show findings without a price, and a price without findings. +type Coster interface { + Cost(ctx context.Context, repo string, pr int) (Cost, error) +} + +// Cost mirrors crq.RoundCost on the wire. Ranges, per-reviewer reasoning and a +// prices-checked date, because a single confident figure would be the one +// output guaranteed to be wrong. +type Cost struct { + Head string `json:"head"` + Low float64 `json:"low"` + High float64 `json:"high"` + Exact bool `json:"exact,omitempty"` + Unpriced []string `json:"unpriced,omitempty"` + Summary string `json:"summary"` + PricesCheckedAt string `json:"prices_checked_at"` + PricingNote string `json:"pricing_note"` + Reviewers []CostReviewer `json:"reviewers"` + Diff CostDiff `json:"diff"` +} + +type CostReviewer struct { + Bot string `json:"bot"` + Low float64 `json:"low"` + High float64 `json:"high"` + Exact bool `json:"exact,omitempty"` + Unknown bool `json:"unknown,omitempty"` + Basis string `json:"basis"` +} + +type CostDiff struct { + Additions int `json:"additions"` + Deletions int `json:"deletions"` + ChangedFiles int `json:"changed_files"` +} + +// PRView is the two-layer page: state renders instantly, observation fills in. +type PRView struct { + Repo string `json:"repo"` + PR int `json:"pr"` + Rev int64 `json:"rev"` + Round *RoundView `json:"round,omitempty"` + Hold *HeldRow `json:"hold,omitempty"` + + // Observed is nil until a GitHub read succeeds. ObserveError explains why + // it is still nil, so the page can say so rather than looking empty. + Observed *Observation `json:"observed,omitempty"` + ObserveError string `json:"observe_error,omitempty"` + + // Title is the pull request's own, so the page says what it is about rather + // than only which number it is. + Title string `json:"title,omitempty"` + // Cost is what the NEXT round would cost. Nil when it could not be worked + // out; CostError says why rather than leaving a blank where money goes. + Cost *Cost `json:"cost,omitempty"` + CostError string `json:"cost_error,omitempty"` + + History []HistoryEntry `json:"history"` +} + +// RoundView is the live round, straight from state — no network needed. +type RoundView struct { + Head string `json:"head"` + Phase string `json:"phase"` + Attempts int `json:"attempts,omitempty"` + EnqueuedAt time.Time `json:"enqueued_at"` + FiredAt *time.Time `json:"fired_at,omitempty"` + Deadline *time.Time `json:"deadline,omitempty"` + RetryAt *time.Time `json:"retry_at,omitempty"` + Note string `json:"note,omitempty"` + Host string `json:"host,omitempty"` + CoOnly bool `json:"co_only,omitempty"` + Bots []Bot `json:"bots"` + Fixing *Session `json:"fixing,omitempty"` + Dismissed []Dismissed `json:"dismissed,omitempty"` + Next string `json:"next,omitempty"` +} + +type Dismissed struct { + ID string `json:"id"` + Reason string `json:"reason"` +} + +// HistoryEntry is an earlier head for this PR, newest first. +type HistoryEntry struct { + Head string `json:"head"` + Outcome string `json:"outcome"` + Note string `json:"note,omitempty"` + At *time.Time `json:"at,omitempty"` + Current bool `json:"current,omitempty"` +} + +// observeCache keeps one observation per (repo, pr, head, state revision, +// reviewer config). Round state and dismissals shape convergence and findings +// even when the head and reviewer configuration stay unchanged. +type observeCache struct { + mu sync.Mutex + entries map[string]observeEntry +} + +type observeEntry struct { + obs Observation + err string + fetched time.Time +} + +const observeTTL = 60 * time.Second + +// maxCacheEntries bounds each cache. The TTL alone only makes an entry +// unusable, it never removes one — and every push, reviewer change and +// allowance change mints a fresh key — so a dashboard left running kept every +// findings payload and every price it had ever served. The bound is generous +// enough that ordinary browsing never reaches it and small enough that reaching +// it costs nothing. +const maxCacheEntries = 512 + +func (c *observeCache) get(key string) (observeEntry, bool) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[key] + if !ok || time.Since(e.fetched) > observeTTL { + return observeEntry{}, false + } + return e, true +} + +func (c *observeCache) put(key string, e observeEntry) { + c.mu.Lock() + defer c.mu.Unlock() + if c.entries == nil { + c.entries = map[string]observeEntry{} + } + c.entries[key] = e + // Expired entries first: none of them can ever be served again, so dropping + // them is free. The bound is the backstop for a burst that outruns the TTL. + pruneCache(c.entries, observeTTL, func(e observeEntry) time.Time { return e.fetched }) +} + +// pruneCache drops every entry past its TTL, then — if the map is still over +// the bound — the oldest of what is left, so a cache cannot grow without limit +// even while every entry in it is live. +func pruneCache[E any](entries map[string]E, ttl time.Duration, at func(E) time.Time) { + now := time.Now() + for key, e := range entries { + if now.Sub(at(e)) > ttl { + delete(entries, key) + } + } + if len(entries) <= maxCacheEntries { + return + } + type aged struct { + key string + at time.Time + } + all := make([]aged, 0, len(entries)) + for key, e := range entries { + all = append(all, aged{key, at(e)}) + } + sort.Slice(all, func(i, j int) bool { return all[i].at.Before(all[j].at) }) + for _, a := range all[:len(entries)-maxCacheEntries] { + delete(entries, a.key) + } +} + +// costKey names everything a price depends on. The head alone is not enough: +// the estimate is a sum over the repository's EFFECTIVE reviewers priced +// against the account's remaining allowance, so adding a paid reviewer — or +// exhausting the allowance — at the same commit changes the answer. Keyed on +// the head alone, the page went on serving the old figure for the whole TTL, +// and an SSE reload could not shift it because it asked the same question. +func costKey(repo string, pr int, head string, bots []BotName, remaining *int) string { + var b strings.Builder + b.WriteString(strings.ToLower(repo) + "#" + strconv.Itoa(pr) + "@" + head) + for _, bot := range bots { + b.WriteString("|" + bot.Login + ":primary=" + strconv.FormatBool(bot.Primary) + ":trigger=" + bot.Trigger) + } + b.WriteString("|allowance=") + if remaining == nil { + b.WriteString("unknown") + } else { + b.WriteString(strconv.Itoa(*remaining)) + } + return b.String() +} + +func observationKey(repo string, pr int, head string, rev int64, bots []BotName) string { + var b strings.Builder + b.WriteString(strings.ToLower(repo) + "#" + strconv.Itoa(pr) + "@" + head + + "|rev=" + strconv.FormatInt(rev, 10)) + for _, bot := range bots { + b.WriteString("|" + bot.Login) + if bot.Primary { + b.WriteString(":primary") + } + if bot.Required { + b.WriteString(":required") + } + b.WriteString(":trigger=" + bot.Trigger + ":command=" + bot.Command) + b.WriteString(":grace=" + strconv.FormatInt(int64(bot.Grace), 10)) + } + return b.String() +} + +// costCache mirrors observeCache. Keyed by costKey: a price for a head that has +// been superseded — or for a reviewer set or allowance that has moved since — +// is a price for the wrong question. +type costCache struct { + mu sync.Mutex + entries map[string]costEntry +} + +type costEntry struct { + cost *Cost + err string + fetched time.Time +} + +// Longer than the observation TTL: the diff of a head does not change, so the +// only thing that can move a price is the account allowance running out. +const costTTL = 5 * time.Minute + +func (c *costCache) get(key string) (costEntry, bool) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[key] + if !ok || time.Since(e.fetched) > costTTL { + return costEntry{}, false + } + return e, true +} + +func (c *costCache) put(key string, e costEntry) { + c.mu.Lock() + defer c.mu.Unlock() + if c.entries == nil { + c.entries = map[string]costEntry{} + } + c.entries[key] = e + pruneCache(c.entries, costTTL, func(e costEntry) time.Time { return e.fetched }) +} + +// buildPRView assembles the cheap layer from state. maxAttempts is the same +// per-repository budget the overview reads, so the two renderings of one claim +// cannot disagree about how far along a session is; nil leaves it unsaid. +func buildPRView(st state.State, repo string, pr int, bots []BotName, inflight time.Duration, now time.Time, maxAttempts func(repo string) int) PRView { + v := PRView{Repo: repo, PR: pr, Rev: st.Rev, History: []HistoryEntry{}} + key := state.Key(repo, pr) + v.Title = titleOf(st, repo, pr) + + if r, ok := st.Rounds[key]; ok { + row := RoundRow{Key: key, Title: r.Title, Repo: r.Repo, PR: r.PR, Head: r.Head, Phase: string(r.Phase), + FiredAt: r.FiredAt, Bots: botMarks(r, bots)} + rv := &RoundView{ + Head: r.Head, Phase: string(r.Phase), Attempts: r.Attempts, + EnqueuedAt: r.EnqueuedAt, FiredAt: r.FiredAt, RetryAt: r.RetryAt, + Note: r.Note, Host: hostOf(r.ByHost), CoOnly: r.CoOnly, Bots: row.Bots, + } + if r.FiredAt != nil && inflight > 0 { + d := r.FiredAt.Add(inflight) + rv.Deadline = &d + } + if r.WaitDeadline != nil { + rv.Deadline = r.WaitDeadline + } + for id, reason := range r.Dismissed { + rv.Dismissed = append(rv.Dismissed, Dismissed{ID: id, Reason: reason}) + } + sort.Slice(rv.Dismissed, func(i, j int) bool { return rv.Dismissed[i].ID < rv.Dismissed[j].ID }) + // Live, for the same reason the overview and the sessions table are: a + // claim whose watcher died is not a running session, and the three views + // must not disagree about that. + if d, ok := st.Dispatches[key]; ok && d.Live(now) { + // Every field the claim carries, because the card renders them: a + // page that has the log path and the findings count in hand and + // shows neither is the overview's session row with holes in it. + s := Session{Key: key, Repo: repo, PR: pr, Head: r.Head, Host: hostOf(d.Host), + Model: d.Model, Attempt: d.Attempts, Findings: d.Findings, Log: d.Log, Since: d.At} + for i := len(st.Archive) - 1; i >= 0; i-- { + archived := st.Archive[i] + if strings.EqualFold(archived.Repo, repo) && archived.PR == pr && + archived.Dispatch != nil && archived.Dispatch.Token == d.Token { + s.Head = archived.Head + break + } + } + if maxAttempts != nil { + s.MaxAttempts = maxAttempts(repo) + } + if !d.Heartbeat.IsZero() { + hb := d.Heartbeat + s.Heartbeat = &hb + } + rv.Fixing = &s + } + row.Bots = rv.Bots + rv.Next = nextForRound(r, row) + v.Round = rv + v.History = append(v.History, HistoryEntry{Head: r.Head, Outcome: string(r.Phase), + Note: r.Note, At: r.FiredAt, Current: true}) + } + + if h, ok := st.Holds[key]; ok { + v.Hold = &HeldRow{Key: key, Repo: repo, PR: pr, Reason: h.Reason, By: h.By, At: h.At} + if v.Round != nil { + v.Hold.Head = v.Round.Head + } + } + + for _, r := range st.Archive { + if !strings.EqualFold(r.Repo, repo) || r.PR != pr { + continue + } + v.History = append(v.History, HistoryEntry{Head: r.Head, Outcome: string(r.Phase), + Note: r.Note, At: r.FiredAt}) + } + sort.SliceStable(v.History, func(i, j int) bool { + a, b := v.History[i].At, v.History[j].At + if a == nil || b == nil { + return b == nil && a != nil + } + return a.After(*b) + }) + return v +} + +// handlePR serves one pull request. The state layer always answers; the +// observation is attempted but never allowed to fail the request — a page that +// shows the round and says "could not reach GitHub" beats a 500. +func (s *Server) handlePR(w http.ResponseWriter, r *http.Request) { + if !s.allowDashboardRead(w, r) { + return + } + owner, name := r.PathValue("owner"), r.PathValue("name") + pr, err := strconv.Atoi(r.PathValue("pr")) + if owner == "" || name == "" || err != nil || pr <= 0 { + http.NotFound(w, r) + return + } + repo := owner + "/" + name + + s.mu.RLock() + if !s.loaded { + err := s.loadErr + s.mu.RUnlock() + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": firstLoadError(err)}) + return + } + st := s.lastState + s.mu.RUnlock() + + bots := s.botsFor(&st)(repo) + view := buildPRView(st, repo, pr, bots, s.pacing(st).Inflight, s.opts.Now(), s.maxAttempts(st)) + stateOnly := r.URL.Query().Get("state_only") == "1" + + if !stateOnly && s.observer != nil { + // The round's head is what invalidates a cached entry, so an untracked + // pull request has nothing to invalidate one WITH: its key never moves, + // and a page reloaded after a push kept being served the previous head's + // findings for the whole TTL. No proof of the head, no head-scoped reuse. + head := "" + if view.Round != nil { + head = view.Round.Head + } + key := observationKey(repo, pr, head, st.Rev, bots) + if r.URL.Query().Get("refresh") == "1" { + s.observations.put(key, observeEntry{}) + } + if e, ok := s.observations.get(key); ok && head != "" && + (e.err != "" || (!e.obs.CheckedAt.IsZero() && e.obs.Head == head)) { + if e.err != "" { + view.ObserveError = e.err + } else { + obs := e.obs + view.Observed = &obs + } + } else { + ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second) + defer cancel() + obs, err := s.observer.Observe(ctx, repo, pr) + entry := observeEntry{obs: obs, fetched: time.Now()} + if err != nil { + entry.err = err.Error() + view.ObserveError = entry.err + } else if head != "" && obs.Head != "" && !sameHead(head, obs.Head) { + entry.err = "pull request moved while this view was loading; refresh for current state" + entry.fetched = time.Time{} // never cache a state/observation race + view.ObserveError = entry.err + } else { + view.Observed = &obs + } + s.observations.put(key, entry) + } + } else if !stateOnly { + view.ObserveError = "this server was started without GitHub access" + } + + // Priced on the same trip and cached the same way: it costs one more pull + // read, which is why the overview does not price every queue row. + if !stateOnly && s.opts.Coster != nil { + // The head GitHub just reported, when the observation above got one. + // The five-minute TTL rests entirely on "the diff of a head does not + // change", and keyed on a round's head that is empty or superseded that + // reasoning does not hold: the page went on quoting the previous diff's + // price for five minutes after a push. + head := "" + if view.Observed != nil { + head = view.Observed.Head + } + key := costKey(repo, pr, head, bots, st.Account.Remaining) + if r.URL.Query().Get("refresh") == "1" { + s.costs.put(key, costEntry{}) + } + if e, ok := s.costs.get(key); ok && head != "" && (e.err != "" || e.cost != nil) { + view.Cost, view.CostError = e.cost, e.err + } else { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + cost, err := s.opts.Coster.Cost(ctx, repo, pr) + entry := costEntry{fetched: time.Now()} + if err != nil { + entry.err = err.Error() + view.CostError = entry.err + } else if head != "" && !strings.EqualFold(cost.Head, head) { + entry.err = "pull request moved while pricing; refresh to price the observed head" + view.CostError = entry.err + } else { + entry.cost = &cost + view.Cost = &cost + } + cacheHead := head + if entry.cost != nil { + cacheHead = entry.cost.Head + } + if cacheHead != "" { + s.costs.put(costKey(repo, pr, cacheHead, bots, st.Account.Remaining), entry) + } + } + } + + writeJSON(w, http.StatusOK, view) +} + +func sameHead(a, b string) bool { + a, b = strings.ToLower(strings.TrimSpace(a)), strings.ToLower(strings.TrimSpace(b)) + return a != "" && b != "" && (strings.HasPrefix(a, b) || strings.HasPrefix(b, a)) +} diff --git a/internal/serve/server.go b/internal/serve/server.go new file mode 100644 index 00000000..44a00d8b --- /dev/null +++ b/internal/serve/server.go @@ -0,0 +1,657 @@ +package serve + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "hash/fnv" + "io/fs" + "net/http" + "strings" + "sync" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +// Loader is the read side of the state store. Taking the interface rather than +// the store keeps this package testable without GitHub. +type Loader interface { + Load(ctx context.Context) (state.State, state.Revision, error) +} + +// Logger matches the logger the rest of crq passes around. +type Logger interface { + Printf(format string, args ...any) +} + +// Options is everything the server needs that it must not decide for itself. +type Options struct { + Addr string + MinInterval time.Duration + Inflight time.Duration + // Bots is the fleet's reviewer list, used only when Resolve is nil. + Bots []BotName + // Resolve answers which reviewers run on one repository. It takes the whole + // state, not just that repository's override, because the answer also + // depends on the fleet's recorded defaults — resolving from the override + // alone showed this host's env list however the fleet had been configured. + // An empty repo asks for the fleet's own answer. + Resolve func(st state.State, repo string) []BotName + // WeeklyLimit is the vendor's weekly fair-use threshold. 0 counts without + // forecasting. + WeeklyLimit int + // PacingFor resolves the three settings above against the state being + // rendered, because all three are fleet-settable: taken from startup, a + // saved pacing or fair-use change reported itself on the settings page while + // the queue cards and the fair-use card went on using the old numbers until + // somebody restarted the server. Nil keeps the startup values. + PacingFor func(st state.State) Pacing + // Poll is how often the state ref is re-read. There is no webhook for a git + // ref, so this is a poll by necessity; a push only happens when Rev moves. + Poll time.Duration + // Assets is the built SPA. Nil serves a plain page explaining how to build + // it, so `crq serve` is still useful from a source checkout. + Assets fs.FS + Log Logger + Now func() time.Time + // Fleet is the configuration the settings and setup pages display. + Fleet FleetConfig + // FleetFor derives the effective fleet settings from the state already + // loaded for this snapshot. Nil retains the Actor fallback for embedders. + FleetFor func(st state.State) *FleetSettings + // AllowReposFor resolves the shared fleet allow-list against the state being + // rendered. Nil keeps Fleet.AllowRepos as a startup-only fallback. + AllowReposFor func(st state.State) []string + // Host names the machine this server runs on, so the tool list can say + // whose PATH it describes. + Host string + // Token authenticates icon fetches. Repository favicons live in private + // repos too, and the browser must never hold this. + Token string + // LookupToken refreshes a rotated credential after an authenticated icon + // request is rejected. Nil keeps Token fixed. + LookupToken func(context.Context) string + // EnrollFor resolves whether crq reviews one repository. Nil falls back to + // reading the env lists alone, which is what the dashboard did before + // enrollment records existed. + EnrollFor EnrollFor + // Discoverer lists the repositories in scope for the repo picker. Nil means + // the picker reports that discovery is not configured rather than showing an + // empty list, which would read as "you have no repositories". + Discoverer Discoverer + // Coster estimates what a round would cost. Optional: without it the PR page + // simply shows no price, rather than a wrong one. + Coster Coster + // TailLog reads a bounded tail of a session log after the handler resolves + // the current session from state. Nil means this dashboard cannot access + // logs on its host. + TailLog func(context.Context, string, string, int64) (LogTail, error) + // SolverFor resolves a repository's fix-session settings. Nil leaves the + // repository page without a solver card rather than showing env values that + // no repository record could change. + SolverFor SolverFor + // Previewer prices an enrollment before it happens. + Previewer Previewer + // Observer supplies the per-PR findings. Optional: without it the PR page + // still renders its state layer. + Observer Observer + // Actor performs writes. Nil, or ReadOnly, makes every action endpoint + // refuse — useful when pointing a dashboard at someone else's fleet. + Actor Actor + ReadOnly bool + // AllowedHosts are extra names actions may be addressed to, beyond loopback, + // IP literals, the bound address and this machine's own name. A reverse + // proxy or a DNS alias is the case for it: the check exists to stop a name + // an ATTACKER controls from being rebound at this port, and crq cannot tell + // one of those from an alias somebody set up on purpose. + AllowedHosts []string +} + +// Pacing is the fire-pacing configuration the overview renders, resolved +// against one state snapshot. +type Pacing struct { + MinInterval time.Duration + Inflight time.Duration + WeeklyLimit int +} + +// pacing resolves the pacing settings for st, falling back to the values this +// server was started with. +func (s *Server) pacing(st state.State) Pacing { + if s.opts.PacingFor == nil { + return Pacing{MinInterval: s.opts.MinInterval, Inflight: s.opts.Inflight, WeeklyLimit: s.opts.WeeklyLimit} + } + return s.opts.PacingFor(st) +} + +// Server holds the latest snapshot and fans it out to connected browsers. +type Server struct { + opts Options + loader Loader + + // refreshMu serializes whole refreshes. The poller and every action handler + // call refresh, and without this a poll that started first could finish last + // and publish ITS older state over the one an action had just written — + // rolling the dashboard back a revision and replaying its events. + refreshMu sync.Mutex + + mu sync.RWMutex + last Snapshot + lastRev int64 + // loaded says a load has actually produced `last`. Rev cannot answer it: a + // state ref that has never been written has revision 0 too, and before the + // first load loadErr is nil as well — so both handlers and the SSE stream + // took the ZERO snapshot for live state and served collections encoded as + // null, which the client reads straight into snap.repos.filter(...). + loaded bool + // digest fingerprints the last snapshot pushed, so a rebuild that changed + // something is broadcast even when the state revision did not move. See + // refresh. + digest uint64 + loadErr error + subs map[chan []byte]struct{} + // tools are probed once: LookPath per refresh would be wasted work, and the + // answer only changes when someone installs something. + tools []Tool + icons *Icons + // lastState is kept so a per-PR request can render without another read of + // the state ref; the poller already has it. + lastState state.State + observer Observer + observations *observeCache + actor Actor + events *eventLog + discovered *discoverCache + costs *costCache +} + +func New(loader Loader, opts Options) *Server { + if opts.Poll <= 0 { + opts.Poll = 5 * time.Second + } + if opts.Now == nil { + opts.Now = time.Now + } + return &Server{opts: opts, loader: loader, subs: map[chan []byte]struct{}{}, + tools: LocalTools(), icons: NewIcons(opts.Token, opts.LookupToken), observer: opts.Observer, + observations: &observeCache{}, actor: opts.Actor, + events: newEventLog(300), discovered: &discoverCache{}, costs: &costCache{}} +} + +// Run polls the state ref and serves until ctx is cancelled. +func (s *Server) Run(ctx context.Context) error { + go s.watch(ctx) + + mux := http.NewServeMux() + mux.HandleFunc("GET /api/snapshot", s.handleSnapshot) + mux.HandleFunc("GET /api/overview", s.handleOverview) + mux.HandleFunc("GET /api/events", s.handleEvents) + mux.HandleFunc("GET /api/health", s.handleHealth) + mux.HandleFunc("GET /api/icon/{kind}/{name...}", s.handleIcon) + mux.HandleFunc("GET /api/pr/{owner}/{name}/{pr}", s.handlePR) + mux.HandleFunc("GET /api/discover", s.handleDiscover) + mux.HandleFunc("GET /api/enroll-preview", s.handleEnrollPreview) + mux.HandleFunc("GET /api/autofix-log/{owner}/{name}/{pr}", s.handleAutofixLog) + mux.HandleFunc("POST /api/setup/refresh", s.handleSetupRefresh) + mux.HandleFunc("POST /api/action/{action}", s.handleAction) + mux.Handle("/", s.assets()) + + srv := &http.Server{ + Addr: s.opts.Addr, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + go func() { + <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = srv.Shutdown(shutdown) + }() + if s.opts.Log != nil { + s.opts.Log.Printf("dashboard on http://%s", s.opts.Addr) + } + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} + +// watch re-reads the state ref and pushes a snapshot whenever Rev moves. A +// failed read never clears the last good snapshot — stale-but-labelled beats +// blank, and the error rides along so the UI can say so. +func (s *Server) watch(ctx context.Context) { + tick := time.NewTicker(s.opts.Poll) + defer tick.Stop() + s.refresh(ctx) + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + s.refresh(ctx) + } + } +} + +func (s *Server) refresh(ctx context.Context) { + // Held across the load as well as the publish: the event feed is derived + // from the previous snapshot, so two refreshes that interleave would each + // diff against a state the other is about to replace. + s.refreshMu.Lock() + defer s.refreshMu.Unlock() + + st, _, err := s.loader.Load(ctx) + if err != nil { + s.markStale(err) + return + } + now := s.opts.Now() + + // Derive events before the snapshot so the feed it carries is current. + s.mu.RLock() + prev := s.lastState + loaded := s.loaded + s.mu.RUnlock() + if loaded { + s.events.add(diffStates(prev, st, now)...) + } + + botsFor := s.botsFor(&st) + pace := s.pacing(st) + ov := BuildOverview(st, now, pace.MinInterval, pace.Inflight, botsFor, pace.WeeklyLimit, s.maxAttempts(st)) + // Read alongside the snapshot rather than cached: it is a state read the + // server has already paid for, and a settings page showing a stale default + // is how two people overwrite each other. + var fleet *FleetSettings + var env []EnvSetting + if s.opts.FleetFor != nil { + fleet = s.opts.FleetFor(st) + } else if s.actor != nil { + if f, err := s.actor.Fleet(ctx); err == nil { + fleet = f + } + } + if s.actor != nil { + env = s.actor.EnvSettings(st) + } + fleetConfig := s.opts.Fleet + if s.opts.AllowReposFor != nil { + fleetConfig.AllowRepos = s.opts.AllowReposFor(st) + } + snap := BuildFleet(st, fleetConfig, ov, s.tools, s.opts.Host, now, botsFor, s.opts.EnrollFor, fleet, s.opts.SolverFor, env) + snap.Events = s.events.list() + digest := snapshotDigest(snap) + + s.mu.Lock() + changed := ov.Rev != s.lastRev || s.last.Stale != nil || digest != s.digest || !s.loaded + s.last, s.lastRev, s.loadErr, s.lastState = snap, ov.Rev, nil, st + s.loaded, s.digest = true, digest + subs := make([]chan []byte, 0, len(s.subs)) + for ch := range s.subs { + subs = append(subs, ch) + } + s.mu.Unlock() + + if !changed && len(subs) > 0 { + // Countdowns tick client-side, so a rebuild that says exactly what the + // last one said needs no push. Only the clock moved. + return + } + broadcast(snap, subs) +} + +// handleSetupRefresh re-probes the service's own PATH before rebuilding the +// snapshot. Ordinary state polls deliberately reuse the tool inventory; +// operators use this after installing a tool or repairing a service PATH. +func (s *Server) handleSetupRefresh(w http.ResponseWriter, r *http.Request) { + if !s.allowDashboardRead(w, r) { + return + } + + // refresh reads tools while holding refreshMu. Updating under the same lock + // keeps this explicit probe from racing the background state poll. + s.refreshMu.Lock() + s.tools = LocalTools() + s.refreshMu.Unlock() + + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + s.refresh(ctx) + snap, _, err := s.snapshot() + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"snapshot": snap}) +} + +// snapshotDigest fingerprints everything a browser would paint, with the render +// clock excluded. +// +// The revision alone is not enough to decide whether to push. BuildOverview +// derives categorical state from `now` — a quota block or slot hold expiring, a +// leader lease lapsing, a retry coming due, a dispatch claim or host report +// going dead — and none of that moves Rev. Client-side countdowns cannot remove +// a finished session or change a blocked headline, so a quiet fleet stayed +// visibly blocked until an unrelated write or a reload. +// +// Overview.Now is excluded precisely because it moves every poll: hashing it +// would make every rebuild look different and turn this back into an +// unconditional broadcast. Everything else `now` decides is categorical, so it +// changes only when the answer does. +func snapshotDigest(snap Snapshot) uint64 { + snap.Overview.Now = time.Time{} + payload, err := json.Marshal(snap) + if err != nil { + return 0 + } + h := fnv.New64a() + _, _ = h.Write(payload) + return h.Sum64() +} + +// markStale records that the state ref could not be read and tells everyone +// looking at the last good snapshot. +// +// Storing the error alone was not enough. The snapshot handlers only refuse +// when nothing has ever loaded, so once one load had succeeded a browser — and +// an action's post-write refresh — kept getting HTTP 200 and a state that had +// stopped being current, presented exactly as a live one. Nothing said the +// dashboard had lost the ref. +func (s *Server) markStale(err error) { + s.mu.Lock() + s.loadErr = err + if !s.loaded { + // Nothing has ever loaded, so there is no snapshot to mark — but the + // stream is open and healthy, and a browser that only ever consumes it + // would sit on "Reading the state ref…" for as long as the credential or + // the ref stays broken. The error is the only thing there is to say, so + // it is said on its own frame: a page with no snapshot can render it, and + // a client that does not know the event ignores it. + subs := s.subscribers() + s.mu.Unlock() + broadcastFrame(unavailableFrame(err), subs) + return + } + if s.last.Stale == nil { + // Since is when the ref was LOST, not when the latest retry failed: + // re-stamping it every poll would show an outage that is always five + // seconds old. + s.last.Stale = &Staleness{Error: err.Error(), Since: s.opts.Now()} + } else { + s.last.Stale.Error = err.Error() + } + snap := s.last + subs := s.subscribers() + s.mu.Unlock() + broadcast(snap, subs) +} + +// subscribers copies the current subscriber set. The caller must hold s.mu. +func (s *Server) subscribers() []chan []byte { + subs := make([]chan []byte, 0, len(s.subs)) + for ch := range s.subs { + subs = append(subs, ch) + } + return subs +} + +func broadcast(snap Snapshot, subs []chan []byte) { + payload, err := json.Marshal(snap) + if err != nil { + return + } + broadcastFrame(dataFrame(payload), subs) +} + +// Subscribers carry whole SSE frames rather than bare payloads, so a message +// that is not a snapshot can be sent down the same stream without the client +// having to tell them apart by inspecting one. +func dataFrame(payload []byte) []byte { + return fmt.Appendf(nil, "data: %s\n\n", payload) +} + +// unavailableFrame reports that there is no state to send yet, and why. Named +// rather than sent as data: a client reads it with an explicit listener, so one +// that predates the event simply never sees it instead of parsing an error as a +// snapshot. +func unavailableFrame(err error) []byte { + payload, merr := json.Marshal(map[string]string{"error": firstLoadError(err)}) + if merr != nil { + return nil + } + return fmt.Appendf(nil, "event: unavailable\ndata: %s\n\n", payload) +} + +func broadcastFrame(frame []byte, subs []chan []byte) { + if len(frame) == 0 { + return + } + for _, ch := range subs { + select { + case ch <- frame: + default: + // The channel holds one complete frame. Replace an unread old one + // with the current state so a browser that catches up never paints a + // snapshot the server already superseded. + select { + case <-ch: + default: + } + select { + case ch <- frame: + default: + } + } + } +} + +// snapshot is the last snapshot a load produced, whether one has been produced +// at all, and the error from the most recent read. +// +// loaded is what callers must gate on, not Rev and not a nil error. Until the +// first load returns there is no snapshot and no error either, and the zero +// value serves collections as null — which the client accepts as live state and +// immediately iterates. +func (s *Server) snapshot() (Snapshot, bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.last, s.loaded, s.loadErr +} + +func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) { + if err := s.addressedHere(r); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + return + } + snap, loaded, err := s.snapshot() + if !loaded { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": firstLoadError(err)}) + return + } + writeJSON(w, http.StatusOK, snap) +} + +func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) { + if err := s.addressedHere(r); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + return + } + snap, loaded, err := s.snapshot() + if !loaded { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": firstLoadError(err)}) + return + } + ov := snap.Overview + ov.Stale = snap.Stale + writeJSON(w, http.StatusOK, ov) +} + +// firstLoadError says why there is nothing to serve yet: the read that failed, +// or simply that the first one has not finished. +func firstLoadError(err error) string { + if err != nil { + return err.Error() + } + return "still reading the state ref" +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + snap, loaded, err := s.snapshot() + // Healthy means a load has succeeded AND the latest one did. Reporting ok + // before the first read finished would have a health check pass against a + // server that has never reached the state ref. + body := map[string]any{"rev": snap.Overview.Rev, "ok": loaded && err == nil} + if err != nil || !loaded { + body["error"] = firstLoadError(err) + } + writeJSON(w, http.StatusOK, body) +} + +// handleEvents streams whole snapshots. The state blob is small, so replacing +// it wholesale is simpler than diffing and makes reconnection trivially correct. +func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { + if err := s.addressedHere(r); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + ch := make(chan []byte, 1) + s.mu.Lock() + s.subs[ch] = struct{}{} + s.mu.Unlock() + defer func() { + s.mu.Lock() + delete(s.subs, ch) + s.mu.Unlock() + }() + + // Send the current state immediately so a fresh tab paints without waiting + // for the next change — but only once a load has produced one. A browser + // connecting during a slow first read would otherwise be handed the zero + // snapshot and take it for live state. The subscription is already in place, + // so that tab gets the real one the moment the load lands. + // + // A first read that has already FAILED is said outright instead. The stream + // itself is fine, so nothing else would ever tell this tab why it is empty. + switch snap, loaded, loadErr := s.snapshot(); { + case loaded: + if payload, err := json.Marshal(snap); err == nil { + _, _ = w.Write(dataFrame(payload)) + flusher.Flush() + } + case loadErr != nil: + _, _ = w.Write(unavailableFrame(loadErr)) + flusher.Flush() + } + + keepalive := time.NewTicker(25 * time.Second) + defer keepalive.Stop() + for { + select { + case <-r.Context().Done(): + return + case frame := <-ch: + _, _ = w.Write(frame) + flusher.Flush() + case <-keepalive.C: + fmt.Fprint(w, ": keepalive\n\n") + flusher.Flush() + } + } +} + +// assets serves the embedded SPA, falling back to index.html so client-side +// routes survive a reload. +func (s *Server) assets() http.Handler { + if s.opts.Assets == nil { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, unbuiltPage) + }) + } + files := http.FileServer(http.FS(s.opts.Assets)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/") + if path != "" { + if _, err := fs.Stat(s.opts.Assets, path); err != nil { + r = r.Clone(r.Context()) + r.URL.Path = "/" + path = "" + } + } + // The bundles are content-hashed, so a name that resolves at all + // resolves to the same bytes for ever and can be cached hard. The page + // that NAMES them cannot: a cached index.html keeps asking for the + // bundle it was built against, so a restarted server serves new assets + // nobody ever requests and the dashboard silently stays a version behind. + if strings.HasPrefix(path, "assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "no-cache") + } + files.ServeHTTP(w, r) + }) +} + +func writeJSON(w http.ResponseWriter, code int, body any) { + var payload bytes.Buffer + enc := json.NewEncoder(&payload) + enc.SetIndent("", " ") + if err := enc.Encode(body); err != nil { + http.Error(w, "could not encode response", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _, _ = w.Write(payload.Bytes()) +} + +const unbuiltPage = ` +Code Review Queue + +

Dashboard assets are not built

+

The API is running — /api/overview works — but the web app +was not compiled into this binary.

+

Build it from the repository root:

+

cd web && bun install && bun run build

+

then rebuild crq. The assets are embedded from web/dist.

+` + +// maxAttempts binds the per-repository fix budget to one loaded state, so the +// session card can say "attempt 2 of 5" rather than leaving the reader to guess +// whether that is nearly the last one. Shared by the overview and the PR page: +// two answers to one question is how the same session reads differently +// depending on which screen you are on. +func (s *Server) maxAttempts(st state.State) func(repo string) int { + return func(repo string) int { + if s.opts.SolverFor == nil { + return 0 + } + return s.opts.SolverFor(st, repo).MaxAttempts + } +} + +// botsFor binds the reviewer resolver to one loaded state, so a row can ask +// about its own repository without another state read. +func (s *Server) botsFor(st *state.State) BotsFor { + return func(repo string) []BotName { + if s.opts.Resolve == nil { + return s.opts.Bots + } + return s.opts.Resolve(*st, repo) + } +} diff --git a/internal/serve/server_test.go b/internal/serve/server_test.go new file mode 100644 index 00000000..6e53424e --- /dev/null +++ b/internal/serve/server_test.go @@ -0,0 +1,840 @@ +package serve + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +// stubLoader hands back one state, counting how many times it was asked. +type stubLoader struct { + st state.State + err error + reads int +} + +func (l *stubLoader) Load(context.Context) (state.State, state.Revision, error) { + l.reads++ + return l.st, state.Revision{}, l.err +} + +type countingObserver struct{ calls int } + +func (o *countingObserver) Observe(context.Context, string, int) (Observation, error) { + o.calls++ + return Observation{}, nil +} + +type sequenceObserver struct { + observations []Observation + calls int +} + +func (o *sequenceObserver) Observe(context.Context, string, int) (Observation, error) { + got := o.observations[o.calls] + o.calls++ + return got, nil +} + +type failingObserver struct{ calls int } + +func (o *failingObserver) Observe(context.Context, string, int) (Observation, error) { + o.calls++ + return Observation{}, errors.New("observation unavailable") +} + +type countingCoster struct { + cost Cost + calls int +} + +func (c *countingCoster) Cost(context.Context, string, int) (Cost, error) { + c.calls++ + return c.cost, nil +} + +func TestRefreshSuppressesOnlyTheInitialRevisionZeroLoad(t *testing.T) { + at := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + loader := &stubLoader{st: state.New()} + srv := New(loader, Options{Now: func() time.Time { return at }}) + srv.refresh(t.Context()) + if events := srv.events.list(); len(events) != 0 { + t.Fatalf("initial load events = %+v, want the existing snapshot suppressed", events) + } + + loader.st.Rev = 1 + loader.st.Enrolled = map[string]state.RepoEnrollment{ + "o/enrolled": {Enabled: true, By: "atlas", UpdatedAt: &at}, + } + srv.refresh(t.Context()) + if events := srv.events.list(); !hasEventText(events, "Repository enrolled for review") { + t.Fatalf("first mutation events = %+v, want the revision-zero predecessor retained", events) + } +} + +func TestRefreshUsesResolvedAllowListForRepositoryRows(t *testing.T) { + st := state.New() + st.Rev = 4 + srv := New(&stubLoader{st: st}, Options{ + Now: time.Now, + Fleet: FleetConfig{AllowRepos: []string{"startup/old"}}, + AllowReposFor: func(state.State) []string { + return []string{"fleet/new"} + }, + }) + srv.refresh(t.Context()) + if len(srv.last.Repos) != 1 || srv.last.Repos[0].Repo != "fleet/new" { + t.Fatalf("repository rows = %+v, want the resolved fleet allow-list", srv.last.Repos) + } +} + +func TestRefreshedSnapshotReportsALoadFailureAfterAnAction(t *testing.T) { + loader := &stubLoader{st: state.New()} + srv := New(loader, Options{Now: func() time.Time { return time.Unix(0, 0).UTC() }}) + if _, err := srv.refreshedSnapshot(t.Context()); err != nil { + t.Fatalf("initial refresh: %v", err) + } + + loader.err = errors.New("state ref unavailable") + if _, err := srv.refreshedSnapshot(t.Context()); err == nil || + !strings.Contains(err.Error(), "state ref unavailable") { + t.Fatalf("failed refresh error = %v, want the state read failure", err) + } +} + +func TestActionSnapshotKeepsThePreviousViewWhenRefreshFails(t *testing.T) { + loader := &stubLoader{st: state.New()} + srv := New(loader, Options{Now: func() time.Time { return time.Unix(0, 0).UTC() }}) + if _, err := srv.refreshedSnapshot(t.Context()); err != nil { + t.Fatalf("initial refresh: %v", err) + } + + loader.err = errors.New("state ref unavailable") + snap, warning := srv.actionSnapshot(t.Context()) + if warning == "" || !strings.Contains(warning, "action succeeded") { + t.Fatalf("warning = %q, want a completed-action warning", warning) + } + if snap.Overview.Now.IsZero() { + t.Fatal("action response lost the last usable snapshot") + } +} + +// Before the first load returns there is no snapshot — and no error either. The +// zero Snapshot encodes its collections as null, and the client takes a 200 for +// live state and iterates them straight away, so the dashboard crashed during +// ordinary startup against a slow state read. +func TestHandlersRefuseUntilTheFirstLoadSucceeds(t *testing.T) { + srv := New(&stubLoader{}, Options{Now: func() time.Time { return time.Unix(0, 0).UTC() }}) + + for _, path := range []string{"/api/snapshot", "/api/overview"} { + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil) + req.Host = "localhost" + switch path { + case "/api/snapshot": + srv.handleSnapshot(rec, req) + default: + srv.handleOverview(rec, req) + } + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("%s = %d before any load, want 503 rather than a null-filled snapshot", path, rec.Code) + } + } + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/pr/o/r/1", nil) + req.Host = "localhost" + req.Header.Set("X-CRQ-Dashboard", "1") + req.SetPathValue("owner", "o") + req.SetPathValue("name", "r") + req.SetPathValue("pr", "1") + srv.handlePR(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("/api/pr = %d before any load, want 503 rather than a false empty PR", rec.Code) + } + + // Health must not read as ok either: a check that passes here passes against + // a server that has never reached the state ref. + rec = httptest.NewRecorder() + srv.handleHealth(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/health", nil)) + var health map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &health); err != nil { + t.Fatal(err) + } + if health["ok"] != false { + t.Errorf("health = %v before any load, want ok false", health) + } + + // And the SSE stream sends nothing until there is something real to send. + rec = httptest.NewRecorder() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + req = httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/events", nil) + req.Host = "localhost" + srv.handleEvents(rec, req) + if body := rec.Body.String(); body != "" { + t.Errorf("the stream sent %q before any load; a browser would take it for live state", body) + } + + // Once a load lands, everything answers. + srv.refresh(context.Background()) + rec = httptest.NewRecorder() + req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/snapshot", nil) + req.Host = "localhost" + srv.handleSnapshot(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("snapshot = %d after a successful load, want 200", rec.Code) + } +} + +func TestSetupRefreshReprobesToolsWithoutAnActor(t *testing.T) { + loader := &stubLoader{} + srv := New(loader, Options{ + Addr: "127.0.0.1:7777", + Host: "test-host", + Now: func() time.Time { return time.Unix(0, 0).UTC() }, + }) + // A made-up cached tool proves the handler replaced the inventory rather + // than merely returning another copy of the existing snapshot. + srv.tools = []Tool{{Name: "definitely-not-a-real-crq-tool", Found: true}} + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "http://127.0.0.1:7777/api/setup/refresh", nil) + req.Header.Set("X-CRQ-Dashboard", "1") + rec := httptest.NewRecorder() + srv.handleSetupRefresh(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("refresh = %d: %s", rec.Code, rec.Body.String()) + } + var result struct { + Snapshot struct { + Setup SetupView `json:"setup"` + } `json:"snapshot"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + for _, tool := range result.Snapshot.Setup.Tools { + if tool.Name == "definitely-not-a-real-crq-tool" { + t.Fatal("refresh returned the stale cached tool inventory") + } + } + if loader.reads != 1 { + t.Fatalf("state reads = %d, want one fresh snapshot read", loader.reads) + } + + rec = httptest.NewRecorder() + srv.handleSetupRefresh(rec, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "http://127.0.0.1:7777/api/setup/refresh", nil)) + if rec.Code != http.StatusForbidden { + t.Fatalf("refresh without dashboard header = %d, want 403", rec.Code) + } +} + +// A first read that FAILS is a different thing from one still running, and the +// stream is the only thing the page consumes. Sending nothing left a broken +// credential or state ref looking exactly like a slow one — the dashboard sat on +// "Reading the state ref…" indefinitely while every other handler could say why. +func TestTheStreamSaysWhyThereIsNoStateYet(t *testing.T) { + loader := &stubLoader{err: errors.New("state ref unreadable: bad credentials")} + srv := New(loader, Options{Now: func() time.Time { return time.Unix(0, 0).UTC() }}) + srv.refresh(context.Background()) + + rec := httptest.NewRecorder() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/events", nil) + req.Host = "localhost" + srv.handleEvents(rec, req) + + body := rec.Body.String() + if !strings.HasPrefix(body, "event: unavailable\n") { + t.Fatalf("stream sent %q, want a named event a snapshot reader ignores", body) + } + if !strings.Contains(body, "bad credentials") { + t.Errorf("stream sent %q, want the error that explains the empty page", body) + } +} + +func TestBroadcastReplacesAQueuedStaleFrame(t *testing.T) { + ch := make(chan []byte, 1) + ch <- []byte("old") + broadcastFrame([]byte("new"), []chan []byte{ch}) + if got := string(<-ch); got != "new" { + t.Fatalf("queued frame = %q, want the latest frame", got) + } +} + +// The dashboard header stops an ordinary cross-site POST but not a DNS rebind: +// a name the attacker controls, re-pointed at 127.0.0.1, is same-origin as far +// as the browser is concerned and may set any header it likes. The name the +// request was addressed to is what still tells the two apart. +func TestActionsAreRefusedOnANameThatOnlyResolvesHere(t *testing.T) { + srv := New(&stubLoader{}, Options{Addr: "127.0.0.1:7777", Host: "atlas", AllowedHosts: []string{"crq.example.test"}}) + + for _, host := range []string{ + "127.0.0.1:7777", "[::1]:7777", "192.168.1.4:7777", // an IP literal cannot be rebound + "localhost:7777", "crq.localhost:7777", + "atlas", "atlas.local:7777", "atlas.tail1234.ts.net", // the same machine, however it is reached + "crq.example.test:7777", // named with --allow-host + } { + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/action/hold", nil) + req.Host = host + if err := srv.addressedHere(req); err != nil { + t.Errorf("Host %q was refused: %v", host, err) + } + } + for _, host := range []string{ + "", "evil.test:7777", "crq.example.test.evil.test:7777", + // The machine is called `atlas`, and this name is not it: a zone its + // owner controls, pointed here, is the rebinding the check is for. + "atlas.attacker.example:7777", "atlas.evil.test", + } { + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/action/hold", nil) + req.Host = host + if err := srv.addressedHere(req); err == nil { + t.Errorf("Host %q was accepted; a page on that name could act on this fleet", host) + } + } + + // And an Origin that contradicts the Host is not a same-origin request + // whatever it claims, even when the Host itself is one we answer to. + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/action/hold", nil) + req.Host = "localhost:7777" + req.Header.Set("Origin", "http://evil.test") + if err := srv.addressedHere(req); err == nil { + t.Error("a cross-origin POST to localhost was accepted") + } +} + +func TestSnapshotStreamsAreRefusedOnANameThatOnlyResolvesHere(t *testing.T) { + srv := New(&stubLoader{}, Options{Addr: "127.0.0.1:7777", Host: "atlas"}) + srv.loaded = true + + for _, handle := range []func(http.ResponseWriter, *http.Request){ + srv.handleSnapshot, + srv.handleOverview, + srv.handleEvents, + } { + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) + req.Host = "attacker.example:7777" + handle(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("snapshot read on rebound host = %d, want 403", rec.Code) + } + } +} + +func TestQuotaHeavyReadsRequireDashboardOriginProof(t *testing.T) { + srv := New(&stubLoader{}, Options{Addr: "127.0.0.1:7777", Host: "atlas"}) + + for _, tc := range []struct { + name string + host string + header string + want bool + }{ + {name: "dashboard", host: "localhost:7777", header: "1", want: true}, + {name: "cross-site get", host: "localhost:7777", want: false}, + {name: "dns rebind", host: "attacker.example:7777", header: "1", want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/discover?refresh=1", nil) + req.Host = tc.host + req.Header.Set("X-CRQ-Dashboard", tc.header) + if got := srv.allowDashboardRead(rec, req); got != tc.want { + t.Fatalf("allowed = %v, want %v (status %d)", got, tc.want, rec.Code) + } + }) + } +} + +func TestPRReadRequiresDashboardOriginProofBeforeObserving(t *testing.T) { + observer := &countingObserver{} + srv := New(&stubLoader{}, Options{Addr: "127.0.0.1:7777", Observer: observer}) + srv.loaded = true + srv.lastState = state.New() + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:7777/api/pr/o/r/1?refresh=1", nil) + req.SetPathValue("owner", "o") + req.SetPathValue("name", "r") + req.SetPathValue("pr", "1") + srv.handlePR(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("unauthorized PR read = %d, want 403", rec.Code) + } + if observer.calls != 0 { + t.Fatalf("observer calls = %d, want none before authorization", observer.calls) + } +} + +// Rev alone cannot decide whether to push. BuildOverview derives categorical +// state from `now` — a quota block expiring, a lease lapsing, a claim going +// dead — and none of that moves Rev, so a quiet fleet stayed visibly blocked +// until an unrelated write. The render clock itself must NOT count, or every +// poll would broadcast and the change detection would mean nothing. +func TestSnapshotDigestIgnoresTheClockButNotWhatItDecides(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + blocked := now.Add(10 * time.Minute) + st := state.New() + st.Account.BlockedUntil = &blocked + + build := func(at time.Time) Snapshot { + ov := BuildOverview(st, at, 0, time.Minute, func(string) []BotName { return nil }, 0, nil) + return BuildFleet(st, FleetConfig{}, ov, nil, "testhost", at, + func(string) []BotName { return nil }, nil, nil, nil, nil) + } + + if a, b := snapshotDigest(build(now)), snapshotDigest(build(now.Add(time.Second))); a != b { + t.Error("the render clock moved the digest; every poll would broadcast and nothing would be gained") + } + if a, b := snapshotDigest(build(now)), snapshotDigest(build(blocked.Add(time.Minute))); a == b { + t.Error("the quota block expired without changing the digest, so nothing would be pushed") + } +} + +// The rolling-upgrade table is only as right as its idea of "newest". Compared +// as text, 2.9.0 outranks 2.10.0 the first time the fleet crosses a digit +// boundary — and then every upgraded host is warned about while the hosts still +// running the old binary read as current, which is the warning exactly +// backwards. +func TestNewestHostVersionIsPickedNumerically(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + st := state.State{HostReports: map[string]state.HostReport{ + "atlas": {Host: "atlas", Version: "2.9.0", At: now}, + "borg": {Host: "borg", Version: "2.10.0", At: now}, + }} + + behind := map[string]bool{} + for _, row := range hostTools(st, now) { + behind[row.Host] = row.Behind + } + if !behind["atlas"] { + t.Error("2.9.0 is behind 2.10.0 and must be marked so") + } + if behind["borg"] { + t.Error("2.10.0 is the newest crq reporting and must not be marked behind") + } +} + +// A round's answer belongs to whichever primary produced it. CRQ_BOT is a +// setting, so a fleet that changes it leaves rounds the retired bot answered +// behind — and attributing those to whatever this process calls its primary +// showed the newly configured bot as working and the one that actually reviewed +// as silent, which is both claims backwards. +func TestAPrimarysAnswerStaysWithThePrimaryThatGaveIt(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + answered := now.Add(-time.Hour) + st := state.State{Rounds: map[string]state.Round{ + "o/repo#1": { + Repo: "o/repo", PR: 1, Head: "abcdef123", Phase: state.PhaseCompleted, + PrimaryAnsweredAt: &answered, PrimaryAnsweredBy: "coderabbitai[bot]", + }, + }} + // The dashboard has since been configured with a different primary. + cfg := FleetConfig{GateRepo: "o/gate", Reviewers: []ReviewerCfg{ + {Login: "macroscope[bot]", Name: "macroscope", Primary: true, Metered: true}, + }} + running := []BotName{{Login: "macroscope[bot]", Name: "macroscope", Primary: true}} + + macroscope := func(st state.State) BotCard { + for _, card := range botCards(st, cfg, func(string) []BotName { return running }, now) { + if card.Login == "macroscope[bot]" { + return card + } + } + t.Fatalf("no card for the configured primary") + return BotCard{} + } + if got := macroscope(st); got.LastSeen != nil || got.Status == "working" { + t.Errorf("the new primary was credited with a review it never did: %+v", got) + } + + // A round recorded before the login was stored has nobody to attribute it + // to but the running primary, which is what crq assumed for every round + // until now — so the fallback stays, and only rounds that name a bot move. + legacy := st.Rounds["o/repo#1"] + legacy.PrimaryAnsweredBy = "" + st.Rounds["o/repo#1"] = legacy + if got := macroscope(st); got.LastSeen == nil || !got.LastSeen.Equal(answered) { + t.Errorf("an unattributed answer must still count for the running primary: %+v", got) + } +} + +func TestAPrimaryAskStaysWithThePrimaryItAddressed(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + asked := now.Add(-time.Hour) + st := state.State{Rounds: map[string]state.Round{ + "o/repo#1": { + Repo: "o/repo", PR: 1, Head: "abcdef123", Phase: state.PhaseReviewing, + FiredAt: &asked, CommandID: 42, + PostedCommands: []state.PostedCommand{{ID: 42, Bot: "coderabbitai[bot]", At: asked}}, + }, + }} + cfg := FleetConfig{Reviewers: []ReviewerCfg{ + {Login: "macroscope[bot]", Name: "macroscope", Primary: true, Metered: true}, + {Login: "coderabbitai[bot]", Name: "coderabbit", Metered: true}, + }} + running := []BotName{{Login: "macroscope[bot]", Name: "macroscope", Primary: true}} + + cards := botCards(st, cfg, func(string) []BotName { return running }, now) + var current, retired BotCard + for _, card := range cards { + switch card.Login { + case "macroscope[bot]": + current = card + case "coderabbitai[bot]": + retired = card + } + } + if current.LastAsked != nil { + t.Errorf("new primary was credited with the retired primary's request: %+v", current) + } + if retired.LastAsked == nil || !retired.LastAsked.Equal(asked) { + t.Errorf("retired primary lost the request addressed to it: %+v", retired) + } +} + +func TestBotCardsUseTheEffectiveFleetPrimary(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + cfg := FleetConfig{Reviewers: []ReviewerCfg{ + {Login: "coderabbitai[bot]", Name: "coderabbit", Primary: true, Metered: true}, + {Login: "cursor[bot]", Name: "bugbot"}, + }} + running := []BotName{ + {Login: "cursor[bot]", Name: "bugbot", Primary: true, Required: true}, + } + cards := botCards(state.State{}, cfg, func(string) []BotName { return running }, now) + primary := "" + for _, card := range cards { + if card.Primary { + if primary != "" { + t.Fatalf("multiple primary cards: %q and %q", primary, card.Login) + } + primary = card.Login + } + if card.Login == "coderabbitai[bot]" && card.Primary { + t.Fatal("the retired startup primary is still marked primary") + } + } + if primary != "cursor[bot]" { + t.Fatalf("primary card = %q, want the effective fleet primary", primary) + } +} + +func TestBotCardsRetainConfiguredPrimaryWhenEffectiveSetIsUnavailable(t *testing.T) { + cfg := FleetConfig{Reviewers: []ReviewerCfg{{ + Login: "coderabbitai[bot]", Name: "coderabbit", Primary: true, Metered: true, + }}} + cards := botCards(state.State{}, cfg, func(string) []BotName { return nil }, time.Now()) + for _, card := range cards { + if card.Login == "coderabbitai[bot]" { + if !card.Primary || !card.Metered { + t.Fatalf("configured fallback primary = %+v", card) + } + return + } + } + t.Fatal("no card for the configured primary") +} + +func TestBotCardsUseEffectiveReviewerMetadata(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + cfg := FleetConfig{Reviewers: []ReviewerCfg{{ + Login: "cursor[bot]", Name: "bugbot", Command: "old command", + Trigger: "never", Grace: Dur(time.Minute), + }}} + running := []BotName{{ + Login: "cursor[bot]", Name: "bugbot", Command: "new command", + Trigger: "always", Grace: Dur(7 * time.Minute), + }} + cards := botCards(state.State{}, cfg, func(string) []BotName { return running }, now) + for _, card := range cards { + if card.Login != "cursor[bot]" { + continue + } + if card.Command != "new command" || card.Trigger != "always" || card.Grace != Dur(7*time.Minute) { + t.Fatalf("card = %+v, want effective state-resolved metadata", card) + } + return + } + t.Fatal("no card for the effective reviewer") +} + +func TestBotCardsDistinguishRegistryReviewersFromCustomGates(t *testing.T) { + cfg := FleetConfig{Reviewers: []ReviewerCfg{ + {Login: "cursor[bot]", Name: "bugbot"}, + {Login: "sonar[bot]", Name: "sonar", Required: true}, + }} + cards := botCards(state.State{}, cfg, func(string) []BotName { return nil }, time.Now()) + got := map[string]bool{} + for _, card := range cards { + got[card.Login] = card.Configurable + } + if !got["cursor[bot]"] { + t.Error("registry co-reviewer is not marked configurable") + } + if got["sonar[bot]"] { + t.Error("custom required login is marked as a triggerable registry co-reviewer") + } +} + +func TestBotCardsIncludeRepositoryOnlyReviewers(t *testing.T) { + st := state.New() + st.Repos = map[string]state.RepoReviewers{} + st.Repos["o/special"] = state.RepoReviewers{ + Required: []string{"cursor[bot]"}, + SetRequired: true, + } + botsFor := func(repo string) []BotName { + if repo == "o/special" { + return []BotName{{ + Login: "cursor[bot]", Name: "bugbot", Required: true, + Command: "bugbot run", Trigger: "always", Grace: Dur(time.Minute), + }} + } + return nil + } + + for _, card := range botCards(st, FleetConfig{}, botsFor, time.Now()) { + if card.Login != "cursor[bot]" { + continue + } + if !card.Enabled || !card.Required || card.Status != "unverified" || card.RepoCount != 1 { + t.Fatalf("repository-only reviewer card = %+v", card) + } + if card.Command != "bugbot run" || card.Trigger != "always" || card.Grace != Dur(time.Minute) { + t.Fatalf("repository-only reviewer lost effective metadata: %+v", card) + } + return + } + t.Fatal("no card for the repository-only reviewer") +} + +func TestBotCardsCountEffectiveReviewersInheritedByAnOverride(t *testing.T) { + st := state.New() + st.Repos = map[string]state.RepoReviewers{ + "o/required-only": { + Required: []string{"coderabbitai[bot]"}, SetRequired: true, + }, + } + botsFor := func(string) []BotName { + return []BotName{ + {Login: "coderabbitai[bot]", Name: "coderabbit", Primary: true, Required: true}, + {Login: "cursor[bot]", Name: "bugbot"}, + } + } + + for _, card := range botCards(st, FleetConfig{}, botsFor, time.Now()) { + if card.Login == "cursor[bot]" { + if card.RepoCount != 1 { + t.Fatalf("inherited reviewer repo count = %d, want 1", card.RepoCount) + } + return + } + } + t.Fatal("no card for inherited reviewer") +} + +func TestCoOnlyRoundLeavesPrimaryPending(t *testing.T) { + at := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + marks := botMarks(state.Round{FiredAt: &at, CommandID: 42, CoOnly: true}, []BotName{{ + Login: "coderabbitai[bot]", Name: "coderabbit", Primary: true, + }}) + if len(marks) != 1 || marks[0].Mark != "pending" { + t.Fatalf("marks = %+v, want the uncommanded primary pending", marks) + } +} + +func TestObservationKeyIncludesEffectiveReviewers(t *testing.T) { + base := []BotName{{Login: "coderabbitai[bot]", Primary: true, Required: true}} + changed := []BotName{ + {Login: "coderabbitai[bot]", Primary: true, Required: true}, + {Login: "cursor[bot]", Required: true, Trigger: "always", Command: "bugbot run"}, + } + if observationKey("o/r", 1, "abcdef123", 7, base) == + observationKey("o/r", 1, "abcdef123", 7, changed) { + t.Fatal("reviewer change reused the old observation cache key") + } +} + +func TestObservationKeyIncludesStateRevision(t *testing.T) { + bots := []BotName{{Login: "coderabbitai[bot]", Primary: true, Required: true}} + if observationKey("o/r", 1, "abcdef123", 7, bots) == + observationKey("o/r", 1, "abcdef123", 8, bots) { + t.Fatal("round-state change reused the old observation cache key") + } +} + +func TestCostKeyIncludesReviewerPricingPolicy(t *testing.T) { + base := []BotName{{Login: "macroscope-app[bot]", Trigger: "selfheal"}} + always := []BotName{{Login: "macroscope-app[bot]", Trigger: "always"}} + if costKey("o/r", 1, "abcdef123", base, nil) == + costKey("o/r", 1, "abcdef123", always, nil) { + t.Fatal("trigger policy change reused the old cost cache key") + } + primary := []BotName{{Login: "macroscope-app[bot]", Trigger: "selfheal", Primary: true}} + if costKey("o/r", 1, "abcdef123", base, nil) == + costKey("o/r", 1, "abcdef123", primary, nil) { + t.Fatal("reviewer role change reused the old cost cache key") + } +} + +func TestPRObservationCacheRejectsAnObservationForAnotherHead(t *testing.T) { + now := time.Now().UTC() + observer := &sequenceObserver{observations: []Observation{ + {Head: "bbbbbbbbb", CheckedAt: now}, + {Head: "ccccccccc", CheckedAt: now.Add(time.Second)}, + }} + srv := New(&stubLoader{}, Options{Addr: "127.0.0.1:7777", Observer: observer}) + srv.loaded = true + srv.lastState = state.New() + srv.lastState.Rounds = map[string]state.Round{ + "o/r#1": {Repo: "o/r", PR: 1, Head: "aaaaaaaaa", Phase: state.PhaseCompleted}, + } + + request := func() { + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:7777/api/pr/o/r/1", nil) + req.Header.Set("X-CRQ-Dashboard", "1") + req.SetPathValue("owner", "o") + req.SetPathValue("name", "r") + req.SetPathValue("pr", "1") + srv.handlePR(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PR read = %d: %s", rec.Code, rec.Body.String()) + } + } + request() + request() + if observer.calls != 2 { + t.Fatalf("observer calls = %d, want a refetch because the cached observation belonged to another head", observer.calls) + } +} + +func TestPROmitsObservationThatRacedAheadOfState(t *testing.T) { + now := time.Now().UTC() + observer := &sequenceObserver{observations: []Observation{{ + Head: "bbbbbbbbb", CheckedAt: now, + }}} + srv := New(&stubLoader{}, Options{Addr: "127.0.0.1:7777", Observer: observer}) + srv.loaded = true + srv.lastState = state.New() + srv.lastState.Rounds = map[string]state.Round{ + "o/r#1": {Repo: "o/r", PR: 1, Head: "aaaaaaaaa", Phase: state.PhaseCompleted}, + } + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:7777/api/pr/o/r/1", nil) + req.Header.Set("X-CRQ-Dashboard", "1") + req.SetPathValue("owner", "o") + req.SetPathValue("name", "r") + req.SetPathValue("pr", "1") + srv.handlePR(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PR read = %d: %s", rec.Code, rec.Body.String()) + } + var view PRView + if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil { + t.Fatal(err) + } + if view.Observed != nil || !strings.Contains(view.ObserveError, "moved") { + t.Fatalf("observed = %+v error = %q, want mixed-head observation omitted", view.Observed, view.ObserveError) + } +} + +func TestPRCostCacheDoesNotUseAStaleRoundHeadWhenObservationFails(t *testing.T) { + observer := &failingObserver{} + coster := &countingCoster{cost: Cost{Head: "bbbbbbbbb"}} + srv := New(&stubLoader{}, Options{ + Addr: "127.0.0.1:7777", Observer: observer, Coster: coster, + }) + srv.loaded = true + srv.lastState = state.New() + srv.lastState.Rounds = map[string]state.Round{ + "o/r#1": {Repo: "o/r", PR: 1, Head: "aaaaaaaaa", Phase: state.PhaseCompleted}, + } + + request := func() { + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:7777/api/pr/o/r/1", nil) + req.Header.Set("X-CRQ-Dashboard", "1") + req.SetPathValue("owner", "o") + req.SetPathValue("name", "r") + req.SetPathValue("pr", "1") + srv.handlePR(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PR read = %d: %s", rec.Code, rec.Body.String()) + } + } + request() + request() + if coster.calls != 2 { + t.Fatalf("cost calls = %d, want a fresh live-head price while observation is unavailable", coster.calls) + } +} + +func TestPRRejectsCostForADifferentObservedHead(t *testing.T) { + now := time.Now().UTC() + observer := &sequenceObserver{observations: []Observation{{Head: "aaaaaaaaa", CheckedAt: now}}} + coster := &countingCoster{cost: Cost{Head: "bbbbbbbbb"}} + srv := New(&stubLoader{}, Options{ + Addr: "127.0.0.1:7777", Observer: observer, Coster: coster, + }) + srv.loaded = true + srv.lastState = state.New() + srv.lastState.Rounds = map[string]state.Round{ + "o/r#1": {Repo: "o/r", PR: 1, Head: "aaaaaaaaa", Phase: state.PhaseCompleted}, + } + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:7777/api/pr/o/r/1", nil) + req.Header.Set("X-CRQ-Dashboard", "1") + req.SetPathValue("owner", "o") + req.SetPathValue("name", "r") + req.SetPathValue("pr", "1") + srv.handlePR(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PR read = %d: %s", rec.Code, rec.Body.String()) + } + var view PRView + if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil { + t.Fatal(err) + } + if view.Cost != nil || !strings.Contains(view.CostError, "moved") { + t.Fatalf("cost = %+v error = %q, want the mixed-head estimate omitted", view.Cost, view.CostError) + } +} + +func TestPRStateOnlyReadSkipsGitHubObservationAndPricing(t *testing.T) { + observer := &countingObserver{} + coster := &countingCoster{} + srv := New(&stubLoader{}, Options{ + Addr: "127.0.0.1:7777", Observer: observer, Coster: coster, + }) + srv.loaded = true + srv.lastState = state.New() + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:7777/api/pr/o/r/1?state_only=1", nil) + req.Header.Set("X-CRQ-Dashboard", "1") + req.SetPathValue("owner", "o") + req.SetPathValue("name", "r") + req.SetPathValue("pr", "1") + srv.handlePR(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PR state read = %d: %s", rec.Code, rec.Body.String()) + } + if observer.calls != 0 || coster.calls != 0 { + t.Fatalf("state-only read made %d observation and %d cost calls", observer.calls, coster.calls) + } +} diff --git a/internal/state/autofix_test.go b/internal/state/autofix_test.go index bb868b1f..eb42a910 100644 --- a/internal/state/autofix_test.go +++ b/internal/state/autofix_test.go @@ -23,7 +23,7 @@ func TestAutofixHealthSurfacesAFailingDispatcher(t *testing.T) { if st.Autofix.Unhealthy() { t.Error("one failure must not raise the alarm") } - for i := 1; i < AutofixUnhealthyAfter; i++ { + for i := 0; i < AutofixUnhealthyAfter; i++ { st.NoteDispatch("cachyos", false, "refusing to fetch into branch", now) } if !st.Autofix.Unhealthy() { diff --git a/internal/state/autofixswitch.go b/internal/state/autofixswitch.go index be02fb4d..2ac13d1b 100644 --- a/internal/state/autofixswitch.go +++ b/internal/state/autofixswitch.go @@ -13,6 +13,8 @@ type RepoAutofixSwitch struct { UpdatedAt *time.Time `json:"updated_at,omitempty"` By string `json:"by,omitempty"` Reason string `json:"reason,omitempty"` + + unknown unknownFields } // AutofixEnabled reports whether fix sessions may run for repo. @@ -28,7 +30,11 @@ type RepoAutofixSwitch struct { // is a class of divergence worth not having. func (s *State) AutofixEnabled(repo string) bool { sw, ok := s.RepoAutofix[autofixRepoKey(repo)] - return !ok || sw.Enabled + if !ok { + // The fleet default, which is on unless one was recorded otherwise. + return s.AutofixDefaultOn() + } + return sw.Enabled } // AutofixSwitch returns repo's explicit setting, and whether one exists. @@ -42,7 +48,11 @@ func (s *State) SetAutofixSwitch(repo string, sw RepoAutofixSwitch) { if s.RepoAutofix == nil { s.RepoAutofix = map[string]RepoAutofixSwitch{} } - s.RepoAutofix[autofixRepoKey(repo)] = sw + key := autofixRepoKey(repo) + if prev, ok := s.RepoAutofix[key]; ok { + sw.unknown = carryUnknown(sw.unknown, prev.unknown) + } + s.RepoAutofix[key] = sw } // ClearAutofixSwitch drops repo's setting, returning it to the default, and diff --git a/internal/state/dashboard.go b/internal/state/dashboard.go index 74d3f0e7..ca246c92 100644 --- a/internal/state/dashboard.go +++ b/internal/state/dashboard.go @@ -43,15 +43,44 @@ func joinScope(scope []string) string { return strings.Join(scope, ",") } -func dashboardLoc(cfg StoreConfig) *time.Location { - if cfg.Timezone != "" { - if loc, err := time.LoadLocation(cfg.Timezone); err == nil { +// dashboardLoc is the zone this issue renders its times in. +// +// The fleet record is asked first, and this host's CRQ_TZ only stands in for it. +// The issue is one shared artifact, so "how times are rendered" is a fleet +// answer by nature: taken from the rendering host's startup environment alone, +// a timezone saved from the settings page was reported as in force while every +// sync went on writing the zone of whichever machine happened to run it. +func dashboardLoc(st State, cfg StoreConfig) *time.Location { + for _, name := range []string{st.Fleet.Env["CRQ_TZ"], cfg.Timezone} { + if name = strings.TrimSpace(name); name == "" { + continue + } + if loc, err := time.LoadLocation(name); err == nil { return loc } } return time.UTC } +// dashboardInterval is the pacing the queue is actually kept at: the fleet's +// recorded floor when there is one, and the rendering process's own +// configuration otherwise. +// +// Same reasoning as dashboardLoc, and the same failure. This issue is written by +// whichever host happens to save state, so reading its startup environment made +// ReadyAt, the queue's order and the "next at" headline advertise a fire time +// Pump — which resolves the setting from the record — does not follow. +func dashboardInterval(st State, cfg StoreConfig) time.Duration { + // The typed field first: it is the refinement the generic env layer is + // merged under, which is the precedence the configuration itself applies. + for _, text := range []string{st.Fleet.MinInterval, st.Fleet.Env["CRQ_MIN_INTERVAL"]} { + if d, err := time.ParseDuration(strings.TrimSpace(text)); err == nil && d >= 0 { + return d + } + } + return cfg.MinInterval +} + func fmtStamp(t *time.Time, loc *time.Location) string { if t == nil { return "—" @@ -272,10 +301,9 @@ func hostCell(writer string) string { // RenderDashboard renders the human-facing dashboard for the current state: // rounds by phase instead of v2's queue/fired/awaiting maps. func RenderDashboard(st State, cfg StoreConfig) string { - cfg = cfg.withFleet(st) - loc := dashboardLoc(cfg) + loc := dashboardLoc(st, cfg) now := time.Now().UTC() - queue := st.Queue(now, cfg.MinInterval) + queue := st.Queue(now, dashboardInterval(st, cfg)) inFlight := inFlightRounds(st) held := heldRounds(st) slot := st.SlotRound() @@ -332,8 +360,12 @@ func RenderDashboard(st State, cfg StoreConfig) string { } else { fmt.Fprintf(&b, "| **CodeRabbit quota** | ✅ not currently blocked |\n") } - if cfg.CoReviewers != "" || hasCoReviewerOverrides(st) { - fmt.Fprintf(&b, "| **Co-reviewers** | %s |\n", coReviewerCell(st, cfg.CoReviewers)) + coReviewers := cfg.CoReviewers + if cfg.ResolveCoReviewers != nil { + coReviewers = cfg.ResolveCoReviewers(st.Fleet) + } + if coReviewers != "" { + fmt.Fprintf(&b, "| **Co-reviewers** | %s |\n", coReviewerCell(st, coReviewers)) } fmt.Fprintf(&b, "| **Last review fired** | %s |\n", fmtStamp(st.LastFired, loc)) if st.Autofix.Unhealthy() { @@ -427,7 +459,6 @@ func RenderDashboard(st State, cfg StoreConfig) string { // count is the WHOLE queue, cooling-down rounds included: a state whose only // work is not yet fire-eligible is queued, never idle. func RenderTitle(st State, cfg StoreConfig) string { - cfg = cfg.withFleet(st) now := time.Now().UTC() queue := len(st.Queue(now, cfg.MinInterval)) held := len(heldRounds(st)) @@ -485,14 +516,14 @@ func cell(v string) string { // labelled, and the repositories that differ are named beside it. func coReviewerCell(st State, fleet string) string { overrides := make([]string, 0, len(st.Repos)) - for repo, settings := range st.Repos { - if !settings.SetCoBots { + for repo, override := range st.Repos { + if !override.SetCoBots { continue } overrides = append(overrides, repo) } if len(overrides) == 0 { - return dash(fleet) + " _(fleet default)_" + return fleet + " _(fleet default)_" } sort.Strings(overrides) // Named, not just counted: "3 repositories differ" sends the reader to the @@ -503,19 +534,10 @@ func coReviewerCell(st State, fleet string) string { if len(listed) > show { listed, suffix = listed[:show], fmt.Sprintf(" +%d more", len(overrides)-show) } - return fmt.Sprintf("%s _(fleet default; %s override%s)_", dash(fleet), + return fmt.Sprintf("%s _(fleet default; %s override%s)_", fleet, strings.Join(listed, ", ")+suffix, plural(len(overrides))) } -func hasCoReviewerOverrides(st State) bool { - for _, settings := range st.Repos { - if settings.SetCoBots { - return true - } - } - return false -} - func plural(n int) string { if n == 1 { return "" diff --git a/internal/state/dashboard_sync_test.go b/internal/state/dashboard_sync_test.go index d96f4fda..6bafee8a 100644 --- a/internal/state/dashboard_sync_test.go +++ b/internal/state/dashboard_sync_test.go @@ -98,7 +98,7 @@ func TestSyncDashboardWritesOnlyOnChange(t *testing.T) { st := syncTestState(t) for i := 0; i < 4; i++ { - if err := store.SyncDashboard(ctx, st, cfg); err != nil { + if err := store.SyncDashboard(ctx, st); err != nil { t.Fatalf("sync %d: %v", i, err) } } @@ -120,7 +120,7 @@ func TestSyncDashboardWritesOnlyOnChange(t *testing.T) { t.Fatal(err) } st.Normalize(t0) - if err := store.SyncDashboard(ctx, st, cfg); err != nil { + if err := store.SyncDashboard(ctx, st); err != nil { t.Fatal(err) } srv.mu.Lock() @@ -142,7 +142,7 @@ func TestSyncDashboardSkipsWriteWhenTheIssueAlreadyMatches(t *testing.T) { st := syncTestState(t) // One process (think: the daemon) publishes the dashboard. - if err := NewGitStateStore(cfg, client, nil).SyncDashboard(ctx, st, cfg); err != nil { + if err := NewGitStateStore(cfg, client, nil).SyncDashboard(ctx, st); err != nil { t.Fatal(err) } srv.mu.Lock() @@ -155,7 +155,7 @@ func TestSyncDashboardSkipsWriteWhenTheIssueAlreadyMatches(t *testing.T) { // A genuinely cold second process against the same issue: its own client, so // it shares no ETag cache with the first. cold := NewGitStateStore(cfg, gh.NewTestClient(httpSrv.URL, httpSrv.Client()), nil) - if err := cold.SyncDashboard(ctx, st, cfg); err != nil { + if err := cold.SyncDashboard(ctx, st); err != nil { t.Fatal(err) } srv.mu.Lock() @@ -168,81 +168,27 @@ func TestSyncDashboardSkipsWriteWhenTheIssueAlreadyMatches(t *testing.T) { } } -func TestSyncDashboardUsesTheSuppliedEffectiveRenderingConfig(t *testing.T) { +func TestSyncDashboardUsesStateBackedRenderConfig(t *testing.T) { srv := &issueServer{} _, client := srv.start(t) startup := StoreConfig{ GateRepo: "owner/state", StateRef: "crq-state-v3", DashboardIssue: 1, Scope: []string{"owner"}, CoReviewers: "codex (selfheal)", } - effective := startup - effective.CoReviewers = "codex (required, always)" - st := syncTestState(t) - - if err := NewGitStateStore(startup, client, nil).SyncDashboard(context.Background(), st, effective); err != nil { - t.Fatal(err) - } - srv.mu.Lock() - defer srv.mu.Unlock() - if !strings.Contains(srv.body, effective.CoReviewers) || strings.Contains(srv.body, startup.CoReviewers) { - t.Fatalf("dashboard body did not use the effective reviewer summary:\n%s", srv.body) - } -} - -// dashboard.md is rewritten on EVERY state commit, so it has to be rendered -// with the same effective policy the gate issue is. Rendering it from the -// writing host's startup configuration would put that machine's co-reviewer set -// in the shared file — disagreeing with the issue, and flapping between hosts -// that are each configured differently. -func TestStateCommitsRenderTheDashboardFileWithTheEffectiveConfig(t *testing.T) { - var blobs []string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - enc := json.NewEncoder(w) - switch { - case strings.Contains(r.URL.Path, "/git/ref/"): - http.NotFound(w, r) // no state ref yet: the first write creates it - case strings.HasSuffix(r.URL.Path, "/git/blobs"): - var payload struct { - Content string `json:"content"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - blobs = append(blobs, payload.Content) - enc.Encode(map[string]string{"sha": "blobsha"}) - case strings.HasSuffix(r.URL.Path, "/git/trees"): - enc.Encode(map[string]string{"sha": "treesha"}) - case strings.HasSuffix(r.URL.Path, "/git/commits"): - enc.Encode(map[string]string{"sha": "commitsha"}) - case strings.HasSuffix(r.URL.Path, "/git/refs"): - enc.Encode(map[string]any{"object": map[string]string{"sha": "commitsha"}}) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(srv.Close) - - startup := StoreConfig{ - GateRepo: "owner/state", StateRef: "crq-state-v3", DashboardIssue: 1, - Scope: []string{"owner"}, CoReviewers: "codex (selfheal)", - } - store := NewGitStateStore(startup, gh.NewTestClient(srv.URL, srv.Client()), nil) + store := NewGitStateStore(startup, client, nil) store.SetRenderConfig(func(State) StoreConfig { effective := startup effective.CoReviewers = "codex (required, always)" return effective }) - if _, err := store.Update(context.Background(), func(st *State) error { - _, err := st.NewRound("owner/repo", 7, "abcdef123", t0) - return err - }); err != nil { + + if err := store.SyncDashboard(context.Background(), syncTestState(t)); err != nil { t.Fatal(err) } - - written := strings.Join(blobs, "\n") - if !strings.Contains(written, "codex (required, always)") || strings.Contains(written, "codex (selfheal)") { - t.Fatalf("the committed dashboard did not use the effective reviewer summary:\n%s", written) + srv.mu.Lock() + defer srv.mu.Unlock() + if !strings.Contains(srv.body, "codex (required, always)") || + strings.Contains(srv.body, "codex (selfheal)") { + t.Fatalf("dashboard used startup reviewers instead of state-backed reviewers:\n%s", srv.body) } } diff --git a/internal/state/dashboard_test.go b/internal/state/dashboard_test.go index 17e50815..19b1c816 100644 --- a/internal/state/dashboard_test.go +++ b/internal/state/dashboard_test.go @@ -98,6 +98,33 @@ func TestRenderDashboardCoolingDownOnly(t *testing.T) { } } +// The pacing floor is a fleet setting, and this issue is written by whichever +// host happens to save state. Rendering it from that process's own startup +// configuration advertised a "next at" — and a queue order — that Pump, which +// resolves the setting from the record, was never going to follow. +func TestRenderDashboardPacesFromTheRecordedInterval(t *testing.T) { + now := time.Now().UTC() + st := stateWith(queuedRound("owner/repo", 7, 1, now)) + fired := now.Add(-time.Minute) + st.LastFired = &fired + st.Fleet.MinInterval = "2h" + + out := RenderDashboard(st, StoreConfig{MinInterval: time.Second}) + ready := fmtStamp(ptime(fired.Add(2*time.Hour)), time.UTC) + if !strings.Contains(out, "next at "+ready) { + t.Errorf("header does not pace from the recorded interval (want %s):\n%s", ready, out) + } + // And the generic layer answers for it when nothing typed does, exactly as + // the configuration resolves it. + st.Fleet.MinInterval = "" + st.Fleet.Env = map[string]string{"CRQ_MIN_INTERVAL": "30m"} + out = RenderDashboard(st, StoreConfig{MinInterval: time.Second}) + ready = fmtStamp(ptime(fired.Add(30*time.Minute)), time.UTC) + if !strings.Contains(out, "next at "+ready) { + t.Errorf("header ignores the fleet's env layer (want %s):\n%s", ready, out) + } +} + // Guard against over-correcting: a genuinely empty state keeps its empty-state // text and its idle title. func TestRenderDashboardEmpty(t *testing.T) { @@ -933,19 +960,6 @@ func TestStatusLine(t *testing.T) { if got := StatusLine(ready, StoreConfig{}); !strings.Contains(got, "next #7") { t.Errorf("a ready queue should name what is next, got %q", got) } - fleetPaced := stateWith(queuedRound("kristofferr/a", 7, 1, now)) - fleetPaced.LastFired = &now - fleetPaced.SetFleetValue("min-interval", "1h") - if got := StatusLine(fleetPaced, StoreConfig{MinInterval: 0}); strings.Contains(got, "next #7") { - t.Errorf("fleet pacing must keep status from claiming the round is ready: %q", got) - } - // The recorded value is whatever an operator typed, and the parser that - // accepted it at `crq config set` trims. One that queue decisions honour must - // not leave this renderer quietly falling back to the host's interval. - fleetPaced.SetFleetValue("min-interval", " 1h ") - if got := StatusLine(fleetPaced, StoreConfig{MinInterval: 0}); strings.Contains(got, "next #7") { - t.Errorf("a padded fleet interval must pace status like an unpadded one: %q", got) - } // Blocked: the countdown is the useful part. blockedState := stateWith(queuedRound("kristofferr/a", 7, 1, now)) @@ -1021,3 +1035,52 @@ func TestStatusLine(t *testing.T) { } } } + +// The issue is ONE artifact the whole fleet reads, so how its times are rendered +// is a fleet answer. Taken from the rendering host's startup environment alone, +// a timezone saved from the settings page was reported as in force while every +// sync went on writing the zone of whichever machine happened to run it. +func TestRenderDashboardPrefersTheFleetTimezone(t *testing.T) { + now := time.Now().UTC() + r := coolingRound("kristofferr/ha-adjustable-bed", 480, 1, now, 11*time.Minute) + st := stateWith(r) + st.Fleet.Env = map[string]string{"CRQ_TZ": "Asia/Tokyo"} + + tokyo, err := time.LoadLocation("Asia/Tokyo") + if err != nil { + t.Skipf("tzdata unavailable: %v", err) + } + out := RenderDashboard(st, StoreConfig{Timezone: "Europe/Oslo"}) + if !strings.Contains(out, fmtStamp(r.RetryAt, tokyo)) { + t.Errorf("ready time not rendered in the fleet's zone:\n%s", out) + } + + // A zone the fleet records but this binary cannot load falls through to the + // host's own rather than silently rendering everything in UTC. + oslo, err := time.LoadLocation("Europe/Oslo") + if err != nil { + t.Skipf("tzdata unavailable: %v", err) + } + st.Fleet.Env["CRQ_TZ"] = "Middle/Earth" + out = RenderDashboard(st, StoreConfig{Timezone: "Europe/Oslo"}) + if !strings.Contains(out, fmtStamp(r.RetryAt, oslo)) { + t.Errorf("an unloadable fleet zone must fall back to the host's:\n%s", out) + } +} + +func TestCoReviewerCellListsOnlyCoBotOverrides(t *testing.T) { + st := New() + st.SetRepoOverride("owner/primary-only", RepoReviewers{PrimaryOff: true}) + st.SetRepoOverride("owner/required-only", RepoReviewers{SetRequired: true}) + st.SetRepoOverride("owner/cobots", RepoReviewers{SetCoBots: true}) + + got := coReviewerCell(st, "codex") + if !strings.Contains(got, "owner/cobots") { + t.Fatalf("co-reviewer cell omitted the co-bot override: %q", got) + } + for _, unrelated := range []string{"owner/primary-only", "owner/required-only"} { + if strings.Contains(got, unrelated) { + t.Fatalf("co-reviewer cell lists unrelated override %q: %q", unrelated, got) + } + } +} diff --git a/internal/state/dispatch_test.go b/internal/state/dispatch_test.go index 70f8111a..2f19911d 100644 --- a/internal/state/dispatch_test.go +++ b/internal/state/dispatch_test.go @@ -1,6 +1,7 @@ package state import ( + "strings" "testing" "time" ) @@ -64,6 +65,12 @@ func TestDispatchClaim(t *testing.T) { } else if why == "" { t.Error("the bound's refusal must say why") } + if ok, why := round.ClaimDispatch("host-c", "tok-recovered", stale.Add(time.Hour+time.Second), 3); !ok { + t.Fatalf("the attempt bound permanently dead-lettered the head: %s", why) + } + if round.Dispatch.Attempts != 1 || round.Dispatch.Exhaustions != 1 { + t.Fatalf("recovered cycle = %#v, want attempt 1 after one cooldown", round.Dispatch) + } // A new head is a fresh start: the previous attempt achieved something. fresh := &Round{Repo: "o/r", PR: 1, Head: "bbbbbbbb2"} @@ -72,6 +79,70 @@ func TestDispatchClaim(t *testing.T) { } } +func TestNormalizePrunesExpiredDispatchIndex(t *testing.T) { + now := time.Now().UTC() + st := New() + st.RememberDispatch("o/r", 1, DispatchClaim{ + Host: "dead", Token: "old", At: now.Add(-time.Hour), Heartbeat: now.Add(-time.Hour), Attempts: 3, + }) + st.RememberDispatch("o/r", 2, DispatchClaim{ + Host: "live", Token: "new", At: now.Add(-time.Minute), Heartbeat: now.Add(-time.Minute), Attempts: 1, + }) + + st.Normalize(now) + + if _, ok := st.Dispatches[Key("o/r", 1)]; ok { + t.Fatal("expired cross-archive dispatch index survived normalization") + } + if _, ok := st.Dispatches[Key("o/r", 2)]; !ok { + t.Fatal("live dispatch index was pruned") + } +} + +func TestDispatchModelFallbackRefundsProviderOutages(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + reset := now.Add(time.Hour) + round := &Round{Repo: "o/r", PR: 1, Head: "aaaaaaaa1"} + models := []string{"opus", "sonnet"} + + if ok, why := round.ClaimDispatchModels("host", "one", now, 5, models); !ok { + t.Fatal(why) + } + if round.Dispatch.Model != "opus" || round.Dispatch.Attempts != 1 { + t.Fatalf("first claim = %#v, want opus attempt 1", round.Dispatch) + } + if !round.MarkDispatchUnavailable("one", reset, "provider unavailable") { + t.Fatal("owner could not mark its selected model unavailable") + } + round.ReleaseDispatch("one") + if round.Dispatch.Attempts != 0 { + t.Fatalf("provider outage spent an attempt: %#v", round.Dispatch) + } + + if ok, why := round.ClaimDispatchModels("host", "two", now.Add(time.Minute), 5, models); !ok { + t.Fatal(why) + } + if round.Dispatch.Model != "sonnet" || round.Dispatch.Attempts != 1 { + t.Fatalf("fallback claim = %#v, want sonnet attempt 1", round.Dispatch) + } + if !round.MarkDispatchUnavailable("two", reset.Add(time.Hour), "provider unavailable") { + t.Fatal("fallback could not be marked unavailable") + } + round.ReleaseDispatch("two") + + if ok, why := round.ClaimDispatchModels("host", "three", now.Add(2*time.Minute), 5, models); ok { + t.Fatal("all parked models should wait") + } else if why == "" { + t.Fatal("the wait must explain itself") + } + if ok, why := round.ClaimDispatchModels("host", "four", reset.Add(time.Second), 5, models); !ok { + t.Fatalf("expired model outage permanently blocked the head: %s", why) + } + if round.Dispatch.Model != "opus" { + t.Fatalf("model after reset = %q, want opus", round.Dispatch.Model) + } +} + // A round being fixed right now must not also be fired: the session is replacing // the code the review would be about, and its push moves the head minutes later. // The rest of the queue keeps moving, because the round leaves the queue instead @@ -280,3 +351,26 @@ func TestHeartbeatSeparatesSupersededFromStolen(t *testing.T) { t.Errorf("expired claim gave ok=%v taken=%v, want a benign miss", ok, taken) } } + +func TestDispatchClarificationStopsOnlyTheCurrentHead(t *testing.T) { + now := time.Date(2026, 7, 29, 8, 0, 0, 0, time.UTC) + round := Round{Repo: "o/r", PR: 1, Head: "aaaaaaaa1", Phase: PhaseQueued} + if ok, why := round.ClaimDispatch("host", "tok", now, 3); !ok { + t.Fatalf("claim: %s", why) + } + if !round.MarkDispatchClarification("tok", "Which behavior should remain?") { + t.Fatal("clarification did not release its claim") + } + if round.DispatchHeld(now) { + t.Fatal("clarification left a live dispatch claim") + } + if ok, why := round.ClaimDispatch("host", "next", now.Add(time.Minute), 3); ok || + !strings.Contains(why, "Which behavior should remain?") { + t.Fatalf("repeat claim = ok %v reason %q, want the clarification to stay terminal", ok, why) + } + + fresh := Round{Repo: "o/r", PR: 1, Head: "bbbbbbbb2", Phase: PhaseQueued} + if ok, why := fresh.ClaimDispatch("host", "fresh", now.Add(time.Minute), 3); !ok { + t.Fatalf("new head was stopped by the old clarification: %s", why) + } +} diff --git a/internal/state/enrollment.go b/internal/state/enrollment.go new file mode 100644 index 00000000..eb5def10 --- /dev/null +++ b/internal/state/enrollment.go @@ -0,0 +1,65 @@ +package state + +import ( + "sort" + "time" +) + +// RepoEnrollment is one repository's answer to "does crq review this project at +// all?", recorded in shared state so every host agrees — and so the decision can +// be made from a dashboard instead of by editing an env file on whichever +// machine happens to run the daemon. +// +// Absent means "no decision here": the host's own CRQ_REPOS/CRQ_EXCLUDE decide, +// exactly as they did before this record existed. +type RepoEnrollment struct { + Enabled bool `json:"enabled"` + // Reason is required to turn a repository OFF, for the same reason a hold + // needs one: an unexplained absence is the one nobody dares reverse. + Reason string `json:"reason,omitempty"` + By string `json:"by,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + + unknown unknownFields +} + +// Enrollment returns repo's enrollment record, and whether one exists. +func (s *State) Enrollment(repo string) (RepoEnrollment, bool) { + e, ok := s.Enrolled[normalizeRepoKey(repo)] + return e, ok +} + +// SetEnrollment records the decision, replacing any earlier one. +func (s *State) SetEnrollment(repo string, e RepoEnrollment) { + if s.Enrolled == nil { + s.Enrolled = map[string]RepoEnrollment{} + } + key := normalizeRepoKey(repo) + if prev, ok := s.Enrolled[key]; ok { + e.unknown = carryUnknown(e.unknown, prev.unknown) + } + s.Enrolled[key] = e +} + +// ClearEnrollment drops the record, handing the repository back to the hosts' +// env files. It returns whether there was one. +func (s *State) ClearEnrollment(repo string) bool { + key := normalizeRepoKey(repo) + if _, ok := s.Enrolled[key]; !ok { + return false + } + delete(s.Enrolled, key) + return true +} + +// EnrolledRepos is every repository with a record, sorted, whichever way it was +// decided — a repository turned off is as much a decision as one turned on and +// must stay visible. +func (s *State) EnrolledRepos() []string { + repos := make([]string, 0, len(s.Enrolled)) + for repo := range s.Enrolled { + repos = append(repos, repo) + } + sort.Strings(repos) + return repos +} diff --git a/internal/state/firelog.go b/internal/state/firelog.go new file mode 100644 index 00000000..89765d16 --- /dev/null +++ b/internal/state/firelog.go @@ -0,0 +1,181 @@ +package state + +import ( + "sort" + "time" +) + +// FireLogWindow is how far back the fire log keeps timestamps. Two weeks, so a +// seven-day rolling count is always answered from data that is fully inside the +// log rather than from its truncated edge. +const FireLogWindow = 14 * 24 * time.Hour + +// FireLogMax bounds the log whatever the window says. A fleet that somehow +// fires far more than any plan allows must not grow the state blob without +// limit; the newest entries are the ones a rolling count needs. +const FireLogMax = 1000 + +// NoteFire records that a metered review was requested at t. +// +// This is the only time series crq keeps, and it exists for one reason: the +// vendor's weekly fair-use throttle. crq already RECOGNISES that throttle when +// CodeRabbit announces it — the classifier and its corpus file have been there +// all along — but recognising it is recognising an ~80% throughput collapse +// after it has happened. Counting fires is what lets crq see the cliff coming, +// and a fleet orchestrator is exactly the thing that walks off it. +// +// Timestamps only. What was reviewed is already in the rounds; how many were +// reviewed is the question no other record can answer, because rounds are +// superseded and the archive is a 50-entry ring that a busy day evicts in hours. +func (s *State) NoteFire(t time.Time) { + s.noteFire(t, t, true) +} + +// NoteObservedFire records a fire whose historical timestamp predates crq's +// observation of it. The event still belongs in the count at firedAt, but log +// coverage cannot honestly start before observedAt. +func (s *State) NoteObservedFire(firedAt, observedAt time.Time) { + s.noteFire(firedAt, observedAt, false) +} + +func (s *State) noteFire(t, coverageStart time.Time, extendCoverageBack bool) { + at := t.UTC() + if s.Account.FiresFrom == nil { + // The first fire this log ever saw starts its coverage. A log written by + // a binary that predates this field starts at its oldest entry, which is + // the most it can honestly claim. + from := coverageStart.UTC() + if len(s.Account.Fires) > 0 && s.Account.Fires[0].Before(from) { + from = s.Account.Fires[0] + } + s.Account.FiresFrom = &from + } else if extendCoverageBack && at.Before(*s.Account.FiresFrom) { + from := at + s.Account.FiresFrom = &from + } + s.Account.Fires = append(s.Account.Fires, at) + s.trimFireLog() +} + +// trimFireLog drops what the window and the cap put out of scope. Sorting first +// makes it correct when two hosts' clocks disagree: entries arrive from whoever +// won the CAS, not in order. +// +// The window is measured from the NEWEST entry rather than from the one just +// appended: recording a fire that is itself old must not widen the window and +// resurrect everything before it. +func (s *State) trimFireLog() { + if len(s.Account.Fires) == 0 { + return + } + sort.Slice(s.Account.Fires, func(i, j int) bool { + return s.Account.Fires[i].Before(s.Account.Fires[j]) + }) + newest := s.Account.Fires[len(s.Account.Fires)-1] + cutoff := newest.Add(-FireLogWindow) + keep := s.Account.Fires[:0] + for _, at := range s.Account.Fires { + if at.After(cutoff) { + keep = append(keep, at) + } + } + if len(keep) > FireLogMax { + keep = keep[len(keep)-FireLogMax:] + // The window drops only entries older than any rolling week asks about, + // so it costs no coverage. The CAP does: it discards by count, and the + // entries it throws away may still be inside the week. Coverage really + // does restart at the oldest one left. + if from := keep[0]; s.Account.FiresFrom == nil || s.Account.FiresFrom.Before(from) { + s.Account.FiresFrom = &from + } + } + s.Account.Fires = keep +} + +// FiresSince counts recorded fires at or after t. +func (s *State) FiresSince(t time.Time) int { + n := 0 + for _, at := range s.Account.Fires { + if !at.Before(t.UTC()) { + n++ + } + } + return n +} + +// WeeklyFires is the rolling seven-day count — the number the fair-use +// threshold is expressed in. +func (s *State) WeeklyFires(now time.Time) int { + return s.FiresSince(now.Add(-7 * 24 * time.Hour)) +} + +// FireLogSince is when the log's coverage starts, which is what makes its count +// honest: a count over three days of history is not a weekly count, and a +// caller that cannot tell the difference will read a fresh log as a quiet week. +// +// The oldest RETAINED fire cannot answer this. On a quiet fleet — one fire on +// day 0, the next on day 20 — recording the second trims the first, and reading +// the start off the survivors turned a period the log had watched all along +// back into a floor for another week. +func (s *State) FireLogSince() *time.Time { + if s.Account.FiresFrom != nil { + from := *s.Account.FiresFrom + return &from + } + if len(s.Account.Fires) == 0 { + return nil + } + first := s.Account.Fires[0] + return &first +} + +// WeeklyUsage is the fair-use picture: how many metered reviews this account +// has spent in the rolling week, against the threshold beyond which the vendor +// throttles it to one review an hour. +type WeeklyUsage struct { + Fires int `json:"fires"` + // Limit is the configured weekly threshold; 0 means none is configured and + // the rest of this is a count without a verdict. + Limit int `json:"limit,omitempty"` + // Complete says the log covers a full seven days. Until it does, Fires is a + // floor — crq only knows about fires since it started keeping the log. + Complete bool `json:"complete"` + // Since is when the log starts, so a partial count can say how partial. + Since *time.Time `json:"since,omitempty"` + // Level is ok | warn | over. warn begins at 80% of the limit, which is + // roughly a day's headroom on a busy fleet — enough to act on. + Level string `json:"level"` + Note string `json:"note"` +} + +// FairUse reports the rolling-week usage against limit. A limit of 0 (not +// configured) still counts, because the count alone is worth seeing; it just +// never claims the account is close to anything. +func (s *State) FairUse(now time.Time, limit int) WeeklyUsage { + out := WeeklyUsage{Fires: s.WeeklyFires(now), Limit: limit, Level: "ok"} + out.Since = s.FireLogSince() + weekAgo := now.UTC().Add(-7 * 24 * time.Hour) + out.Complete = out.Since != nil && !out.Since.After(weekAgo) + switch { + case limit <= 0: + out.Note = "no weekly threshold is configured, so this is a count without a verdict" + case out.Fires >= limit: + out.Level = "over" + out.Note = "past the weekly fair-use threshold — the vendor throttles to about one review an hour from here" + case out.Fires*5 >= limit*4: + out.Level = "warn" + out.Note = "close to the weekly fair-use threshold, beyond which reviews are throttled to about one an hour" + default: + out.Note = "inside the weekly fair-use allowance" + } + switch { + case out.Since == nil: + // An empty log is not a quiet week. Saying so matters most right after + // an upgrade, when every fleet's log is empty and the count reads as an + // account that has done nothing. + out.Note = "no metered review has been recorded yet — this log starts with the first one" + case !out.Complete: + out.Note += "; the log starts " + out.Since.Format("2006-01-02") + ", so this is a floor" + } + return out +} diff --git a/internal/state/firelog_test.go b/internal/state/firelog_test.go new file mode 100644 index 00000000..d1fb8a15 --- /dev/null +++ b/internal/state/firelog_test.go @@ -0,0 +1,125 @@ +package state + +import ( + "testing" + "time" +) + +// The fire log's only job is forecasting the weekly fair-use cliff, so what it +// must never do is state a confident weekly number it does not have the history +// for. These rows pin that, and the trimming that keeps the log bounded. +func TestFireLogAndFairUse(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + s := New() + + // A log younger than a week reports a FLOOR, not a weekly count. + for i := 0; i < 10; i++ { + s.NoteFire(now.Add(-time.Duration(i) * time.Hour)) + } + u := s.FairUse(now, 60) + if u.Fires != 10 || u.Complete { + t.Fatalf("usage = %+v, want 10 fires and an incomplete week", u) + } + if u.Level != "ok" { + t.Errorf("level = %q, want ok at 10 of 60", u.Level) + } + + // Old entries fall out of the window; the count follows. + s.NoteFire(now.Add(-20 * 24 * time.Hour)) + if got := len(s.Account.Fires); got != 10 { + t.Errorf("log = %d entries, want the out-of-window fire dropped", got) + } + + // Once the log reaches back a full week the count is complete. + s.NoteFire(now.Add(-8 * 24 * time.Hour)) + if u = s.FairUse(now, 60); !u.Complete { + t.Error("a log starting more than a week ago covers the rolling week") + } + if u.Fires != 10 { + t.Errorf("fires = %d, want the 8-day-old entry outside the 7-day count", u.Fires) + } + + // The warning band opens at 80% — early enough to act on, not so early it + // is background noise. + busy := New() + for i := 0; i < 48; i++ { + busy.NoteFire(now.Add(-time.Duration(i) * time.Hour)) + } + if u = busy.FairUse(now, 60); u.Level != "warn" { + t.Errorf("level = %q at 48 of 60, want warn", u.Level) + } + for i := 48; i < 60; i++ { + busy.NoteFire(now.Add(-time.Duration(i) * time.Hour)) + } + if u = busy.FairUse(now, 60); u.Level != "over" { + t.Errorf("level = %q at 60 of 60, want over", u.Level) + } + + // With no threshold configured it still counts, but claims nothing. + if u = busy.FairUse(now, 0); u.Level != "ok" || u.Fires != 60 { + t.Errorf("usage = %+v, want a count with no verdict", u) + } + + // The cap holds whatever the window allows. + huge := New() + for i := 0; i < FireLogMax+50; i++ { + huge.NoteFire(now.Add(-time.Duration(i) * time.Minute)) + } + if got := len(huge.Account.Fires); got != FireLogMax { + t.Errorf("log = %d entries, want it capped at %d", got, FireLogMax) + } +} + +// Coverage is when the log STARTED, not when its oldest survivor was recorded. +// A quiet fleet's first fire ages out of the retention window as soon as a +// later one trims it, and reading the start off the survivors turned a period +// the log had watched all along back into a floor for another week. +func TestCoverageOutlivesTheOldestRetainedFire(t *testing.T) { + start := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + s := New() + + s.NoteFire(start) + s.NoteFire(start.Add(20 * 24 * time.Hour)) + if got := len(s.Account.Fires); got != 1 { + t.Fatalf("log = %d entries, want the day-0 fire trimmed by the window", got) + } + + now := start.Add(21 * 24 * time.Hour) + u := s.FairUse(now, 60) + if !u.Complete { + t.Errorf("usage = %+v, want a complete week: the log has been running for three", u) + } + if u.Since == nil || !u.Since.Equal(start) { + t.Errorf("since = %v, want the first fire the log ever saw (%v)", u.Since, start) + } + + // The entry CAP is different: it discards by count, so the history it drops + // may still be inside the rolling week. Coverage really does restart there. + capped := New() + for i := 0; i <= FireLogMax; i++ { + capped.NoteFire(start.Add(time.Duration(i) * time.Minute)) + } + if got := len(capped.Account.Fires); got != FireLogMax { + t.Fatalf("log = %d entries, want it capped at %d", got, FireLogMax) + } + if capped.Account.FiresFrom == nil || !capped.Account.FiresFrom.Equal(capped.Account.Fires[0]) { + t.Errorf("coverage = %v, want it to restart at the oldest entry the cap left", capped.Account.FiresFrom) + } +} + +func TestObservedHistoricalFireDoesNotBackdateCoverage(t *testing.T) { + observedAt := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + firedAt := observedAt.Add(-8 * 24 * time.Hour) + s := New() + + s.NoteObservedFire(firedAt, observedAt) + if len(s.Account.Fires) != 1 || !s.Account.Fires[0].Equal(firedAt) { + t.Fatalf("fires = %v, want the historical event counted at %s", s.Account.Fires, firedAt) + } + if s.Account.FiresFrom == nil || !s.Account.FiresFrom.Equal(observedAt) { + t.Fatalf("coverage = %v, want observation time %s", s.Account.FiresFrom, observedAt) + } + if usage := s.FairUse(observedAt, 60); usage.Complete { + t.Fatalf("usage = %+v, an adopted old command cannot supply a week of unseen coverage", usage) + } +} diff --git a/internal/state/fleet.go b/internal/state/fleet.go index 1e15789f..e3d7431c 100644 --- a/internal/state/fleet.go +++ b/internal/state/fleet.go @@ -1,76 +1,96 @@ package state -import ( - "sort" - "strings" - "time" -) +import "time" -// Fleet is the policy every host in the fleet shares, keyed by setting name. +// FleetDefaults is what every repository inherits, recorded once for the whole +// fleet instead of in each host's env file. // -// It lives HERE for the reason the per-repository overrides do, one level up: -// a daemon on one machine and an agent on another reading different -// configurations while writing one shared state ref is a class of divergence -// worth not having. Per-host environment files diverge the moment somebody -// edits one — a repository excluded on the laptop and reviewed by the server, -// a rate-limit window one host respects and another does not — and nothing -// says so, because each host is behaving correctly according to what it can -// see. +// It exists for the same reason the enrollment record does: the settings that +// govern a fleet lived in ~/.config/crq/env on whichever machine happened to +// run the daemon, so changing one meant editing a file per host and hoping they +// agreed. A record here is one answer every host reads. // -// A flat map rather than a struct, on purpose. One JSON member means an older -// binary round-trips the whole thing rather than dropping settings it does not -// know. Newly interpreted decision-changing keys still require a writer -// capability bump so an active older driver cannot ignore them. -type Fleet map[string]string +// Every field is optional and absent means "keep using this host's env value", +// so a fleet that never writes one behaves exactly as before. That is also what +// makes the record safe to add to: a field a newer binary sets is preserved by +// an older one through the tolerant round-trip, and simply not acted on. +type FleetDefaults struct { + // CoBots/Required are the fleet's reviewer defaults, the base a per-repo + // override starts from. Set* distinguishes "not chosen" from "chosen to be + // none", which a JSON round trip would otherwise flatten. + CoBots []string `json:"cobots,omitempty"` + Required []string `json:"required,omitempty"` + SetCoBots bool `json:"set_cobots,omitempty"` + SetRequired bool `json:"set_required,omitempty"` -// FleetValue returns a fleet setting and whether it is recorded. Absent means -// "the fleet has no opinion", which is what lets a host fall back to its own -// environment and to crq's defaults. -func (s *State) FleetValue(key string) (string, bool) { - value, ok := s.FleetConfig[key] - return value, ok -} + // MinInterval is the pacing floor between metered fires, as a duration + // string ("90s"). A string rather than a number because the unit is part of + // the value, and a bare integer in JSON invites reading it as the wrong one. + MinInterval string `json:"min_interval,omitempty"` -// SetFleetValue records a fleet setting, replacing any earlier value. -func (s *State) SetFleetValue(key, value string) { - if s.FleetConfig == nil { - s.FleetConfig = Fleet{} - } - s.FleetConfig[key] = value + // WeeklyLimit is the vendor's weekly fair-use threshold. A pointer so 0 can + // mean "count, but forecast nothing" rather than "unset". + WeeklyLimit *int `json:"weekly_limit,omitempty"` + + // AutofixDefault is whether a repository with no explicit switch may be + // fixed. Absent means yes, which is what crq has always done. + AutofixDefault *bool `json:"autofix_default,omitempty"` + + // Solver is the fleet's fix-session default, which a repository's own record + // is layered over. + Solver SolverSettings `json:"solver,omitempty"` + + // Env is the fleet's answer for any other setting, keyed exactly as the + // environment variable is. Kept as raw strings and re-parsed rather than + // decoded into fields, so a setting becomes fleet-settable without needing + // plumbing of its own — and so the fleet path and the env path cannot end + // up disagreeing about what a value means. + // + // Only settings crq lists as fleet-settable are honoured. A key an older + // binary does not recognise round-trips untouched. + Env map[string]string `json:"env,omitempty"` + + By string `json:"by,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + + // envUnknown carries members a newer binary wrote inside Env with a value + // shape this binary cannot parse yet. + envUnknown unknownFields + // unknown carries members a newer binary wrote inside this record. State's + // own carrier cannot: it sees "fleet" as a member it knows and hands the + // whole object to the ordinary decoder. See tolerant.go. + unknown unknownFields } -// UnsetFleetValue drops a setting, returning the fleet to per-host defaults for -// it, and reports whether one was there. -func (s *State) UnsetFleetValue(key string) bool { - if _, ok := s.FleetConfig[key]; !ok { - return false - } - delete(s.FleetConfig, key) - return true +// Empty reports whether the record says nothing at all. A record that has been +// emptied field by field must not keep reading as "recorded": every setting +// would show its env value while the fleet claimed to have an answer. +// +// A carried member counts as something said. A newer binary's fleet setting is +// a setting this one cannot name, and answering "empty" for it would have +// SetFleetDefaults replace the whole record with a zero value — erasing it on +// the one path the round trip exists to survive. +func (f FleetDefaults) Empty() bool { + return !f.SetCoBots && !f.SetRequired && f.MinInterval == "" && + f.WeeklyLimit == nil && f.AutofixDefault == nil && f.Solver.Empty() && + len(f.Env) == 0 && len(f.envUnknown) == 0 && len(f.unknown) == 0 } -// FleetKeys lists the recorded settings in a stable order. -func (s *State) FleetKeys() []string { - keys := make([]string, 0, len(s.FleetConfig)) - for key := range s.FleetConfig { - keys = append(keys, key) +// AutofixDefaultOn is the answer for a repository with no explicit switch. +func (s *State) AutofixDefaultOn() bool { + if s.Fleet.AutofixDefault == nil { + return true } - sort.Strings(keys) - return keys + return *s.Fleet.AutofixDefault } -// withFleet applies the state-backed settings the state package itself renders. -// The full policy is interpreted by crq; MinInterval also shapes queue -// readiness in dashboards/status, including GitStateStore's background sync. -// -// Trimmed the way crq's own fleet parser trims, because the recorded value is -// whatever an operator typed: a stored " 5m " that queue decisions honour must -// not leave the dashboard and status line advertising this host's interval. -func (c StoreConfig) withFleet(s State) StoreConfig { - if value, ok := s.FleetValue("min-interval"); ok { - if interval, err := time.ParseDuration(strings.TrimSpace(value)); err == nil && interval >= 0 { - c.MinInterval = interval - } +// SetFleetDefaults records the fleet defaults, stamping who and when. +func (s *State) SetFleetDefaults(fd FleetDefaults, by string, now time.Time) { + if fd.Empty() { + s.Fleet = FleetDefaults{} + return } - return c + at := now.UTC() + fd.By, fd.UpdatedAt = by, &at + s.Fleet = fd } diff --git a/internal/state/holdview_test.go b/internal/state/holdview_test.go index 2aa88ba6..cbc8752e 100644 --- a/internal/state/holdview_test.go +++ b/internal/state/holdview_test.go @@ -101,28 +101,27 @@ func TestCoReviewerRowSaysItIsTheDefaultAndNamesExceptions(t *testing.T) { t.Error("the row does not say the list is a default") } - // A required-reviewer override does not change the co-reviewer list. - st.SetRepoOverride("owner/required-only", RepoReviewers{SetRequired: true, UpdatedAt: &now}) - body = RenderDashboard(st, cfg) - if strings.Contains(body, "owner/required-only") { - t.Errorf("the co-reviewer row names an unrelated override:\n%s", body) - } - // Once a repository overrides it, the row names which. st.SetRepoOverride("owner/special", RepoReviewers{SetCoBots: true, UpdatedAt: &now}) body = RenderDashboard(st, cfg) - if !strings.Contains(body, "owner/special") { - t.Errorf("the row does not name the repository that differs:\n%s", body) + row := dashboardRow(body, "Co-reviewers") + if !strings.Contains(row, "owner/special") { + t.Errorf("the row does not name the repository that differs:\n%s", row) } - if !strings.Contains(body, "override") { + if !strings.Contains(row, "override") { t.Error("the row does not say the named repository overrides the default") } - - // An explicit repository answer still needs a row when the fleet default is empty. - withoutDefault := RenderDashboard(st, StoreConfig{}) - if !strings.Contains(withoutDefault, "**Co-reviewers**") || - !strings.Contains(withoutDefault, "owner/special") { - t.Errorf("an override with an empty fleet default is hidden:\n%s", withoutDefault) + if !strings.Contains(row, "owner/special override)") { + t.Errorf("one override has the wrong label: %s", row) + } + st.SetRepoOverride("owner/primary-only", RepoReviewers{PrimaryOff: true, UpdatedAt: &now}) + st.SetRepoOverride("owner/required-only", RepoReviewers{SetRequired: true, UpdatedAt: &now}) + body = RenderDashboard(st, cfg) + row = dashboardRow(body, "Co-reviewers") + for _, repo := range []string{"owner/primary-only", "owner/required-only"} { + if strings.Contains(row, repo) { + t.Errorf("the co-reviewer row names unrelated override %q:\n%s", repo, row) + } } // Many overrides are truncated rather than allowed to wrap the table. @@ -130,10 +129,18 @@ func TestCoReviewerRowSaysItIsTheDefaultAndNamesExceptions(t *testing.T) { st.SetRepoOverride(repo, RepoReviewers{SetCoBots: true, UpdatedAt: &now}) } body = RenderDashboard(st, cfg) - if !strings.Contains(body, "+2 more") { - t.Errorf("five overrides were not summarised:\n%s", body) + row = dashboardRow(body, "Co-reviewers") + if !strings.Contains(row, "+2 more") || !strings.Contains(row, "overrides") { + t.Errorf("five overrides were not labelled and summarised:\n%s", row) } - if !strings.Contains(body, "overrides") { - t.Errorf("multiple repositories were labelled as one override:\n%s", body) +} + +func dashboardRow(body, label string) string { + prefix := "| **" + label + "** |" + for _, row := range strings.Split(body, "\n") { + if strings.HasPrefix(row, prefix) { + return row + } } + return "" } diff --git a/internal/state/hosts.go b/internal/state/hosts.go new file mode 100644 index 00000000..37b25080 --- /dev/null +++ b/internal/state/hosts.go @@ -0,0 +1,457 @@ +package state + +import ( + "sort" + "strconv" + "strings" + "time" +) + +// WriterHost reduces a writer key ("host=X pid=… run=…") to its machine name. +// Plain host names pass through unchanged for records written before keys +// carried process identity. +func WriterHost(writer string) string { + for _, field := range strings.Fields(writer) { + if host, ok := strings.CutPrefix(field, "host="); ok { + return host + } + } + return writer +} + +// HostReport is what one machine says about itself: which crq it runs, and +// which tools it can actually reach. +// +// It exists because every question about a fleet turns into a per-host +// question, and crq could only answer for whichever machine you happened to be +// asking from. "Is claude installed" has a different answer on each host, and +// the one that matters — is it on the PATH the SERVICE runs with — has a +// different answer again from the shell you are typing in. +// +// Reported rather than inferred. A host writes its own record; nothing else may +// write one for it, because a machine that has stopped reporting is exactly the +// machine whose last claim about itself should stop being trusted. +type HostReport struct { + Host string `json:"host"` + // Version is the crq that wrote this. Two hosts on different versions is + // the single most common cause of "that setting did nothing". + Version string `json:"version,omitempty"` + // Caps is what that binary understands, so a reader can see WHY a host + // ignores a setting rather than only that it does. + Caps int `json:"caps,omitempty"` + // Roles are the crq services running here ("autoreview", "autofix", + // "serve"), so a fleet can be read as who does what. + Roles []string `json:"roles,omitempty"` + // RoleSeen dates each role separately, because the record as a whole cannot. + // Every service on a host writes the SAME record and merges the roles it + // found there, so the one still running refreshed the stopped one's claim on + // every pass and the table showed a dead service running for ever. + RoleSeen map[string]time.Time `json:"role_seen,omitempty"` + // RoleCaps is what the binary behind each role understands, kept apart for + // the same reason RoleSeen is. During an ordinary rolling upgrade one + // machine runs one service on the new build and another on the old, and a + // single value is whichever of them wrote last: a fresh `serve` heartbeat + // advertised the newest capabilities while an old `autofix` watcher went on + // ignoring the very setting LaggingRoleWriters was then told it honoured. + RoleCaps map[string]int `json:"role_caps,omitempty"` + // RoleVersions is the crq version behind each role. A host can be halfway + // through an upgrade, so the record-wide Version alone cannot say whether + // this particular reporter has changed. + RoleVersions map[string]string `json:"role_versions,omitempty"` + // Agent is the fix agent this host is installed to run, by name ("claude", + // "codex"). Reported rather than inferred, and for the same reason the tool + // probes are: it is chosen per machine at install time and exported to the + // autofix unit alone, so no other process can read it. A dashboard that + // assumed one instead checked the wrong agent's availability on every host + // and reported a working codex fleet as missing its agent. + // + // Kept when a reporter names none, so the serve heartbeat on the same + // machine does not erase what its autofix service said. + Agent string `json:"agent,omitempty"` + // Tools is what this host can run, resolved across the roles reporting here + // — see RoleTools for why that is not simply the last report. + Tools []ToolReport `json:"tools,omitempty"` + // RoleTools is what each role probed on ITS own PATH, kept apart because the + // PATHs differ: the autofix unit adds the selected agent's directory and + // serve does not. Merged into one list, the two took turns overwriting each + // other and the dashboard alternated between "the fix agent is here" and + // "it is missing" while the service that runs sessions never changed. + RoleTools map[string][]ToolReport `json:"role_tools,omitempty"` + At time.Time `json:"at"` + + // unknown carries members a newer binary wrote inside this record. State's + // carrier cannot: it recognises "host_reports" and hands each report to an + // ordinary decoder. See tolerant.go. + unknown unknownFields +} + +// toolRoleRank orders roles by whose PATH the answer should come from. The +// autofix service is the one that actually starts fix sessions, so its view of +// "is the agent reachable" is the operative one; a dashboard that merely +// renders the answer is the least authoritative. +func toolRoleRank(role string) int { + switch role { + case "autofix": + return 0 + case "autoreview": + return 1 + case "serve": + return 3 + default: + return 2 + } +} + +// ToolReport is one executable on one host. +type ToolReport struct { + Name string `json:"name"` + // Path is empty when it was not found at all. + Path string `json:"path,omitempty"` + // Version is the tool's own, when it will say; some do not. + Version string `json:"version,omitempty"` + + // unknown carries members a newer binary wrote inside this record, for the + // same reason HostReport does — it is nested deeper still. See tolerant.go. + unknown unknownFields +} + +// Found reports whether the tool is reachable at all. +func (t ToolReport) Found() bool { return t.Path != "" } + +// HostReportTTL is how long a host's self-report is worth showing as current. +// Past it the record is kept and marked stale rather than dropped: "this host +// last said it had claude, two days ago" is more useful than silence. +const HostReportTTL = 30 * time.Minute + +// SetHostReport records what a host says about itself. +// +// Roles MERGE rather than replace, because a machine runs more than one crq +// service and each reports only its own: the autofix watcher and the review +// daemon on the same host would otherwise take turns overwriting each other, +// and the table would show whichever wrote last as the only thing running +// there. +// +// Each role therefore expires on its OWN last sighting, not on the record's. A +// shared At cannot answer this: the surviving service refreshes it every pass, +// so a carried-forward role from a service that stopped was renewed for ever +// and the dashboard reported it running indefinitely. +// +// Tool probes merge the same way and for the same reason: they describe the +// PATH of the service that took them, so they are kept per role and resolved +// into Tools rather than replaced wholesale by whoever wrote last. Capabilities +// join them, because a machine mid-upgrade runs its roles on two builds. +// +// The reporter's record is a fresh value every pass, so what a NEWER binary +// wrote is carried onto it — both the report's own unrecognised members and +// each probe's. Without that, an older service's heartbeat erased a newer one's +// additions every time it ran, which is the one thing tolerant.go exists to +// prevent. +func (s *State) SetHostReport(r HostReport, now time.Time) { + if s.HostReports == nil { + s.HostReports = map[string]HostReport{} + } + now = now.UTC() + seen := map[string]time.Time{} + tools := map[string][]ToolReport{} + caps := map[string]int{} + versions := map[string]string{} + reportedVersion := r.Version + if prev, ok := s.HostReports[r.Host]; ok { + for role, at := range prev.RoleSeen { + seen[role] = at + } + for role, probed := range prev.RoleTools { + tools[role] = probed + } + for role, c := range prev.RoleCaps { + caps[role] = c + } + for role, version := range prev.RoleVersions { + versions[role] = version + } + for _, role := range prev.Roles { + // A record written before roles were dated (an older binary, or one + // whose write dropped the member): its roles are exactly as old as it + // is, which is the honest date to expire them from. + if _, dated := seen[role]; !dated { + seen[role] = prev.At + } + // Likewise for a record written before probes were kept per role: + // its one list is the best any of its roles could say, so it stands + // in for all of them until each reports again on its own. + if _, probed := tools[role]; !probed && len(prev.Tools) > 0 { + tools[role] = prev.Tools + } + // And for one written before capabilities were: the record's own + // value is all it can say for the roles it carries forward. + if _, known := caps[role]; !known { + caps[role] = prev.Caps + } + if _, known := versions[role]; !known { + versions[role] = prev.Version + } + } + agentAuthoritative := false + for _, role := range r.Roles { + agentAuthoritative = agentAuthoritative || role == "autofix" + } + if r.Agent == "" && !agentAuthoritative { + // Only the autofix service knows this. Every other service on the + // machine reports nothing, and replacing the answer with silence + // would make the agent flicker away on the next heartbeat. + r.Agent = prev.Agent + } + r.unknown = carryUnknown(r.unknown, prev.unknown) + } + for _, role := range r.Roles { + seen[role] = now + caps[role] = r.Caps + versions[role] = reportedVersion + if len(r.Tools) > 0 { + tools[role] = mergeTools(tools[role], r.Tools) + } + } + roles := make([]string, 0, len(seen)) + for role, at := range seen { + if now.Sub(at) > HostReportTTL { + delete(seen, role) + delete(tools, role) + delete(caps, role) + delete(versions, role) + continue + } + roles = append(roles, role) + } + sort.Strings(roles) + r.Roles = roles + if len(seen) == 0 { + seen = nil + } + r.RoleSeen = seen + r.Tools = resolveTools(tools, roles, r.Tools) + if len(tools) == 0 { + tools = nil + } + r.RoleTools = tools + if len(caps) == 0 { + caps = nil + } + r.RoleCaps = caps + if len(versions) == 0 { + versions = nil + } + r.RoleVersions = versions + r.Version = newestHostVersion(versions, reportedVersion) + r.At = now + s.HostReports[r.Host] = r +} + +// VersionFor is the crq version behind role. Records written before versions +// were kept per role fall back to the record-wide answer. +func (r HostReport) VersionFor(role string) string { + if version, ok := r.RoleVersions[role]; ok { + return version + } + return r.Version +} + +func newestHostVersion(versions map[string]string, fallback string) string { + newest := fallback + for _, version := range versions { + if dottedVersionAfter(version, newest) { + newest = version + } + } + return newest +} + +func dottedVersionAfter(a, b string) bool { + ap, bp := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; i < len(ap) || i < len(bp); i++ { + x, y := "0", "0" + if i < len(ap) { + x = ap[i] + } + if i < len(bp) { + y = bp[i] + } + if x == y { + continue + } + xn, xerr := strconv.Atoi(x) + yn, yerr := strconv.Atoi(y) + if xerr == nil && yerr == nil { + return xn > yn + } + return x > y + } + return false +} + +// mergeTools carries what a newer binary recorded about a tool onto this +// reporter's fresh probe of it, matched by name. The probe itself is the +// reporter's to state — a path that has changed has changed — but the members +// it has never heard of are not, and rebuilding the slice from scratch dropped +// them on every pass. +func mergeTools(prev, next []ToolReport) []ToolReport { + if len(prev) == 0 { + return next + } + byName := make(map[string]ToolReport, len(prev)) + for _, t := range prev { + byName[t.Name] = t + } + out := make([]ToolReport, 0, len(next)) + for _, t := range next { + if old, ok := byName[t.Name]; ok { + t.unknown = carryUnknown(t.unknown, old.unknown) + } + out = append(out, t) + } + return out +} + +// CapsFor is what the binary running role understands. It falls back to the +// record's own value for a role that predates per-role capabilities, which is +// the same thing that value meant before they existed. +func (r HostReport) CapsFor(role string) int { + if c, ok := r.RoleCaps[role]; ok { + return c + } + return r.Caps +} + +// resolveTools answers "what can this host run" from the per-role probes, one +// tool at a time: the highest-ranked role that has an opinion about a tool owns +// the answer for it. fallback covers a host whose roles have all aged out — +// there is nobody left to speak for it, so the reporter's own list stands. +func resolveTools(byRole map[string][]ToolReport, roles []string, fallback []ToolReport) []ToolReport { + ranked := append([]string(nil), roles...) + sort.SliceStable(ranked, func(i, j int) bool { + return toolRoleRank(ranked[i]) < toolRoleRank(ranked[j]) + }) + var out []ToolReport + claimed := map[string]bool{} + for _, role := range ranked { + for _, t := range byRole[role] { + if claimed[t.Name] { + continue + } + claimed[t.Name] = true + out = append(out, t) + } + } + if len(out) == 0 { + return fallback + } + return out +} + +// ToolsReportedBy is what one role probed, or the record's resolved list when +// that role predates per-role probes. It is what a reporter must compare its +// own probe against: Tools is resolved across every service on the host, so a +// reporter whose answer another role outranks would read the difference as a +// change and rewrite state on every pass. +func (r HostReport) ToolsReportedBy(role string) []ToolReport { + if probed, ok := r.RoleTools[role]; ok { + return probed + } + return r.Tools +} + +// StaleRole reports whether this record still names a role whose own last +// sighting has aged out. The record itself may be fresh — a second service on +// the host keeps writing it — which is why a caller deciding "nothing changed, +// skip the write" has to ask: skipping is what would keep the dead role listed. +func (r HostReport) StaleRole(now time.Time) bool { + for _, role := range r.Roles { + at, ok := r.RoleSeen[role] + if !ok { + at = r.At + } + if now.Sub(at) > HostReportTTL { + return true + } + } + return false +} + +// RolesFresh reports whether every role in want was last seen within the given +// age. It is what a caller deciding "nothing changed, skip the write" must ask +// instead of looking at the record's own At: another service on the same host +// keeps that fresh, so a reporter that trusted it never refreshed its OWN role +// and let it age out from under itself — at which point the next writer of any +// kind prunes the still-running service from the table. +func (r HostReport) RolesFresh(want []string, now time.Time, within time.Duration) bool { + for _, role := range want { + at, ok := r.RoleSeen[role] + if !ok { + // Undated: only the record's own age can speak for it, and only if + // the record still lists the role at all. + if !r.lists(role) { + return false + } + at = r.At + } + if now.Sub(at) >= within { + return false + } + } + return true +} + +func (r HostReport) lists(role string) bool { + for _, have := range r.Roles { + if have == role { + return true + } + } + return false +} + +// HostReportList is every host's self-report, most recently heard from first. +func (s *State) HostReportList() []HostReport { + out := make([]HostReport, 0, len(s.HostReports)) + for _, r := range s.HostReports { + out = append(out, r) + } + sort.Slice(out, func(i, j int) bool { + if !out[i].At.Equal(out[j].At) { + return out[i].At.After(out[j].At) + } + return out[i].Host < out[j].Host + }) + return out +} + +// FixAgent is the agent the fleet's hosts say they run fix sessions with, from +// the most recently heard-from host that has an answer. Empty when none has. +// +// The freshest report wins because hosts CAN disagree — an install is per +// machine — and this answers "what is the fleet fixing with", which is a +// question about the machines actually running. It is not a setting and must +// never be treated as one: a host runs the agent it was installed with, +// whatever this says. +func (s *State) FixAgent(now time.Time) string { + for _, r := range s.HostReportList() { + if r.Agent != "" && r.RolesFresh([]string{"autofix"}, now, HostReportTTL) { + return r.Agent + } + } + return "" +} + +// ToolOn reports what a named host says about one tool, and whether that host +// has said anything at all. +func (s *State) ToolOn(host, tool string) (ToolReport, bool) { + r, ok := s.HostReports[host] + if !ok { + return ToolReport{}, false + } + for _, t := range r.Tools { + if t.Name == tool { + return t, true + } + } + return ToolReport{Name: tool}, true +} diff --git a/internal/state/hosts_test.go b/internal/state/hosts_test.go new file mode 100644 index 00000000..3b2325e5 --- /dev/null +++ b/internal/state/hosts_test.go @@ -0,0 +1,174 @@ +package state + +import ( + "testing" + "time" +) + +// Every crq service on a host writes the SAME record and reports only its own +// role, which is why roles merge. The merge is also what made a stopped service +// immortal: the survivor refreshed the record's single timestamp on every pass, +// so the role it carried forward never aged out and the dashboard reported a +// dead daemon running for ever. +func TestStoppedHostRoleExpiresWhileTheOtherKeepsReporting(t *testing.T) { + base := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + st := New() + + st.SetHostReport(HostReport{Host: "mac", Roles: []string{"autoreview"}}, base) + st.SetHostReport(HostReport{Host: "mac", Roles: []string{"autofix"}}, base) + if got := st.HostReports["mac"].Roles; len(got) != 2 { + t.Fatalf("roles = %v, want both services merged", got) + } + + // autofix stops here. autoreview keeps reporting, well inside the TTL. + for at := base.Add(10 * time.Minute); !at.After(base.Add(25 * time.Minute)); at = at.Add(10 * time.Minute) { + st.SetHostReport(HostReport{Host: "mac", Roles: []string{"autoreview"}}, at) + } + if got := st.HostReports["mac"].Roles; len(got) != 2 { + t.Fatalf("roles = %v, want autofix still listed inside its own TTL", got) + } + + // Past autofix's OWN last sighting it is dropped, even though the record + // itself is fresh. + st.SetHostReport(HostReport{Host: "mac", Roles: []string{"autoreview"}}, base.Add(HostReportTTL+time.Minute)) + got := st.HostReports["mac"].Roles + if len(got) != 1 || got[0] != "autoreview" { + t.Errorf("roles = %v, want only the service still reporting", got) + } +} + +func TestFixAgentIgnoresAHostWhoseAutofixRoleExpired(t *testing.T) { + base := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + st := New() + st.SetHostReport(HostReport{Host: "stale", Roles: []string{"autofix"}, Agent: "claude"}, base) + st.SetHostReport(HostReport{Host: "live", Roles: []string{"autofix"}, Agent: "codex"}, base.Add(10*time.Minute)) + + // The stale host keeps reporting serve, so its record is freshest even + // after its autofix role has expired. + now := base.Add(HostReportTTL + time.Minute) + st.SetHostReport(HostReport{Host: "stale", Roles: []string{"serve"}}, now) + if got := st.FixAgent(now); got != "codex" { + t.Fatalf("fix agent = %q, want the host whose autofix service is still live", got) + } + + // Freshness is also enforced without another report getting a chance to + // prune the stored role. + unpruned := New() + unpruned.SetHostReport(HostReport{Host: "stale", Roles: []string{"autofix"}, Agent: "claude"}, base) + unpruned.SetHostReport(HostReport{Host: "live", Roles: []string{"autofix"}, Agent: "codex"}, base.Add(10*time.Minute)) + if got := unpruned.FixAgent(now); got != "codex" { + t.Fatalf("fix agent with unpruned stale role = %q, want the still-fresh host", got) + } +} + +func TestAutofixCanClearTheHostAgentWhileOtherRolesPreserveIt(t *testing.T) { + now := time.Date(2026, 7, 29, 19, 0, 0, 0, time.UTC) + st := New() + st.SetHostReport(HostReport{Host: "mac", Roles: []string{"autofix"}, Agent: "claude"}, now) + st.SetHostReport(HostReport{Host: "mac", Roles: []string{"serve"}}, now.Add(time.Minute)) + if got := st.HostReports["mac"].Agent; got != "claude" { + t.Fatalf("serve cleared agent to %q, want it preserved", got) + } + st.SetHostReport(HostReport{Host: "mac", Roles: []string{"autofix"}}, now.Add(2*time.Minute)) + if got := st.HostReports["mac"].Agent; got != "" { + t.Fatalf("autofix cleared agent to %q, want empty", got) + } +} + +func TestHostVersionsStayWithTheirReportingRoles(t *testing.T) { + now := time.Date(2026, 7, 29, 19, 0, 0, 0, time.UTC) + st := New() + st.SetHostReport(HostReport{Host: "mac", Version: "2.0.0", Roles: []string{"autofix"}}, now) + st.SetHostReport(HostReport{Host: "mac", Version: "2.1.0", Roles: []string{"serve"}}, now.Add(time.Minute)) + + got := st.HostReports["mac"] + if got.VersionFor("autofix") != "2.0.0" || got.VersionFor("serve") != "2.1.0" { + t.Fatalf("role versions = %v, want each service's own build", got.RoleVersions) + } + if got.Version != "2.1.0" { + t.Fatalf("host version = %q, want the newest active service", got.Version) + } + + st.SetHostReport(HostReport{Host: "mac", Version: "2.0.0", Roles: []string{"autofix"}}, now.Add(2*time.Minute)) + if got := st.HostReports["mac"].Version; got != "2.1.0" { + t.Fatalf("older role heartbeat regressed host version to %q", got) + } +} + +// A record an older binary wrote carries roles and no dates. They are exactly +// as old as the record, which is the honest date to expire them from — anything +// else either drops a live role or keeps a dead one. +func TestUndatedRolesExpireFromTheRecordTheyCameWith(t *testing.T) { + base := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + st := New() + st.HostReports = map[string]HostReport{ + "linux": {Host: "linux", Roles: []string{"serve"}, At: base}, + } + + st.SetHostReport(HostReport{Host: "linux", Roles: []string{"autofix"}}, base.Add(time.Minute)) + if got := st.HostReports["linux"].Roles; len(got) != 2 { + t.Fatalf("roles = %v, want the undated role carried while it is still fresh", got) + } + + st.SetHostReport(HostReport{Host: "linux", Roles: []string{"autofix"}}, base.Add(HostReportTTL+time.Minute)) + got := st.HostReports["linux"].Roles + if len(got) != 1 || got[0] != "autofix" { + t.Errorf("roles = %v, want the undated role expired against the record it came with", got) + } +} + +// StaleRole is what a caller skipping a no-change write has to ask: the record +// is fresh because another service keeps writing it, and skipping is precisely +// what would leave the dead role listed. +func TestStaleRoleSeesPastAFreshRecord(t *testing.T) { + base := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + st := New() + st.SetHostReport(HostReport{Host: "mac", Roles: []string{"autofix"}}, base) + st.SetHostReport(HostReport{Host: "mac", Roles: []string{"autoreview"}}, base.Add(20*time.Minute)) + + rec := st.HostReports["mac"] + if rec.StaleRole(base.Add(20 * time.Minute)) { + t.Error("no role has aged out yet") + } + if !rec.StaleRole(base.Add(HostReportTTL + time.Minute)) { + t.Error("autofix has not been heard from in a TTL and the record still names it") + } +} + +// Tool probes describe the PATH of the service that took them, and those PATHs +// differ: the autofix unit adds the selected agent's directory and serve does +// not. Replacing the list with whichever service wrote last made the dashboard +// alternate between "the fix agent is here" and "it is missing" while the +// service that actually runs sessions never changed. +func TestToolReportsStayWithTheRoleThatProbedThem(t *testing.T) { + base := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + st := New() + + st.SetHostReport(HostReport{Host: "mac", Roles: []string{"autofix"}, Tools: []ToolReport{ + {Name: "git", Path: "/usr/bin/git"}, + {Name: "claude", Path: "/opt/agents/claude"}, + }}, base) + // serve runs on the same machine with a plainer PATH and cannot see the agent. + st.SetHostReport(HostReport{Host: "mac", Roles: []string{"serve"}, Tools: []ToolReport{ + {Name: "git", Path: "/usr/bin/git"}, + {Name: "claude"}, + }}, base.Add(time.Minute)) + + agent, ok := st.ToolOn("mac", "claude") + if !ok || !agent.Found() || agent.Path != "/opt/agents/claude" { + t.Errorf("claude = %+v, want the answer from the role that runs fix sessions", agent) + } + if probed := st.HostReports["mac"].RoleTools["serve"]; len(probed) != 2 || probed[1].Found() { + t.Errorf("serve's own probe = %+v, want it kept as reported", probed) + } + + // Once autofix stops reporting there is nobody left to outrank serve, so its + // answer is the host's answer. + st.SetHostReport(HostReport{Host: "mac", Roles: []string{"serve"}, Tools: []ToolReport{ + {Name: "git", Path: "/usr/bin/git"}, + {Name: "claude"}, + }}, base.Add(HostReportTTL+time.Minute)) + if agent, _ := st.ToolOn("mac", "claude"); agent.Found() { + t.Errorf("claude = %+v, want serve's answer once autofix has aged out", agent) + } +} diff --git a/internal/state/migrate.go b/internal/state/migrate.go new file mode 100644 index 00000000..bbc6b9e9 --- /dev/null +++ b/internal/state/migrate.go @@ -0,0 +1,122 @@ +package state + +import ( + "encoding/json" + "fmt" + "strings" +) + +// migrateV5State translates v5's flat fleet policy map into FleetDefaults. +// The "fleet" member kept its JSON name in v5, so decoding it directly would +// either discard the old policy or fail on values such as cobots, whose shape +// changed from a comma-separated string to an array. +func migrateV5State(raw []byte) ([]byte, error) { + var root map[string]json.RawMessage + if err := json.Unmarshal(raw, &root); err != nil { + return nil, err + } + fleetRaw, ok := root["fleet"] + if !ok || string(fleetRaw) == "null" { + return raw, nil + } + var legacy map[string]json.RawMessage + if err := json.Unmarshal(fleetRaw, &legacy); err != nil { + return nil, fmt.Errorf("decode v5 fleet policy: %w", err) + } + + next := make(map[string]any) + env := make(map[string]json.RawMessage) + if valueRaw, ok := legacy["env"]; ok { + // Some v5 writers already emitted FleetDefaults while others still + // emitted the flat policy. Start with the nested shape so flat legacy + // keys below deterministically win when both spell the same setting. + if err := json.Unmarshal(valueRaw, &env); err != nil { + return nil, fmt.Errorf("decode v5 fleet env: %w", err) + } + } + for key, valueRaw := range legacy { + if key == "env" { + continue + } + var value string + if err := json.Unmarshal(valueRaw, &value); err != nil { + // V4 values were strings. Preserve an unexpected future member + // verbatim so the migration is no more destructive than tolerant + // decoding of an unknown v5 field. + next[key] = valueRaw + continue + } + switch key { + case "required-bots": + next["required"] = splitLegacyList(value) + next["set_required"] = true + case "cobots": + next["cobots"] = splitLegacyList(value) + next["set_cobots"] = true + case "min-interval": + next["min_interval"] = strings.TrimSpace(value) + default: + if envKey, ok := legacyFleetEnvKey(key); ok { + encodedValue, err := json.Marshal(strings.TrimSpace(value)) + if err != nil { + return nil, fmt.Errorf("encode v5 fleet env %s: %w", envKey, err) + } + env[envKey] = encodedValue + } else { + next[key] = value + } + } + } + if len(env) > 0 { + next["env"] = env + } + encoded, err := json.Marshal(next) + if err != nil { + return nil, err + } + root["fleet"] = encoded + return json.Marshal(root) +} + +func splitLegacyList(value string) []string { + var out []string + for _, item := range strings.Split(value, ",") { + if item = strings.TrimSpace(item); item != "" { + out = append(out, item) + } + } + return out +} + +func legacyFleetEnvKey(key string) (string, bool) { + keys := map[string]string{ + "scope": "CRQ_SCOPE", + "repos": "CRQ_REPOS", + "exclude": "CRQ_EXCLUDE", + "feedback-bots": "CRQ_FEEDBACK_BOTS", + "inflight-timeout": "CRQ_INFLIGHT_TIMEOUT", + "rate-limit-fallback": "CRQ_RL_FALLBACK", + "calibrate-ttl": "CRQ_CALIBRATE_TTL", + "settle": "CRQ_SETTLE", + "skip-marker": "CRQ_AUTOREVIEW_SKIP_MARKER", + "skip-authors": "CRQ_AUTOREVIEW_SKIP_AUTHORS", + "rate-limit-co-degrade": "CRQ_RL_CO_DEGRADE", + } + if envKey, ok := keys[key]; ok { + return envKey, true + } + if !strings.HasPrefix(key, "cobot-") { + return "", false + } + rest := strings.TrimPrefix(key, "cobot-") + name, suffix, ok := strings.Cut(rest, "-") + if !ok || name == "" { + return "", false + } + switch suffix { + case "trigger", "cmd", "grace": + return "CRQ_COBOT_" + strings.ToUpper(name) + "_" + strings.ToUpper(suffix), true + default: + return "", false + } +} diff --git a/internal/state/solver.go b/internal/state/solver.go new file mode 100644 index 00000000..293cfa27 --- /dev/null +++ b/internal/state/solver.go @@ -0,0 +1,190 @@ +package state + +import ( + "sort" + "time" +) + +// SolverSettings is how a fix session should be run: which model, how hard it +// should think, what else to tell it, and the limits crq itself enforces. +// +// It is recorded per repository and, as a default, for the whole fleet. The +// reason it is not one fleet-wide answer is that the repositories differ more +// than the fleet does: a Go service worth a slow careful model and five +// attempts sits next to a docs repository where a fast one and a single +// attempt is the right trade. +// +// What is NOT here is the agent binary. That is chosen at install time and +// baked into the session script, because switching between claude and codex is +// a different command line rather than a different flag — and a per-repo agent +// would mean the watcher could not know what it was about to run until it ran +// it. Model and effort ARE per repo, because every agent has them. +type SolverSettings struct { + // Models is the preferred model followed by ordered fallbacks. SetModels + // distinguishes an explicit "agent default" (an empty list) from inherit. + // + // Model is retained for state written by v2.0 binaries. New writers keep it + // equal to the first model so older readers still run the preferred choice. + Models []string `json:"models,omitempty"` + SetModels bool `json:"set_models,omitempty"` + Model string `json:"model,omitempty"` + // SetEffort distinguishes an explicit "agent default" (an empty effort) + // from inheritance, just as SetModels does for the model ranking. + Effort string `json:"effort,omitempty"` + SetEffort bool `json:"set_effort,omitempty"` + // Prompt is extra instruction appended to the fix prompt, for the standing + // context a repository needs every time ("this project uses bun, never npm"). + // SetPrompt distinguishes an explicit empty prompt from inheritance. + Prompt string `json:"prompt,omitempty"` + SetPrompt bool `json:"set_prompt,omitempty"` + // MaxAttempts bounds fix sessions per head, so a fix that keeps not working + // stops. Nil inherits. + MaxAttempts *int `json:"max_attempts,omitempty"` + // Severities limits which findings autofix hands to an agent. SetSeverities + // distinguishes an explicit empty selection ("fix nothing") from inherit. + Severities []string `json:"severities,omitempty"` + SetSeverities bool `json:"set_severities,omitempty"` + // AskMode controls how readily a fix session stops for clarification. + // SetAskMode distinguishes an explicit choice from inherit. + AskMode string `json:"ask_mode,omitempty"` + SetAskMode bool `json:"set_ask_mode,omitempty"` + // Forks allows sessions on pull requests whose head branch lives in another + // repository. Off by default fleet-wide: a session runs an agent over that + // branch's code with approvals bypassed and a write token in reach. + Forks *bool `json:"forks,omitempty"` + // SkipAuthors are pull-request authors crq does not enqueue here. Set* + // distinguishes "not chosen" from "chosen to be nobody". + SkipAuthors []string `json:"skip_authors,omitempty"` + SetSkipAuthors bool `json:"set_skip_authors,omitempty"` + + By string `json:"by,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + + // unknown carries members a newer binary wrote inside this record, for the + // same reason FleetDefaults does: it is nested, so no outer carrier sees it. + unknown unknownFields +} + +// Empty reports whether this record says nothing at all, which is how a "clear" +// is distinguished from a setting of every field to its zero value. +// +// A carried member counts, for the same reason it does in FleetDefaults: +// clearing the last field THIS binary knows would otherwise drop the repository +// record — or collapse the fleet defaults around it — and take a newer binary's +// solver setting with it. +func (s SolverSettings) Empty() bool { + return !s.SetModels && len(s.Models) == 0 && s.Model == "" && + !s.SetEffort && s.Effort == "" && !s.SetPrompt && s.Prompt == "" && + s.MaxAttempts == nil && !s.SetSeverities && len(s.Severities) == 0 && + !s.SetAskMode && s.AskMode == "" && + s.Forks == nil && !s.SetSkipAuthors && + len(s.unknown) == 0 +} + +// Merge layers one record over another: every field this one states wins, and +// every field it leaves absent keeps the base's answer. That is what makes +// "fleet default, then repository" work without either layer knowing about the +// other. +func (s SolverSettings) Merge(over SolverSettings) SolverSettings { + out := s + if over.SetModels || len(over.Models) > 0 { + out.Models = append([]string(nil), over.Models...) + out.SetModels = true + out.Model = firstModel(out.Models) + } else if over.Model != "" { + // A legacy record is a one-entry ranking. + out.Models = []string{over.Model} + out.SetModels = true + out.Model = over.Model + } + if over.SetEffort || over.Effort != "" { + out.Effort = over.Effort + out.SetEffort = true + } + if over.SetPrompt || over.Prompt != "" { + out.Prompt = over.Prompt + out.SetPrompt = true + } + if over.MaxAttempts != nil { + out.MaxAttempts = over.MaxAttempts + } + if over.SetSeverities || len(over.Severities) > 0 { + out.Severities = append([]string(nil), over.Severities...) + out.SetSeverities = true + } + if over.SetAskMode || over.AskMode != "" { + out.AskMode = over.AskMode + out.SetAskMode = true + } + if over.Forks != nil { + out.Forks = over.Forks + } + if over.SetSkipAuthors { + out.SkipAuthors, out.SetSkipAuthors = over.SkipAuthors, true + } + if over.UpdatedAt != nil { + out.By, out.UpdatedAt = over.By, over.UpdatedAt + } + return out +} + +// RankedModels returns the effective ordered model list. A nil/empty result +// means "use the agent's own default". +func (s SolverSettings) RankedModels() []string { + if s.SetModels || len(s.Models) > 0 { + return append([]string(nil), s.Models...) + } + if s.Model != "" { + return []string{s.Model} + } + return nil +} + +func firstModel(models []string) string { + if len(models) == 0 { + return "" + } + return models[0] +} + +// Solver returns repo's own solver record, and whether one exists. +func (s *State) Solver(repo string) (SolverSettings, bool) { + sv, ok := s.RepoSolver[normalizeRepoKey(repo)] + return sv, ok +} + +// SetSolver records repo's solver settings, replacing any earlier ones. +func (s *State) SetSolver(repo string, sv SolverSettings, by string, now time.Time) { + if s.RepoSolver == nil { + s.RepoSolver = map[string]SolverSettings{} + } + at := now.UTC() + sv.By, sv.UpdatedAt = by, &at + s.RepoSolver[normalizeRepoKey(repo)] = sv +} + +// ClearSolver drops repo's record, returning it to the fleet default. +func (s *State) ClearSolver(repo string) bool { + key := normalizeRepoKey(repo) + if _, ok := s.RepoSolver[key]; !ok { + return false + } + delete(s.RepoSolver, key) + return true +} + +// SolverRepos lists every repository with its own record, sorted. +func (s *State) SolverRepos() []string { + out := make([]string, 0, len(s.RepoSolver)) + for repo := range s.RepoSolver { + out = append(out, repo) + } + sort.Strings(out) + return out +} + +// EffectiveSolver is the fleet default with repo's own record layered over it. +func (s *State) EffectiveSolver(repo string) SolverSettings { + sv, _ := s.Solver(repo) + return s.Fleet.Solver.Merge(sv) +} diff --git a/internal/state/solver_settings_test.go b/internal/state/solver_settings_test.go new file mode 100644 index 00000000..40b4dc61 --- /dev/null +++ b/internal/state/solver_settings_test.go @@ -0,0 +1,22 @@ +package state + +import "testing" + +func TestSolverSettingsWithLegacyAskModeIsNotEmpty(t *testing.T) { + if (SolverSettings{AskMode: "uncertain"}).Empty() { + t.Fatal("a legacy ask-mode value is configuration, not an empty record") + } +} + +func TestSolverSettingsExplicitEmptyPromptOverridesFleetPrompt(t *testing.T) { + fleet := SolverSettings{Prompt: "fleet instructions"} + repo := SolverSettings{SetPrompt: true} + + got := fleet.Merge(repo) + if got.Prompt != "" || !got.SetPrompt { + t.Fatalf("merged prompt = %q (set=%t), want an explicit empty prompt", got.Prompt, got.SetPrompt) + } + if repo.Empty() { + t.Fatal("an explicitly empty prompt must keep the repository record") + } +} diff --git a/internal/state/state.go b/internal/state/state.go index 50b6105e..d660cc58 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -1,4 +1,4 @@ -// Package state defines crq's persisted schema v5: one Round per tracked PR, +// Package state defines crq's persisted schema v6: one Round per tracked PR, // a single global fire slot, and the CodeRabbit account quota. A Round is // never deleted, only transitioned (or archived when superseded by a new // head) — the invariant that makes "forgot we already requested a review at @@ -69,6 +69,13 @@ type Round struct { // binaries never fired them, so ignoring the map is correct for them. // Mutate only through Co/SetCoCommand/ClaimCo/ClearCoClaim. CoBots map[string]CoBotRound `json:"cobots,omitempty"` + // Title is the pull request's own title, recorded when the round is + // enqueued. Every queue and in-flight list showed "repo#141" and nothing + // about what that pull request was for, and fetching a title per row would + // be a request per row — so it is stored once, with the round that needs it. + // Stale after a rename, which is a price worth paying for a list that says + // what it is listing. + Title string `json:"title,omitempty"` // CoOnly marks a round that reached its fired/reviewing state WITHOUT crq // requesting a primary review — a co-reviewer-only trigger, or a bounded @@ -116,6 +123,27 @@ type Round struct { // reviewer must report it again. Dismissed map[string]string `json:"dismissed,omitempty"` + // PrimaryAnsweredAt is when crq OBSERVED the primary produce head evidence, + // the same fact CoBots[login].AnsweredAt records for a co-reviewer. + // + // It cannot be derived from the phase. A required set that omits the primary + // completes as soon as the co-reviewers answer and the primary merely + // acknowledges the metered command, so reading a completed round as proof + // the primary reviewed labelled a reviewer as working on the strength of its + // own acknowledgement. FiredAt is crq's command going out and says even less. + // + // Not in CoBots despite being the same fact: several fire and completion + // rules walk that map as "the co-reviewers", and the primary appearing there + // would quietly join them. + PrimaryAnsweredAt *time.Time `json:"primary_answered_at,omitempty"` + // PrimaryAnsweredBy is WHICH primary produced that evidence. CRQ_BOT is a + // setting, and a fleet that changes it leaves rounds answered by the retired + // one behind: attributing them to whatever the reading process calls its + // primary shows the new bot as working and the old one as silent, which is + // the two claims exactly backwards. The identity travels as data here for + // the same reason it does in CoBots. + PrimaryAnsweredBy string `json:"primary_answered_by,omitempty"` + // RetryAt is the earliest time this head may fire again (awaiting_retry). RetryAt *time.Time `json:"retry_at,omitempty"` @@ -183,10 +211,26 @@ type CoBotRound struct { CommandID int64 `json:"command_id,omitempty"` CommandedAt *time.Time `json:"commanded_at,omitempty"` ClaimedAt *time.Time `json:"claimed_at,omitempty"` + // AnsweredAt is when crq FIRST observed this bot produce head evidence — a + // review, a clean summary at the SHA, a completed check run. + // + // The three fields above are all crq's own bookkeeping: what it posted and + // what it claimed the right to post. None of them says the bot did + // anything, and treating them as evidence of that is how a bot nobody has + // an account for reads as working — crq asks, records that it asked, and + // nothing ever answers. Only this field is about the bot. + AnsweredAt *time.Time `json:"answered_at,omitempty"` + + // unknown carries members a newer binary wrote inside this record. Round's + // carrier cannot: it sees "cobots" as a member it knows and hands the map + // to the ordinary decoder, which drops anything inside the values. See + // tolerant.go. + unknown unknownFields } func (c CoBotRound) empty() bool { - return c.CommandID == 0 && c.CommandedAt == nil && c.ClaimedAt == nil + return c.CommandID == 0 && c.CommandedAt == nil && c.ClaimedAt == nil && c.AnsweredAt == nil && + len(c.unknown) == 0 } // codexCoBotKey is dialect.CodexBotLogin under coBotKey. The literal is @@ -250,6 +294,34 @@ func (r *Round) ClaimCo(login string, now time.Time) { r.setCo(login, c) } +// NoteCoAnswer records that login was OBSERVED producing head evidence. The +// FIRST such observation wins: a round is one head, so the evidence does not +// change, and re-stamping it with each sweep's clock would both rewrite state +// on every pass and make a bot that answered days ago read as freshly active. +func (r *Round) NoteCoAnswer(login string, at time.Time) { + c := r.Co(login) + if c.AnsweredAt != nil { + return + } + t := at.UTC() + c.AnsweredAt = &t + r.setCo(login, c) +} + +// NotePrimaryAnswer records that login, the primary at the time, was OBSERVED +// producing head evidence. Same terms as NoteCoAnswer sets for a co-reviewer: +// the FIRST observation wins, since a round is one head and re-stamping it +// would rewrite state on every sweep. The login is stored with it so a later +// reader is not left inferring who answered from its own configuration. +func (r *Round) NotePrimaryAnswer(login string, at time.Time) { + if r.PrimaryAnsweredAt != nil { + return + } + t := at.UTC() + r.PrimaryAnsweredAt = &t + r.PrimaryAnsweredBy = login +} + // ClearCoClaim releases login's claim without recording a command. func (r *Round) ClearCoClaim(login string) { c := r.Co(login) @@ -262,8 +334,17 @@ func (r *Round) ClearCoClaim(login string) { // write only them, new ones dual-write), so they are authoritative in BOTH // directions: they overwrite the mirror entry, and an empty legacy set clears // a stale mirror (a writer zeroed the legacy fields directly). +// Authoritative about what they CARRY, which is the three trigger fields. +// AnsweredAt has no legacy counterpart — it is what crq observed the bot do, +// not bookkeeping about crq's own post — so no writer of any version can state +// it there. Overwriting the whole entry therefore erased it on every load, and +// Codex alone read as a bot that never answers. func (r *Round) foldLegacyCodex() { - r.setCo(codexCoBotKey, CoBotRound{CommandID: r.CodexCommandID, CommandedAt: r.CodexCommandedAt, ClaimedAt: r.CodexClaimedAt}) + prev := r.Co(codexCoBotKey) + r.setCo(codexCoBotKey, CoBotRound{ + CommandID: r.CodexCommandID, CommandedAt: r.CodexCommandedAt, ClaimedAt: r.CodexClaimedAt, + AnsweredAt: prev.AnsweredAt, unknown: prev.unknown, + }) } // inferCoOnly backfills CoOnly for rounds written before the flag existed. The @@ -329,6 +410,21 @@ type AccountQuota struct { // that extends the window on every bounce. RLCommentID int64 `json:"rl_comment_id,omitempty"` RLCommentUpdated *time.Time `json:"rl_comment_updated,omitempty"` + // Fires is when metered reviews were requested, trimmed to a rolling two + // weeks. See firelog.go: it exists to forecast the vendor's WEEKLY fair-use + // throttle, which crq can already recognise but never see coming. + Fires []time.Time `json:"fires,omitempty"` + // FiresFrom is when the log's COVERAGE starts, which is not the same as its + // oldest surviving entry: trimming keeps the blob small and would otherwise + // keep moving the start forward, telling a quiet fleet its fully observed + // week is only a floor. Recorded once and moved only when the entry cap + // genuinely discards history the rolling week needs. + FiresFrom *time.Time `json:"fires_from,omitempty"` + + // unknown carries members a newer binary wrote inside this record. State's + // carrier cannot: it sees "account" as a member it knows and hands the whole + // object to the ordinary decoder. See tolerant.go. + unknown unknownFields } type LeaderLease struct { @@ -367,7 +463,7 @@ func (l LeaderCapabilityLease) HasCapability(want string) bool { return false } -// State is schema v5. It persists as state.json in the existing git state ref; +// State is schema v6. It persists as state.json in the existing git state ref; // v4 is migrated in place so its live rounds survive the compatibility fence. type State struct { Version int `json:"v"` // 5 @@ -399,6 +495,13 @@ type State struct { // one. Persisted in the shared state so the whole fleet uses the new issue. CalibrationIssue int `json:"calibration_issue,omitempty"` + // Holds are the PRs crq must not fire a review for, keyed by "owner/name#pr". + // + // Holding used to take two commands that could not be one: the skip marker + // stops fleet auto-review from enqueueing, `crq cancel` stops the pump, and + // between the two a daemon fired anyway. A hold is one fact, in the state + // every firing path already reads. + Holds map[string]Hold `json:"holds,omitempty"` // Writers records which hosts have written this state and what they can do, // so a feature that only SOME binaries understand can say so instead of // pretending agreement. Sharing a ref stops an old binary erasing a new @@ -416,18 +519,6 @@ type State struct { // read this ref, so both cannot disagree about it. Repos map[string]RepoReviewers `json:"repos,omitempty"` - // FleetConfig is the policy every host shares — which repositories are in - // scope, who reviews, and the timings the queue paces itself by. Same - // argument as Repos above, one level up: hosts that each carry their own - // answer diverge silently. See fleet.go. - FleetConfig Fleet `json:"fleet,omitempty"` - // Holds are the PRs crq must not fire a review for, keyed by "owner/name#pr". - // - // Holding used to take two commands that could not be one: the skip marker - // stops fleet auto-review from enqueueing, `crq cancel` stops the pump, and - // between the two a daemon fired anyway. A hold is one fact, in the state - // every firing path already reads. - Holds map[string]Hold `json:"holds,omitempty"` // Archive keeps recently finished rounds (superseded, closed, cancelled) // for the dashboard and debugging. Bounded by ArchiveMax. Archive []Round `json:"archive,omitempty"` @@ -444,6 +535,18 @@ type State struct { // A session may still be resolving threads after its push superseded the // claimed round, and archive eviction must not admit a second session. Dispatches map[string]DispatchClaim `json:"dispatches,omitempty"` + // Fleet is what every repository inherits, recorded once for the whole fleet + // rather than in each host's env file. See fleet.go. + Fleet FleetDefaults `json:"fleet,omitempty"` + // HostReports is what each machine says about itself: its crq version and + // the tools it can reach. See hosts.go. + HostReports map[string]HostReport `json:"host_reports,omitempty"` + // RepoSolver is how a fix session runs, per repository. See solver.go. + RepoSolver map[string]SolverSettings `json:"repo_solver,omitempty"` + // Enrolled answers "does crq review this project at all?" per repository, + // so the decision lives with the fleet rather than in one host's env file. + // Absent means the hosts' CRQ_REPOS/CRQ_EXCLUDE decide, as before. + Enrolled map[string]RepoEnrollment `json:"enrolled,omitempty"` // RepoAutofix answers "may crq fix pull requests here?" per repository. Absent // means the default, which is yes — see AutofixEnabled. RepoAutofix map[string]RepoAutofixSwitch `json:"repo_autofix,omitempty"` @@ -481,19 +584,50 @@ func (s State) LeaderHasCapability(want string) bool { s.LeaderCapabilities.HasCapability(want) } -const SchemaVersion = 5 +const SchemaVersion = 6 // WriterCaps is what THIS binary understands. Bump it when a state field starts // changing decisions, so a fleet running two versions can tell. -const WriterCaps = 3 +const WriterCaps = 8 // CapsRepoOverrides is the capability that makes per-repository reviewer // overrides safe to act on. const CapsRepoOverrides = 1 -// CapsFleetPolicy is the capability that makes state-backed fleet policy safe -// to activate while a process is driving the queue. -const CapsFleetPolicy = 3 +// CapsPrimaryOff is the capability that makes RepoReviewers.PrimaryOff safe to +// act on. A host below it still fires the primary there — so turning the +// primary off has to say which hosts will not honour it, exactly as the +// override itself does. +// +// Worse here than elsewhere, which is why the warning matters: a binary from +// before RepoReviewers round-tripped unknown members does not merely ignore the +// switch, it ERASES it on its next write, and the repository silently resumes +// metered primary reviews on every host. Nothing can be done about a binary +// already in the field; the tolerant decoding in tolerant.go is what stops the +// next one from doing it. +const CapsPrimaryOff = 2 + +// CapsEnrollment is the capability that makes State.Enrolled safe to act on. A +// host below it decides from its own env alone, so a repository enrolled here +// is invisible to it and one turned off here keeps being reviewed by it. +const CapsEnrollment = 3 + +// CapsFleetDefaults is the capability that makes State.Fleet safe to act on. A +// host below it keeps deciding from its own env, so a default recorded here is +// simply not applied there. +const CapsFleetDefaults = 4 + +// CapsSolver is the capability that makes every solver setting safe to act on. +// A host below it either runs every fix session with its own install-time +// settings or cannot distinguish an explicitly empty repository prompt from +// inheritance, so the dashboard must name it before claiming the saved answer +// applies fleet-wide. +const CapsSolver = 8 + +// CapsDispatchClarification is the capability that makes a head-scoped +// clarification marker terminal for autofix dispatch. Older watchers preserve +// the marker but may still launch another session for the same question. +const CapsDispatchClarification = 7 // writerTTL is how long a host counts as still active for capability purposes. const writerTTL = 30 * time.Minute @@ -533,8 +667,18 @@ func (s *State) NoteWriter(host string, caps int, now time.Time) { // on this actually honour it?" An old binary loads an unknown field, writes it // back untouched, and keeps deciding from its own fleet-wide configuration. func (s *State) LaggingWriters(caps int, now time.Time) []string { + acting := map[string]bool{} + // The leader identifies itself as "host= pid= run=", which is + // exactly the process identity capabilities are recorded under — the run + // component is what keeps a restart into a reused pid from inheriting them. + if s.Leader != nil && s.Leader.ExpiresAt.After(now) && strings.TrimSpace(s.Leader.Owner) != "" { + acting[s.Leader.Owner] = true + } + if slot := s.SlotRound(); slot != nil && slot.ByHost != "" { + acting[slot.ByHost] = true + } var out []string - for host := range s.actingWriters(now) { + for host := range acting { if seen, ok := s.Writers[host]; ok && seen.Caps >= caps && now.Sub(seen.At) <= writerTTL { continue } @@ -544,41 +688,46 @@ func (s *State) LaggingWriters(caps int, now time.Time) []string { return out } -// AdvancedWriters names the hosts driving this queue with a capability HIGHER -// than caps — the fleet running a newer binary than the caller's. +// LaggingRoleWriters is LaggingWriters plus every host reporting one of the +// given roles with an older capability set. // -// It is the mirror of LaggingWriters, and answers the question an old CLI has to -// ask before it deletes a recorded setting it cannot interpret: is anyone acting -// on it? WriterCaps is bumped exactly when a state field starts changing -// decisions, so a driver announcing more than this binary knows is the one that -// wrote that setting and owns whatever cleanup dropping it needs. -func (s *State) AdvancedWriters(caps int, now time.Time) []string { - var out []string - for host := range s.actingWriters(now) { - if seen, ok := s.Writers[host]; ok && seen.Caps > caps && now.Sub(seen.At) <= writerTTL { - out = append(out, host) +// The acting set LaggingWriters builds is the leader and the fire-slot owner — +// the processes that drive a REVIEW. A setting consumed by something else has no +// entry there: the autofix watcher holds neither lease, so a solver record's +// lagging list came back empty while an old watcher went on dispatching +// install-time model, fork and attempt values. A host's self-report is what +// names it, since it records the binary's capabilities beside the roles running +// there. +// +// Reports are named by machine and writers by process ("host=blue pid=4711 +// run=1a2b"), so a machine lagging in both registers is listed once, under the +// writer identity that says which process it is. +// +// Capabilities are asked of the ROLE, not of the record: a machine mid-upgrade +// runs one service on each build, and the record's own value is whichever wrote +// last. Reading it let a fresh `serve` heartbeat vouch for an old watcher. +func (s *State) LaggingRoleWriters(caps int, now time.Time, roles ...string) []string { + out := s.LaggingWriters(caps, now) + named := map[string]bool{} + for _, writer := range out { + named[hostName(writer)] = true + } + for host, report := range s.HostReports { + if named[host] { + continue + } + for _, role := range roles { + if report.CapsFor(role) < caps && report.RolesFresh([]string{role}, now, HostReportTTL) { + named[host] = true + out = append(out, host) + break + } } } sort.Strings(out) return out } -// actingWriters names the processes DRIVING this queue: holding the leader lease -// or the fire slot. -func (s *State) actingWriters(now time.Time) map[string]bool { - acting := map[string]bool{} - // The leader identifies itself as "host= pid= run=", which is - // exactly the process identity capabilities are recorded under — the run - // component is what keeps a restart into a reused pid from inheriting them. - if s.Leader != nil && s.Leader.ExpiresAt.After(now) && strings.TrimSpace(s.Leader.Owner) != "" { - acting[s.Leader.Owner] = true - } - if slot := s.SlotRound(); slot != nil && slot.ByHost != "" { - acting[slot.ByHost] = true - } - return acting -} - // RepoReviewers overrides which reviewers run on one repository. A nil slice // means "no override, use the fleet default"; an empty non-nil slice means // "none here" — the difference is why these are pointers to slices in JSON @@ -588,12 +737,26 @@ type RepoReviewers struct { CoBots []string `json:"cobots,omitempty"` // Required are the logins that gate convergence here. Required []string `json:"required,omitempty"` + // PrimaryOff turns the metered primary off for this repository: crq never + // posts its review command here, never spends the account quota or the fire + // slot on it, and never waits for its review. The co-reviewers resolve the + // round alone. There is no "set" companion because false IS the default — + // the primary runs unless a repository says otherwise. + PrimaryOff bool `json:"primary_off,omitempty"` // SetCoBots/SetRequired record whether each list was set at all, so // "explicitly none" survives a JSON round trip that drops empty slices. SetCoBots bool `json:"set_cobots,omitempty"` SetRequired bool `json:"set_required,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"` By string `json:"by,omitempty"` + + // unknown carries JSON members this binary has no field for. State + // recognises "repos" and hands each value to an ordinary decoder, so the + // top-level carrier never sees a member added INSIDE one of these records — + // an older binary that knows the map but not PrimaryOff would drop the + // switch on its next write and the repository would silently resume metered + // primary reviews. See tolerant.go. + unknown unknownFields } // RepoOverride returns the override for repo, and whether one exists. @@ -902,6 +1065,28 @@ func (s *State) PutRound(r Round) { s.Rounds[Key(r.Repo, r.PR)] = r } +// MoveToFront gives the current round the lowest sequence in the state. +// +// Normal rounds have positive sequences. Explicitly prioritized rounds use +// negative ones, which keeps the existing queue contract understood by older +// binaries while also letting autofix distinguish operator priority from +// ordinary FIFO order. +func (s *State) MoveToFront(repo string, pr int) bool { + r := s.Round(repo, pr) + if r == nil || (r.Phase != PhaseQueued && r.Phase != PhaseAwaitingRetry) { + return false + } + minSeq := int64(0) + for _, other := range s.Rounds { + if other.Seq < minSeq { + minSeq = other.Seq + } + } + r.Seq = minSeq - 1 + s.PutRound(*r) + return true +} + // NewRound begins a round for a head with no current round. It refuses to // clobber an existing round — supersede via EndRound first — so "two rounds // for one PR" cannot happen by accident. @@ -1061,10 +1246,36 @@ type DispatchClaim struct { Token string `json:"token"` At time.Time `json:"at"` Heartbeat time.Time `json:"heartbeat"` - // Attempts counts dispatches for THIS head, so a fix that keeps failing - // stops instead of looping. It survives release; only a new head clears it, - // because a new head means the previous attempt achieved something. + // Attempts counts dispatches in THIS head's current attempt cycle. It + // survives release; the cycle resets after a cooldown or on a new head. Attempts int `json:"attempts,omitempty"` + // Model is the model selected for this claim. ModelCursor points at the next + // ranked fallback, so ordinary failures rotate rather than hammering one + // model. UnavailableModels parks provider/model outages until their retry + // time without spending the head's fix-attempt budget. + Model string `json:"model,omitempty"` + ModelCursor int `json:"model_cursor,omitempty"` + UnavailableModels map[string]time.Time `json:"unavailable_models,omitempty"` + LastFailure string `json:"last_failure,omitempty"` + // Clarification is a head-scoped terminal stop requested by the agent. It + // is separate from an administrative hold because standalone watch/autofix + // has no autoreview leader capable of enforcing those holds. + Clarification string `json:"clarification,omitempty"` + // AttemptResetAt makes the safety bound a cooldown rather than a permanent + // dead letter. Exhaustions lengthens repeated cooldowns, capped at a day. + AttemptResetAt *time.Time `json:"attempt_reset_at,omitempty"` + Exhaustions int `json:"exhaustions,omitempty"` + // Findings is how many the session set out to fix, and Log is where its + // output is going. Both are for the reader: "attempt 2" says nothing about + // whether that is nearly the last one, and a session with no visible log is + // one you cannot check on. + Findings int `json:"findings,omitempty"` + Log string `json:"log,omitempty"` + + // unknown carries members a newer binary wrote inside this record. The maps + // holding claims are recognised by name, so only the claim itself can carry + // one. See tolerant.go. + unknown unknownFields } // DispatchHeld reports whether a live claim exists. @@ -1083,7 +1294,7 @@ func (r *Round) DispatchHeld(now time.Time) bool { // same worktree generation's work. func (s *State) ArchivedDispatchHeld(repo string, pr int, now time.Time) bool { key := Key(repo, pr) - if claim, ok := s.Dispatches[key]; ok && claimHeld(claim, now) { + if claim, ok := s.Dispatches[key]; ok && claim.Live(now) { return true } for i := range s.Archive { @@ -1109,7 +1320,7 @@ func (s *State) HeartbeatArchivedDispatch(repo string, pr int, token string, now s.Dispatches[key] = claim return true, false } - if claimHeld(claim, now) { + if claim.Live(now) { return false, true } } @@ -1150,6 +1361,26 @@ func (s *State) ReleaseArchivedDispatch(repo string, pr int, token string) bool return released } +// MarkArchivedDispatchUnavailable records a provider outage on the archived +// claim owned by token. A session can outlive the head it started on, so the +// current round is not necessarily the claim owner. +func (s *State) MarkArchivedDispatchUnavailable(repo string, pr int, token string, until time.Time, reason string) bool { + key := Key(repo, pr) + marked := false + if claim, ok := s.Dispatches[key]; ok && claim.Token == token { + claim.markUnavailable(until, reason) + s.Dispatches[key] = claim + marked = true + } + for i := range s.Archive { + r := &s.Archive[i] + if Key(r.Repo, r.PR) == key && r.MarkDispatchUnavailable(token, until, reason) { + marked = true + } + } + return marked +} + // RememberDispatch stores a newly granted claim independently of its round. func (s *State) RememberDispatch(repo string, pr int, claim DispatchClaim) { if s.Dispatches == nil { @@ -1158,30 +1389,140 @@ func (s *State) RememberDispatch(repo string, pr int, claim DispatchClaim) { s.Dispatches[Key(repo, pr)] = claim } -func claimHeld(claim DispatchClaim, now time.Time) bool { - return !claim.Heartbeat.IsZero() && now.UTC().Sub(claim.Heartbeat) < DispatchTTL +// Live reports whether a session is still behind this claim: a heartbeat within +// DispatchTTL. It is the same predicate dispatch ownership uses, exported so a +// reader — the dashboard — cannot show a crashed watcher's claim as a running +// session for ever. +func (c DispatchClaim) Live(now time.Time) bool { + return !c.Heartbeat.IsZero() && now.UTC().Sub(c.Heartbeat) < DispatchTTL } // ClaimDispatch takes this round's dispatch claim, or reports why it cannot. A // claim past its TTL is taken over, keeping the attempt count: that session died, // but its attempt still happened. func (r *Round) ClaimDispatch(host, token string, now time.Time, maxAttempts int) (bool, string) { + return r.ClaimDispatchModels(host, token, now, maxAttempts, nil) +} + +// ClaimDispatchModels claims a fix session and selects the first currently +// available model in ranking order. An empty ranking is one selectable entry: +// the agent's own default. +func (r *Round) ClaimDispatchModels(host, token string, now time.Time, maxAttempts int, models []string) (bool, string) { now = now.UTC() attempts := 0 + cursor := 0 + unavailable := map[string]time.Time{} + lastFailure := "" + var attemptResetAt *time.Time + exhaustions := 0 + var unknown unknownFields if r.Dispatch != nil { if r.DispatchHeld(now) { return false, "another watcher is already fixing this round" } + if r.Dispatch.Clarification != "" { + return false, "autofix needs clarification: " + r.Dispatch.Clarification + } attempts = r.Dispatch.Attempts + cursor = r.Dispatch.ModelCursor + lastFailure = r.Dispatch.LastFailure + attemptResetAt = r.Dispatch.AttemptResetAt + exhaustions = r.Dispatch.Exhaustions + unknown = r.Dispatch.unknown + for model, until := range r.Dispatch.UnavailableModels { + if until.After(now) { + unavailable[model] = until.UTC() + } + } } if maxAttempts > 0 && attempts >= maxAttempts { - return false, fmt.Sprintf("%d dispatch attempts already made for this head", attempts) + resetAt := r.Dispatch.At.Add(dispatchAttemptCooldown(exhaustions)) + if attemptResetAt != nil { + resetAt = attemptResetAt.UTC() + } + if now.Before(resetAt) { + return false, fmt.Sprintf( + "%d dispatch attempts already made in this cycle; retries resume at %s", + attempts, resetAt.Format(time.RFC3339), + ) + } + attempts = 0 + attemptResetAt = nil + exhaustions++ + } + if len(models) == 0 { + models = []string{""} + } + if cursor < 0 || cursor >= len(models) { + cursor = 0 + } + selected, selectedAt := "", -1 + var earliest time.Time + for step := 0; step < len(models); step++ { + i := (cursor + step) % len(models) + model := strings.TrimSpace(models[i]) + if until, blocked := unavailable[model]; blocked { + if earliest.IsZero() || until.Before(earliest) { + earliest = until + } + continue + } + selected, selectedAt = model, i + break + } + if selectedAt < 0 { + return false, fmt.Sprintf("all configured models are temporarily unavailable until %s", earliest.Format(time.RFC3339)) } r.beginDispatchHold(now) - r.Dispatch = &DispatchClaim{Host: host, Token: token, At: now, Heartbeat: now, Attempts: attempts + 1} + r.Dispatch = &DispatchClaim{ + Host: host, Token: token, At: now, Heartbeat: now, + Attempts: attempts + 1, Model: selected, + ModelCursor: (selectedAt + 1) % len(models), + UnavailableModels: unavailable, LastFailure: lastFailure, + AttemptResetAt: attemptResetAt, Exhaustions: exhaustions, + unknown: unknown, + } + if maxAttempts > 0 && r.Dispatch.Attempts >= maxAttempts { + resetAt := now.Add(dispatchAttemptCooldown(exhaustions)) + r.Dispatch.AttemptResetAt = &resetAt + } return true, "" } +func dispatchAttemptCooldown(exhaustions int) time.Duration { + cooldown := time.Hour + for i := 0; i < exhaustions && cooldown < 24*time.Hour; i++ { + cooldown *= 2 + } + if cooldown > 24*time.Hour { + return 24 * time.Hour + } + return cooldown +} + +// MarkDispatchUnavailable records a provider/model outage against the selected +// claim and refunds its attempt. It deliberately leaves ModelCursor advanced, +// so the next claim tries the next ranked model. +func (r *Round) MarkDispatchUnavailable(token string, until time.Time, reason string) bool { + if r.Dispatch == nil || r.Dispatch.Token != token { + return false + } + r.Dispatch.markUnavailable(until, reason) + return true +} + +func (c *DispatchClaim) markUnavailable(until time.Time, reason string) { + if c.UnavailableModels == nil { + c.UnavailableModels = map[string]time.Time{} + } + c.UnavailableModels[c.Model] = until.UTC() + c.LastFailure = reason + if c.Attempts > 0 { + c.Attempts-- + } + c.AttemptResetAt = nil +} + // beginDispatchHold mirrors the new dispatch exclusion into queue state every // older binary already knows. The original cooldown is restored on release. func (r *Round) beginDispatchHold(now time.Time) { @@ -1241,6 +1582,20 @@ func (r *Round) ReleaseDispatch(token string) bool { return true } +// MarkDispatchClarification ends token's live claim while retaining a terminal +// reason on this head. A new head gets a new Round and may dispatch normally. +func (r *Round) MarkDispatchClarification(token, question string) bool { + if r.Dispatch == nil || r.Dispatch.Token != token { + return false + } + r.Dispatch.Clarification = strings.TrimSpace(question) + if r.Dispatch.Attempts > 0 { + r.Dispatch.Attempts-- + } + r.Dispatch.AttemptResetAt = nil + return r.ReleaseDispatch(token) +} + // AutofixUnhealthyAfter is how many consecutive dispatch attempts may fail to // start a session before crq says so out loud. One failure is a transient; three // in a row is a dispatcher that is not working. @@ -1688,6 +2043,16 @@ func (s *State) Normalize(now time.Time) { if s.AutofixByHost != nil { s.summarizeAutofix(now) } + // Dispatches is the cross-archive ownership index, not attempt history. + // Once a claim's heartbeat expires scheduling already treats it as free, so + // retaining it can only grow state and let an older dashboard render a dead + // session forever. The round (current or archived) retains the attempt + // counters used by a replacement claim. + for key, claim := range s.Dispatches { + if !claim.Live(now) { + delete(s.Dispatches, key) + } + } // Fold both hold representations before repairing the slot. The top-level // mirror may be the only copy left after a pre-FireSlot-tolerance binary // normalized and rewrote an orphaned hold. diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 9f534723..90494efb 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -450,3 +450,39 @@ func TestRequestedRoundsExcludesCoOnly(t *testing.T) { t.Fatalf("a co-only round must keep its anchor and marker, got %+v", r) } } + +func TestMoveToFrontReordersAndCanBeRepeated(t *testing.T) { + st := New() + for pr := 1; pr <= 3; pr++ { + if _, err := st.NewRound("o/r", pr, "aaaaaaaa1", t0); err != nil { + t.Fatal(err) + } + } + + if !st.MoveToFront("o/r", 3) { + t.Fatal("MoveToFront rejected a tracked round") + } + if got := st.NextEligible(t0); got == nil || got.PR != 3 || got.Seq != -1 { + t.Fatalf("first prioritized round = %+v, want PR 3 at sequence -1", got) + } + if !st.MoveToFront("o/r", 2) { + t.Fatal("second MoveToFront rejected a tracked round") + } + if got := st.NextEligible(t0); got == nil || got.PR != 2 || got.Seq != -2 { + t.Fatalf("second prioritized round = %+v, want PR 2 at sequence -2", got) + } + if st.MoveToFront("o/r", 99) { + t.Fatal("MoveToFront accepted an untracked round") + } + + reserved := st.Rounds[Key("o/r", 1)] + reserved.Phase = PhaseReserved + st.PutRound(reserved) + seq := reserved.Seq + if st.MoveToFront("o/r", 1) { + t.Fatal("MoveToFront accepted a reserved round and changed its identity") + } + if got := st.Round("o/r", 1); got == nil || got.Seq != seq { + t.Fatalf("reserved round = %+v, want sequence %d unchanged", got, seq) + } +} diff --git a/internal/state/statusline.go b/internal/state/statusline.go index 211d37b2..dbb10fb1 100644 --- a/internal/state/statusline.go +++ b/internal/state/statusline.go @@ -16,9 +16,8 @@ import ( // Everything here is already computed for the dashboard; this is a second // rendering of the same reduced state, not new logic. func StatusLine(st State, cfg StoreConfig) string { - cfg = cfg.withFleet(st) now := time.Now().UTC() - queue := st.Queue(now, cfg.MinInterval) + queue := st.Queue(now, dashboardInterval(st, cfg)) inFlight := inFlightRounds(st) held := heldRounds(st) diff --git a/internal/state/store.go b/internal/state/store.go index 31fb9d55..cc18140c 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -43,6 +43,10 @@ type StoreConfig struct { // bots ("" hides the dashboard row, keeping co-bot-less dashboards // byte-identical). CoReviewers string + // ResolveCoReviewers applies the current fleet record before rendering. + // The state package owns persistence but not reviewer registry policy, so + // crq supplies this resolver while static callers may keep CoReviewers. + ResolveCoReviewers func(FleetDefaults) string // Host names this process in the state it writes, so the fleet can tell which // binaries are driving and what they understand (see State.NoteWriter). Host string @@ -81,13 +85,13 @@ type Revision struct { type StateStore interface { Load(context.Context) (State, Revision, error) Update(context.Context, func(*State) error) (State, error) - SyncDashboard(context.Context, State, StoreConfig) error + SyncDashboard(context.Context, State) error } -// GitStateStore persists v5 state as state.json in a git ref, with the same -// compare-and-swap mechanism as v4 (12 retries on UpdateRef 409/422). +// GitStateStore persists v4 state as state.json in a git ref, with the same +// compare-and-swap mechanism as v3 (12 retries on UpdateRef 409/422). // -// V4 is migrated in place; still older payloads are discarded because crq is +// V3 is migrated in place; still older payloads are discarded because crq is // pre-release and they describe a world this binary cannot act on. A NEWER one // is refused. The fleet runs mixed binary versions during a rolling deploy, so // reinitializing there would mean the first old binary to wake up erases every @@ -98,13 +102,8 @@ type GitStateStore struct { gh *gh.GitHub log Logger - // renderCfg resolves the configuration a dashboard render should use for a - // given state, defaulting to this process's own. crq replaces it with one - // that applies fleet policy: dashboard.md is rewritten on EVERY state - // commit, so rendering it from startup configuration would have each host - // write its own reviewer set into the shared file — flapping between - // machines, and disagreeing with the gate issue, which SyncDashboard already - // renders from the effective policy. + // renderCfg resolves state-backed fleet settings before rendering. It is + // installed once by crq, which owns the bot registry needed to format them. renderCfg func(State) StoreConfig // syncMu serializes the read-then-write in SyncDashboard, so concurrent @@ -116,10 +115,8 @@ func NewGitStateStore(cfg StoreConfig, client *gh.GitHub, log Logger) *GitStateS return &GitStateStore{cfg: cfg, gh: client, log: log} } -// SetRenderConfig installs the resolver compareAndSwap renders dashboard.md -// with. It is set once, before the store is used: the state package holds no bot -// registry, so only crq can turn recorded fleet policy into a rendering -// configuration. +// SetRenderConfig installs the effective configuration used for dashboard.md +// and the gate issue. func (s *GitStateStore) SetRenderConfig(resolve func(State) StoreConfig) { s.renderCfg = resolve } @@ -173,9 +170,9 @@ func (s *GitStateStore) Load(ctx context.Context) (State, Revision, error) { if err != nil { return State{}, Revision{}, err } - // Peek at the schema version before a full decode. V4 is the one supported - // migration: v5 intentionally fences v4 pumping clients from fleet policy, - // while preserving every live v4 round during the rollout. + // Peek at the schema version before a full decode. V5 is the one supported + // migration: v6 fences v5 writers that encoded fleet policy with a + // different shape, while preserving every live v5 round during rollout. var probe struct { Version int `json:"v"` } @@ -190,6 +187,12 @@ func (s *GitStateStore) Load(ctx context.Context) (State, Revision, error) { s.logf("state ref %s holds schema v%d (want v%d) — reinitializing to a fresh state (no migration; crq is pre-release)", s.cfg.StateRef, probe.Version, SchemaVersion) return s.fresh(), rev, nil } + if probe.Version == SchemaVersion-1 { + raw, err = migrateV5State(raw) + if err != nil { + return State{}, Revision{}, s.refuse("holds a v5 fleet policy this binary cannot migrate", err) + } + } var st State if err := json.Unmarshal(raw, &st); err != nil { // The version says this binary should understand or migrate it and it @@ -314,15 +317,16 @@ func (s *GitStateStore) compareAndSwap(ctx context.Context, st *State, rev Revis // // A read failure is never fatal: fall through and PATCH, which is the old // behavior. -func (s *GitStateStore) SyncDashboard(ctx context.Context, st State, renderCfg StoreConfig) error { +func (s *GitStateStore) SyncDashboard(ctx context.Context, st State) error { if err := s.cfg.requireDashboard(); err != nil { return err } - body, err := IssueBody(st, renderCfg) + cfg := s.renderConfig(st) + body, err := IssueBody(st, cfg) if err != nil { return err } - title := RenderTitle(st, renderCfg) + title := RenderTitle(st, cfg) // Held across read-then-write so two concurrent syncs cannot both observe a // stale issue and both write it. @@ -380,7 +384,7 @@ func (m *MemoryStore) Update(_ context.Context, mutate func(*State) error) (Stat return st, nil } -func (m *MemoryStore) SyncDashboard(context.Context, State, StoreConfig) error { return nil } +func (m *MemoryStore) SyncDashboard(context.Context, State) error { return nil } // Clone deep-copies a State via its JSON representation, so a mutate closure // can never scribble on the store's retained copy. diff --git a/internal/state/store_version_test.go b/internal/state/store_version_test.go index a6274cbe..04e2b934 100644 --- a/internal/state/store_version_test.go +++ b/internal/state/store_version_test.go @@ -76,9 +76,9 @@ func TestLoadRefusesUndecodableCurrentState(t *testing.T) { } } -func TestLoadMigratesV4WithoutLosingLiveRounds(t *testing.T) { +func TestLoadMigratesV5WithoutLosingLiveRounds(t *testing.T) { payload := `{ - "v":4, + "v":5, "rev":7, "next_seq":2, "rounds":{ @@ -92,6 +92,17 @@ func TestLoadMigratesV4WithoutLosingLiveRounds(t *testing.T) { } }, "account":{"scope":"owner"}, + "fleet":{ + "scope":"owner,friend", + "repos":"owner/repo,friend/private", + "exclude":"owner/paused", + "required-bots":"coderabbitai[bot],cursor[bot]", + "cobots":"bugbot,codex", + "min-interval":" 2m ", + "inflight-timeout":"10m", + "cobot-bugbot-trigger":"always", + "future-policy":"keep" + }, "future_top_level":{"keep":true} }` st, _, err := versionStore(t, payload).Load(context.Background()) @@ -104,17 +115,101 @@ func TestLoadMigratesV4WithoutLosingLiveRounds(t *testing.T) { if round := st.Round("owner/repo", 7); round == nil || round.Head != "abcdef123" { t.Fatalf("live v4 round was lost during migration: %+v", round) } + if !st.Fleet.SetRequired || strings.Join(st.Fleet.Required, ",") != "coderabbitai[bot],cursor[bot]" { + t.Errorf("required = %v (set %v), want migrated v5 policy", st.Fleet.Required, st.Fleet.SetRequired) + } + if !st.Fleet.SetCoBots || strings.Join(st.Fleet.CoBots, ",") != "bugbot,codex" { + t.Errorf("cobots = %v (set %v), want migrated v5 policy", st.Fleet.CoBots, st.Fleet.SetCoBots) + } + if st.Fleet.MinInterval != "2m" || st.Fleet.Env["CRQ_SCOPE"] != "owner,friend" || + st.Fleet.Env["CRQ_REPOS"] != "owner/repo,friend/private" || + st.Fleet.Env["CRQ_EXCLUDE"] != "owner/paused" || + st.Fleet.Env["CRQ_INFLIGHT_TIMEOUT"] != "10m" || + st.Fleet.Env["CRQ_COBOT_BUGBOT_TRIGGER"] != "always" { + t.Errorf("fleet defaults were not migrated completely: %+v", st.Fleet) + } encoded, err := json.Marshal(st) if err != nil { t.Fatal(err) } if !strings.Contains(string(encoded), "future_top_level") { - t.Fatalf("unknown v4 state was lost during migration: %s", encoded) + t.Fatalf("unknown v5 state was lost during migration: %s", encoded) + } + if !strings.Contains(string(encoded), "future-policy") { + t.Fatalf("unknown v5 fleet policy was lost during migration: %s", encoded) + } +} + +func TestLoadMigratesAlreadyNestedV5FleetDefaults(t *testing.T) { + payload := `{ + "v":5, + "rounds":{}, + "fleet":{ + "cobots":["codex"], + "set_cobots":true, + "required":["coderabbitai[bot]"], + "set_required":true, + "min_interval":"3m", + "env":{"CRQ_SETTLE":"45s"}, + "scope":"owner" + } + }` + st, _, err := versionStore(t, payload).Load(context.Background()) + if err != nil { + t.Fatal(err) + } + if st.Version != SchemaVersion || !st.Fleet.SetCoBots || !st.Fleet.SetRequired || + strings.Join(st.Fleet.CoBots, ",") != "codex" || + strings.Join(st.Fleet.Required, ",") != "coderabbitai[bot]" || + st.Fleet.MinInterval != "3m" || st.Fleet.Env["CRQ_SETTLE"] != "45s" || + st.Fleet.Env["CRQ_SCOPE"] != "owner" { + t.Fatalf("nested v5 fleet defaults were not preserved: %+v", st.Fleet) + } +} + +func TestLoadMergesNestedAndFlatV5FleetEnvironment(t *testing.T) { + payload := `{ + "v":5, + "rounds":{}, + "fleet":{ + "env":{ + "CRQ_SETTLE":"45s", + "CRQ_SCOPE":"nested-owner", + "FUTURE_SETTING":{"mode":"careful"} + }, + "scope":"flat-owner", + "inflight-timeout":"10m" + } + }` + st, _, err := versionStore(t, payload).Load(context.Background()) + if err != nil { + t.Fatal(err) + } + if st.Fleet.Env["CRQ_SETTLE"] != "45s" || + st.Fleet.Env["CRQ_SCOPE"] != "flat-owner" || + st.Fleet.Env["CRQ_INFLIGHT_TIMEOUT"] != "10m" { + t.Fatalf("mixed-shape v5 environment was not merged: %+v", st.Fleet.Env) + } + encoded, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + var roundTrip struct { + Fleet struct { + Env map[string]json.RawMessage `json:"env"` + } `json:"fleet"` + } + if err := json.Unmarshal(encoded, &roundTrip); err != nil { + t.Fatal(err) + } + if got := string(roundTrip.Fleet.Env["FUTURE_SETTING"]); got != `{"mode":"careful"}` { + t.Fatalf("future nested environment member = %s, want it preserved", got) } } // An OLDER payload is genuinely obsolete: crq is pre-release, there is no -// migration, and a v3 state describes a world this binary cannot act on. +// multi-version migration, and a v3 state describes a world this binary cannot +// act on safely. func TestLoadReinitializesOlderState(t *testing.T) { st, _, err := versionStore(t, `{"v":3,"queue":[{"repo":"owner/repo","pr":7}]}`).Load(context.Background()) if err != nil { @@ -124,6 +219,6 @@ func TestLoadReinitializesOlderState(t *testing.T) { t.Errorf("version = %d, want a fresh v%d", st.Version, SchemaVersion) } if len(st.Rounds) != 0 { - t.Errorf("rounds = %v, want none carried from v2", st.Rounds) + t.Errorf("rounds = %v, want none carried from v3", st.Rounds) } } diff --git a/internal/state/tolerant.go b/internal/state/tolerant.go index eb04aa04..98d561d7 100644 --- a/internal/state/tolerant.go +++ b/internal/state/tolerant.go @@ -18,16 +18,26 @@ import ( // Unknown members are therefore carried by default. A load keeps whatever it // did not recognise, a save puts it back, and a field this binary has never // heard of survives a foreign write untouched. A schema bump is reserved for a -// compatibility fence: newer state is refused by older binaries, as v5 requires -// so v4 pumping clients cannot ignore state-backed fleet policy. +// compatibility fence: newer state is refused by older binaries, as v4 requires +// so v3 pumping clients cannot ignore administrative holds. // unknownFields holds JSON members this binary has no field for, verbatim. type unknownFields map[string]json.RawMessage var ( - fireSlotFields = jsonFieldNames(reflect.TypeOf(FireSlot{})) - roundFields = jsonFieldNames(reflect.TypeOf(Round{})) - stateFields = jsonFieldNames(reflect.TypeOf(State{})) + fireSlotFields = jsonFieldNames(reflect.TypeOf(FireSlot{})) + roundFields = jsonFieldNames(reflect.TypeOf(Round{})) + stateFields = jsonFieldNames(reflect.TypeOf(State{})) + fleetFields = jsonFieldNames(reflect.TypeOf(FleetDefaults{})) + solverFields = jsonFieldNames(reflect.TypeOf(SolverSettings{})) + repoReviewersFields = jsonFieldNames(reflect.TypeOf(RepoReviewers{})) + repoAutofixFields = jsonFieldNames(reflect.TypeOf(RepoAutofixSwitch{})) + enrollmentFields = jsonFieldNames(reflect.TypeOf(RepoEnrollment{})) + accountFields = jsonFieldNames(reflect.TypeOf(AccountQuota{})) + coBotFields = jsonFieldNames(reflect.TypeOf(CoBotRound{})) + dispatchFields = jsonFieldNames(reflect.TypeOf(DispatchClaim{})) + hostReportFields = jsonFieldNames(reflect.TypeOf(HostReport{})) + toolReportFields = jsonFieldNames(reflect.TypeOf(ToolReport{})) ) // UnmarshalJSON decodes a fire slot and remembers anything it did not recognise. @@ -98,6 +108,311 @@ func (s State) MarshalJSON() ([]byte, error) { return mergeUnknown(plain(s), s.unknown) } +// Nesting matters: State recognises "fleet" and hands the whole object to an +// ordinary decoder, so its top-level carrier never sees a member added INSIDE +// the record. Without their own round trip, a newer binary's fleet or solver +// setting is dropped the first time an older one rewrites state — which is the +// rolling-upgrade guarantee the record's own documentation makes. + +// UnmarshalJSON decodes fleet defaults and remembers anything it did not recognise. +func (f *FleetDefaults) UnmarshalJSON(raw []byte) error { + type plain FleetDefaults + decodable := raw + var object map[string]json.RawMessage + var envUnknown unknownFields + if err := json.Unmarshal(raw, &object); err != nil { + return err + } + if envRaw, ok := object["env"]; ok { + var nested map[string]json.RawMessage + if err := json.Unmarshal(envRaw, &nested); err != nil { + return err + } + known := make(map[string]string) + for key, valueRaw := range nested { + var value string + if err := json.Unmarshal(valueRaw, &value); err != nil { + if envUnknown == nil { + envUnknown = unknownFields{} + } + envUnknown[key] = valueRaw + continue + } + known[key] = value + } + knownRaw, err := json.Marshal(known) + if err != nil { + return err + } + object["env"] = knownRaw + decodable, err = json.Marshal(object) + if err != nil { + return err + } + } + var decoded plain + if err := json.Unmarshal(decodable, &decoded); err != nil { + return err + } + unknown, err := captureUnknown(raw, fleetFields) + if err != nil { + return err + } + *f = FleetDefaults(decoded) + f.envUnknown = envUnknown + f.unknown = unknown + return nil +} + +// MarshalJSON writes fleet defaults back with the members it did not recognise intact. +func (f FleetDefaults) MarshalJSON() ([]byte, error) { + type plain FleetDefaults + raw, err := mergeUnknown(plain(f), f.unknown) + if err != nil || len(f.envUnknown) == 0 { + return raw, err + } + var object map[string]json.RawMessage + if err := json.Unmarshal(raw, &object); err != nil { + return nil, err + } + nested := make(map[string]json.RawMessage, len(f.Env)+len(f.envUnknown)) + for key, valueRaw := range f.envUnknown { + nested[key] = valueRaw + } + for key, value := range f.Env { + valueRaw, err := json.Marshal(value) + if err != nil { + return nil, err + } + nested[key] = valueRaw + } + envRaw, err := json.Marshal(nested) + if err != nil { + return nil, err + } + object["env"] = envRaw + return json.Marshal(object) +} + +// UnmarshalJSON decodes solver settings and remembers anything it did not recognise. +func (s *SolverSettings) UnmarshalJSON(raw []byte) error { + type plain SolverSettings + var decoded plain + if err := json.Unmarshal(raw, &decoded); err != nil { + return err + } + unknown, err := captureUnknown(raw, solverFields) + if err != nil { + return err + } + *s = SolverSettings(decoded) + s.unknown = unknown + return nil +} + +// MarshalJSON writes solver settings back with the members it did not recognise intact. +func (s SolverSettings) MarshalJSON() ([]byte, error) { + type plain SolverSettings + return mergeUnknown(plain(s), s.unknown) +} + +// UnmarshalJSON decodes one repository's reviewer override and remembers +// anything it did not recognise. The map around it is not enough: State +// recognises "repos" by name, so only the record itself can carry a member a +// newer binary added inside it. +func (r *RepoReviewers) UnmarshalJSON(raw []byte) error { + type plain RepoReviewers + var decoded plain + if err := json.Unmarshal(raw, &decoded); err != nil { + return err + } + unknown, err := captureUnknown(raw, repoReviewersFields) + if err != nil { + return err + } + *r = RepoReviewers(decoded) + r.unknown = unknown + return nil +} + +// MarshalJSON writes a reviewer override back with the members it did not recognise intact. +func (r RepoReviewers) MarshalJSON() ([]byte, error) { + type plain RepoReviewers + return mergeUnknown(plain(r), r.unknown) +} + +// UnmarshalJSON decodes one repository's enrollment record and remembers +// anything it did not recognise. Same nesting argument as the reviewer +// override: "enrolled" is a member every binary that has the map recognises, +// so only the record itself can carry a member a newer one added inside it. +func (e *RepoEnrollment) UnmarshalJSON(raw []byte) error { + type plain RepoEnrollment + var decoded plain + if err := json.Unmarshal(raw, &decoded); err != nil { + return err + } + unknown, err := captureUnknown(raw, enrollmentFields) + if err != nil { + return err + } + *e = RepoEnrollment(decoded) + e.unknown = unknown + return nil +} + +// MarshalJSON writes an enrollment record back with the members it did not recognise intact. +func (e RepoEnrollment) MarshalJSON() ([]byte, error) { + type plain RepoEnrollment + return mergeUnknown(plain(e), e.unknown) +} + +// UnmarshalJSON decodes a repository autofix switch and remembers anything it +// did not recognise. +func (s *RepoAutofixSwitch) UnmarshalJSON(raw []byte) error { + type plain RepoAutofixSwitch + var decoded plain + if err := json.Unmarshal(raw, &decoded); err != nil { + return err + } + unknown, err := captureUnknown(raw, repoAutofixFields) + if err != nil { + return err + } + *s = RepoAutofixSwitch(decoded) + s.unknown = unknown + return nil +} + +// MarshalJSON writes an autofix switch back with unknown members intact. +func (s RepoAutofixSwitch) MarshalJSON() ([]byte, error) { + type plain RepoAutofixSwitch + return mergeUnknown(plain(s), s.unknown) +} + +// The same nesting argument applies to records that have been recognised for +// longer, and it is sharper there: "account", "cobots" and "dispatches" are +// members every schema-v4 binary knows, so an older one hands each to an +// ordinary decoder and drops whatever a newer binary added INSIDE it — the +// weekly fire log, a co-reviewer's answer, a session's findings count. The +// containing map being new is what makes a record safe to leave without one; +// none of these is new. + +// UnmarshalJSON decodes the account quota and remembers anything it did not recognise. +func (a *AccountQuota) UnmarshalJSON(raw []byte) error { + type plain AccountQuota + var decoded plain + if err := json.Unmarshal(raw, &decoded); err != nil { + return err + } + unknown, err := captureUnknown(raw, accountFields) + if err != nil { + return err + } + *a = AccountQuota(decoded) + a.unknown = unknown + return nil +} + +// MarshalJSON writes the account quota back with the members it did not recognise intact. +func (a AccountQuota) MarshalJSON() ([]byte, error) { + type plain AccountQuota + return mergeUnknown(plain(a), a.unknown) +} + +// UnmarshalJSON decodes one co-reviewer's round bookkeeping and remembers +// anything it did not recognise. +func (c *CoBotRound) UnmarshalJSON(raw []byte) error { + type plain CoBotRound + var decoded plain + if err := json.Unmarshal(raw, &decoded); err != nil { + return err + } + unknown, err := captureUnknown(raw, coBotFields) + if err != nil { + return err + } + *c = CoBotRound(decoded) + c.unknown = unknown + return nil +} + +// MarshalJSON writes a co-reviewer's bookkeeping back with the members it did not recognise intact. +func (c CoBotRound) MarshalJSON() ([]byte, error) { + type plain CoBotRound + return mergeUnknown(plain(c), c.unknown) +} + +// UnmarshalJSON decodes one dispatch claim and remembers anything it did not recognise. +func (c *DispatchClaim) UnmarshalJSON(raw []byte) error { + type plain DispatchClaim + var decoded plain + if err := json.Unmarshal(raw, &decoded); err != nil { + return err + } + unknown, err := captureUnknown(raw, dispatchFields) + if err != nil { + return err + } + *c = DispatchClaim(decoded) + c.unknown = unknown + return nil +} + +// MarshalJSON writes a dispatch claim back with the members it did not recognise intact. +func (c DispatchClaim) MarshalJSON() ([]byte, error) { + type plain DispatchClaim + return mergeUnknown(plain(c), c.unknown) +} + +// UnmarshalJSON decodes one host's self-report and remembers anything it did +// not recognise. Same nesting argument as every record above: "host_reports" is +// a member this binary knows, so it hands each report to an ordinary decoder +// and only the report itself can carry a member a newer binary added inside it. +func (r *HostReport) UnmarshalJSON(raw []byte) error { + type plain HostReport + var decoded plain + if err := json.Unmarshal(raw, &decoded); err != nil { + return err + } + unknown, err := captureUnknown(raw, hostReportFields) + if err != nil { + return err + } + *r = HostReport(decoded) + r.unknown = unknown + return nil +} + +// MarshalJSON writes a host report back with the members it did not recognise intact. +func (r HostReport) MarshalJSON() ([]byte, error) { + type plain HostReport + return mergeUnknown(plain(r), r.unknown) +} + +// UnmarshalJSON decodes one tool probe and remembers anything it did not +// recognise. Nested one level deeper again — inside "tools" and "role_tools" — +// so neither the report's carrier nor the map around it can speak for it. +func (t *ToolReport) UnmarshalJSON(raw []byte) error { + type plain ToolReport + var decoded plain + if err := json.Unmarshal(raw, &decoded); err != nil { + return err + } + unknown, err := captureUnknown(raw, toolReportFields) + if err != nil { + return err + } + *t = ToolReport(decoded) + t.unknown = unknown + return nil +} + +// MarshalJSON writes a tool probe back with the members it did not recognise intact. +func (t ToolReport) MarshalJSON() ([]byte, error) { + type plain ToolReport + return mergeUnknown(plain(t), t.unknown) +} + // captureUnknown returns the members of raw that known does not name. func captureUnknown(raw []byte, known map[string]bool) (unknownFields, error) { var all map[string]json.RawMessage @@ -117,6 +432,28 @@ func captureUnknown(raw []byte, known map[string]bool) (unknownFields, error) { return out, nil } +// carryUnknown folds prev's carried members into next's, for a record that is +// REBUILT rather than edited. +// +// Round-tripping is only half the guarantee: a value a writer constructs from +// scratch — a host's self-report, a repository's enrollment — starts with an +// empty carrier however carefully the loaded one preserved its members, so the +// next save erased them. next wins where both carry a member: it is the newer +// read of the two. +func carryUnknown(next, prev unknownFields) unknownFields { + if len(prev) == 0 { + return next + } + out := make(unknownFields, len(prev)+len(next)) + for name, value := range prev { + out[name] = value + } + for name, value := range next { + out[name] = value + } + return out +} + // mergeUnknown marshals value and adds the carried members back. // // A carried member never shadows a field this binary owns: if a later build diff --git a/internal/state/tolerant_test.go b/internal/state/tolerant_test.go index 1aa628a8..85f9be8b 100644 --- a/internal/state/tolerant_test.go +++ b/internal/state/tolerant_test.go @@ -68,6 +68,57 @@ func TestUnknownRoundFieldsSurviveARewrite(t *testing.T) { } } +func TestRenewedDispatchClaimPreservesUnknownFields(t *testing.T) { + var round Round + if err := json.Unmarshal([]byte(`{ + "repo":"owner/repo","pr":1,"head":"abcdef123","phase":"queued", + "dispatch":{ + "host":"old","token":"old","at":"2026-07-26T10:00:00Z", + "heartbeat":"2026-07-26T10:00:00Z", + "future_dispatch_policy":{"mode":"audit"} + } + }`), &round); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + if ok, why := round.ClaimDispatch("new", "new", now, 3); !ok { + t.Fatal(why) + } + raw, err := json.Marshal(round.Dispatch) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"future_dispatch_policy":{"mode":"audit"}`) { + t.Fatalf("renewed claim dropped its future field: %s", raw) + } +} + +func TestSetEnrollmentCarriesUnknownFields(t *testing.T) { + var st State + if err := json.Unmarshal([]byte(`{ + "v": 4, + "enrolled": { + "owner/repo": {"enabled": true, "future_enrollment_policy": {"mode": "audit"}} + } + }`), &st); err != nil { + t.Fatal(err) + } + + st.SetEnrollment("owner/repo", RepoEnrollment{Enabled: false, Reason: "paused"}) + out, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + var back map[string]any + if err := json.Unmarshal(out, &back); err != nil { + t.Fatal(err) + } + enrolled := back["enrolled"].(map[string]any)["owner/repo"].(map[string]any) + if _, ok := enrolled["future_enrollment_policy"]; !ok { + t.Fatalf("SetEnrollment dropped a future field: %s", out) + } +} + // FireSlot is nested beneath a known State field, so top-level tolerance cannot // carry additions made to the slot itself. func TestUnknownFireSlotFieldsSurviveARewrite(t *testing.T) { @@ -246,3 +297,412 @@ func TestRewritePreservesNormalizeAndLegacyFolding(t *testing.T) { t.Errorf("the legacy dual-write must still be emitted:\n%s", out) } } + +// The fleet record and the solver record nested inside it are extensible by +// design — their own documentation promises a field a newer binary adds +// survives an older one reading and rewriting state. They are nested beneath a +// member State recognises, so the top-level carrier never sees them and only +// their own round trip can keep that promise. +func TestUnknownFleetFieldsSurviveARewrite(t *testing.T) { + foreign := `{ + "v": 3, "rev": 7, "next_seq": 9, + "fleet": { + "min_interval": "90s", + "future_pacing": {"burst": 3}, + "solver": {"model": "opus", "future_solver_flag": "sandbox"} + }, + "repo_solver": { + "owner/repo": {"effort": "high", "future_repo_flag": true} + }, + "account": {"scope": "owner"} + }` + + var st State + if err := json.Unmarshal([]byte(foreign), &st); err != nil { + t.Fatal(err) + } + if st.Fleet.MinInterval != "90s" || st.Fleet.Solver.Model != "opus" { + t.Fatalf("known fields must still decode: %+v", st.Fleet) + } + + out, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + var back map[string]any + if err := json.Unmarshal(out, &back); err != nil { + t.Fatal(err) + } + fleet, _ := back["fleet"].(map[string]any) + if fleet == nil { + t.Fatalf("the fleet record vanished:\n%s", out) + } + pacing, _ := fleet["future_pacing"].(map[string]any) + if pacing == nil || pacing["burst"] != float64(3) { + t.Errorf("carried fleet member lost its content: %#v", fleet["future_pacing"]) + } + solver, _ := fleet["solver"].(map[string]any) + if solver == nil || solver["future_solver_flag"] != "sandbox" { + t.Errorf("carried solver member was dropped: %#v", fleet["solver"]) + } + repos, _ := back["repo_solver"].(map[string]any) + own, _ := repos["owner/repo"].(map[string]any) + if own == nil || own["future_repo_flag"] != true { + t.Errorf("a repository's own solver record dropped a member: %#v", own) + } +} + +// The records that have been recognised the LONGEST are the ones this matters +// most for: every schema-v4 binary knows "account", "cobots" and "dispatches", +// so each hands the value to an ordinary decoder and drops whatever a newer +// binary put inside it — the weekly fire log, a co-reviewer's answer, a +// session's own detail. Being old is not the same as being closed. +func TestUnknownMembersOfLongKnownRecordsSurviveARewrite(t *testing.T) { + foreign := `{ + "v": 4, "rev": 3, "next_seq": 2, + "rounds": { + "owner/repo#1": { + "repo": "owner/repo", "pr": 1, "head": "abcdef123", "seq": 1, + "phase": "reviewing", "enqueued_at": "2026-07-26T12:00:00Z", + "cobots": { + "chatgpt-codex-connector": { + "command_id": 7, "answered_at": "2026-07-26T12:03:00Z", + "future_bot_detail": {"verdict": "clean"} + } + } + } + }, + "dispatches": { + "owner/repo#1": {"host": "mac", "attempts": 1, "future_session_detail": "pid=42"} + }, + "account": { + "scope": "owner", + "fires": ["2026-07-26T11:00:00Z"], + "future_quota_detail": {"window": "week"} + } + }` + + var st State + if err := json.Unmarshal([]byte(foreign), &st); err != nil { + t.Fatal(err) + } + round := st.Rounds["owner/repo#1"] + if len(st.Account.Fires) != 1 || round.Co(codexCoBotKey).CommandID != 7 { + t.Fatalf("known fields must still decode: %+v", st.Account) + } + + out, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + var back map[string]any + if err := json.Unmarshal(out, &back); err != nil { + t.Fatal(err) + } + account, _ := back["account"].(map[string]any) + quota, _ := account["future_quota_detail"].(map[string]any) + if quota == nil || quota["window"] != "week" { + t.Errorf("the account quota dropped a member: %#v", account) + } + rounds, _ := back["rounds"].(map[string]any) + written, _ := rounds["owner/repo#1"].(map[string]any) + cobots, _ := written["cobots"].(map[string]any) + codex, _ := cobots["chatgpt-codex-connector"].(map[string]any) + detail, _ := codex["future_bot_detail"].(map[string]any) + if detail == nil || detail["verdict"] != "clean" { + t.Errorf("a co-reviewer's entry dropped a member: %#v", codex) + } + dispatches, _ := back["dispatches"].(map[string]any) + claim, _ := dispatches["owner/repo#1"].(map[string]any) + if claim == nil || claim["future_session_detail"] != "pid=42" { + t.Errorf("a dispatch claim dropped a member: %#v", claim) + } +} + +// A repository's reviewer override is nested the same way the fleet record is: +// State recognises "repos" by name and hands each value to an ordinary decoder, +// so only the record itself can carry a member a newer binary added inside it. +// Without this, an old binary erased PrimaryOff on its next write and the +// repository silently resumed metered primary reviews. +func TestUnknownRepoOverrideFieldsSurviveARewrite(t *testing.T) { + foreign := `{ + "v": 4, "rev": 3, "next_seq": 1, + "repos": { + "owner/repo": { + "cobots": ["codex"], "set_cobots": true, + "primary_off": true, + "future_reviewer_flag": {"mode": "strict"} + } + }, + "account": {"scope": "owner"} + }` + + var st State + if err := json.Unmarshal([]byte(foreign), &st); err != nil { + t.Fatal(err) + } + ov, ok := st.RepoOverride("owner/repo") + if !ok || !ov.PrimaryOff || !ov.SetCoBots { + t.Fatalf("known fields must still decode: %+v", ov) + } + + out, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + var back map[string]any + if err := json.Unmarshal(out, &back); err != nil { + t.Fatal(err) + } + repos, _ := back["repos"].(map[string]any) + own, _ := repos["owner/repo"].(map[string]any) + if own == nil { + t.Fatalf("the override vanished:\n%s", out) + } + if own["primary_off"] != true { + t.Errorf("primary_off was not written back: %#v", own) + } + carried, _ := own["future_reviewer_flag"].(map[string]any) + if carried == nil || carried["mode"] != "strict" { + t.Errorf("a member this binary does not know was dropped: %#v", own["future_reviewer_flag"]) + } +} + +func TestUnknownEnrollmentFieldsSurviveARewrite(t *testing.T) { + foreign := `{ + "v": 4, "rev": 3, "next_seq": 1, + "enrolled": { + "owner/repo": { + "enabled": false, "reason": "paused for the quarter", + "future_enroll_flag": {"until": "2030-01-01"} + } + }, + "account": {"scope": "owner"} + }` + + var st State + if err := json.Unmarshal([]byte(foreign), &st); err != nil { + t.Fatal(err) + } + rec, ok := st.Enrollment("owner/repo") + if !ok || rec.Enabled || rec.Reason != "paused for the quarter" { + t.Fatalf("known fields must still decode: %+v", rec) + } + + out, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + var back map[string]any + if err := json.Unmarshal(out, &back); err != nil { + t.Fatal(err) + } + enrolled, _ := back["enrolled"].(map[string]any) + own, _ := enrolled["owner/repo"].(map[string]any) + if own == nil { + t.Fatalf("the enrollment record vanished:\n%s", out) + } + if own["reason"] != "paused for the quarter" { + t.Errorf("reason was not written back: %#v", own) + } + carried, _ := own["future_enroll_flag"].(map[string]any) + if carried == nil || carried["until"] != "2030-01-01" { + t.Errorf("a member this binary does not know was dropped: %#v", own["future_enroll_flag"]) + } +} + +func TestUnknownAutofixSwitchFieldsSurviveReplacement(t *testing.T) { + foreign := `{ + "v": 6, "rev": 3, "next_seq": 1, + "repo_autofix": { + "owner/repo": { + "enabled": true, + "future_fix_policy": {"sandbox": "strict"} + } + } + }` + var st State + if err := json.Unmarshal([]byte(foreign), &st); err != nil { + t.Fatal(err) + } + st.SetAutofixSwitch("owner/repo", RepoAutofixSwitch{Enabled: false, Reason: "paused"}) + out, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + var back map[string]any + if err := json.Unmarshal(out, &back); err != nil { + t.Fatal(err) + } + switches, _ := back["repo_autofix"].(map[string]any) + sw, _ := switches["owner/repo"].(map[string]any) + future, _ := sw["future_fix_policy"].(map[string]any) + if sw["enabled"] != false || future["sandbox"] != "strict" { + t.Fatalf("replaced switch lost known or future fields: %#v", sw) + } +} + +// A host report is nested twice over: State recognises "host_reports" and hands +// each record to an ordinary decoder, which in turn hands each tool probe to +// another. So neither the map nor the report above it can carry a member a +// newer binary added inside them — only the records themselves can. +func TestUnknownHostReportFieldsSurviveARewrite(t *testing.T) { + foreign := `{ + "v": 4, "rev": 3, "next_seq": 1, + "host_reports": { + "atlas": { + "host": "atlas", "version": "1.2.3", "caps": 6, + "roles": ["autofix"], + "tools": [{"name": "claude", "path": "/usr/bin/claude", "future_tool_flag": "sandboxed"}], + "at": "2026-07-26T12:00:00Z", + "future_host_flag": {"gpu": true} + } + }, + "account": {"scope": "owner"} + }` + + var st State + if err := json.Unmarshal([]byte(foreign), &st); err != nil { + t.Fatal(err) + } + rec := st.HostReports["atlas"] + if rec.Version != "1.2.3" || rec.Caps != 6 || len(rec.Tools) != 1 || rec.Tools[0].Path != "/usr/bin/claude" { + t.Fatalf("known fields must still decode: %+v", rec) + } + + out, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + var back map[string]any + if err := json.Unmarshal(out, &back); err != nil { + t.Fatal(err) + } + reports, _ := back["host_reports"].(map[string]any) + atlas, _ := reports["atlas"].(map[string]any) + if atlas == nil { + t.Fatalf("the host report vanished:\n%s", out) + } + carried, _ := atlas["future_host_flag"].(map[string]any) + if carried == nil || carried["gpu"] != true { + t.Errorf("a report member this binary does not know was dropped: %#v", atlas["future_host_flag"]) + } + tools, _ := atlas["tools"].([]any) + if len(tools) != 1 { + t.Fatalf("the tool probes vanished: %#v", atlas["tools"]) + } + tool, _ := tools[0].(map[string]any) + if tool["future_tool_flag"] != "sandboxed" { + t.Errorf("a tool member this binary does not know was dropped: %#v", tool) + } +} + +// Round-tripping is only half of it. A host's self-report is CONSTRUCTED fresh +// on every heartbeat, so however carefully the load preserved a newer binary's +// members, the next report by an older service on the same machine replaced the +// record with a value carrying none — erasing them on a timer, at both levels of +// nesting. +func TestAnOlderHeartbeatKeepsANewerBinarysHostMembers(t *testing.T) { + foreign := `{ + "v": 4, "rev": 3, "next_seq": 1, + "host_reports": { + "atlas": { + "host": "atlas", "version": "9.9.9", "caps": 99, + "roles": ["autofix"], + "role_seen": {"autofix": "2026-07-26T11:55:00Z"}, + "role_tools": {"autofix": [{"name": "claude", "path": "/new/claude", "future_tool_flag": "sandboxed"}]}, + "at": "2026-07-26T11:55:00Z", + "future_host_flag": {"gpu": true} + } + }, + "account": {"scope": "owner"} + }` + + var st State + if err := json.Unmarshal([]byte(foreign), &st); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + // The same machine's older service, reporting what IT can see. + st.SetHostReport(HostReport{ + Host: "atlas", Version: "1.0.0", Caps: 1, Roles: []string{"autofix"}, + Tools: []ToolReport{{Name: "claude", Path: "/old/claude"}}, + }, now) + + out, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + var back map[string]any + if err := json.Unmarshal(out, &back); err != nil { + t.Fatal(err) + } + reports, _ := back["host_reports"].(map[string]any) + atlas, _ := reports["atlas"].(map[string]any) + if atlas == nil { + t.Fatalf("the host report vanished:\n%s", out) + } + // The probe itself is the reporter's to state — a path that changed changed. + if atlas["version"] != "1.0.0" { + t.Errorf("version = %#v, want the reporting binary's own", atlas["version"]) + } + carried, _ := atlas["future_host_flag"].(map[string]any) + if carried == nil || carried["gpu"] != true { + t.Errorf("a report member this binary does not know was erased by a heartbeat: %#v", atlas) + } + roleTools, _ := atlas["role_tools"].(map[string]any) + probed, _ := roleTools["autofix"].([]any) + if len(probed) != 1 { + t.Fatalf("the role's probes vanished: %#v", roleTools) + } + tool, _ := probed[0].(map[string]any) + if tool["path"] != "/old/claude" { + t.Errorf("path = %#v, want the fresh probe's answer", tool["path"]) + } + if tool["future_tool_flag"] != "sandboxed" { + t.Errorf("a tool member this binary does not know was erased by a re-probe: %#v", tool) + } +} + +// Carrying a member is not enough on its own: a record whose last RECOGNISED +// field is cleared must still read as recorded, or the writer that treats +// "empty" as "delete this" erases the newer binary's setting on the one path +// the round trip exists to survive. +func TestClearingTheLastKnownFieldKeepsACarriedMember(t *testing.T) { + foreign := `{ + "v": 4, "rev": 2, "next_seq": 1, + "fleet": { + "min_interval": "90s", + "future_pacing": {"burst": 3}, + "solver": {"model": "opus", "future_solver_flag": "sandbox"} + } + }` + + var st State + if err := json.Unmarshal([]byte(foreign), &st); err != nil { + t.Fatal(err) + } + if st.Fleet.Solver.Empty() { + t.Error("a solver record carrying a newer binary's member is not empty") + } + if st.Fleet.Empty() { + t.Error("fleet defaults carrying a newer binary's member are not empty") + } + + // An operator clearing every setting THIS build knows, field by field. + fd := st.Fleet + fd.MinInterval = "" + fd.Solver.Model = "" + if fd.Solver.Empty() || fd.Empty() { + t.Fatalf("clearing the known fields must not empty a carrying record: %+v", fd) + } + st.SetFleetDefaults(fd, "atlas", time.Now()) + + out, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), "future_pacing") || !strings.Contains(string(out), "future_solver_flag") { + t.Errorf("a carried member was erased by clearing the fields around it:\n%s", out) + } +} diff --git a/internal/state/writers_test.go b/internal/state/writers_test.go index 6759cb38..d108ed59 100644 --- a/internal/state/writers_test.go +++ b/internal/state/writers_test.go @@ -5,12 +5,6 @@ import ( "time" ) -func TestFleetPolicyCapabilityAdvancesWithNewDecisionKeys(t *testing.T) { - if WriterCaps != 3 || CapsFleetPolicy != WriterCaps { - t.Fatalf("writer caps = %d, fleet caps = %d; want fleet policy fenced at writer capability 3", WriterCaps, CapsFleetPolicy) - } -} - // Sharing a state ref stops an older binary ERASING a new field. It does not // make that binary act on it: it loads the field as unknown JSON, writes it back // untouched, and keeps deciding from its own fleet-wide configuration. So the @@ -99,31 +93,6 @@ func TestLaggingWritersMatchesTheFireSlotOwner(t *testing.T) { } } -// The mirror question: an old binary about to drop a setting it cannot read has -// to know whether the crq that wrote it is still driving the queue. -func TestAdvancedWritersNamesTheNewerDriver(t *testing.T) { - now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) - st := New() - st.Leader = &LeaderLease{Owner: "host=new-mac pid=7", ExpiresAt: now.Add(time.Minute)} - - // A leader running this version is not ahead of it. - st.NoteWriter("host=new-mac pid=7", WriterCaps, now) - if got := st.AdvancedWriters(WriterCaps, now); len(got) != 0 { - t.Errorf("advanced = %v, want none for a leader on this version", got) - } - - st.NoteWriter("host=new-mac pid=7", WriterCaps+1, now) - if got := st.AdvancedWriters(WriterCaps, now); len(got) != 1 || got[0] != "host=new-mac pid=7" { - t.Fatalf("advanced = %v, want the newer leader named", got) - } - - // And only while it is driving: a stale stamp says nothing about now. - st.Leader.ExpiresAt = now - if got := st.AdvancedWriters(WriterCaps, now); len(got) != 0 { - t.Errorf("advanced = %v, want none once the lease has lapsed", got) - } -} - // Reopening a round is not a failed attempt. Moving LastAttemptAt would raise // the adoption floor past a newly required co-reviewer's own unanswered trigger, // so crq would post that bot a second request for the round it is reopening to @@ -147,3 +116,64 @@ func TestReopenKeepsTheAdoptionFloor(t *testing.T) { t.Errorf("LastAttemptAt = %v, want it untouched by a reopen", r.LastAttemptAt) } } + +// The host that consumes solver settings is the autofix watcher, and it holds +// neither the leader lease nor the fire slot — so the acting set LaggingWriters +// builds cannot see it. An old watcher went on dispatching install-time model, +// fork and attempt values while the repository's page reported nobody lagging. +func TestLaggingRoleWritersNamesTheAutofixWatcher(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + st := New() + st.SetHostReport(HostReport{Host: "atlas", Caps: CapsSolver - 1, Roles: []string{"autofix"}}, now) + st.SetHostReport(HostReport{Host: "blue", Caps: CapsSolver, Roles: []string{"autofix"}}, now) + // Running something else here, so its older binary is not this setting's + // problem: it never starts a fix session. + st.SetHostReport(HostReport{Host: "green", Caps: CapsSolver - 1, Roles: []string{"serve"}}, now) + + if got := st.LaggingWriters(CapsSolver, now); len(got) != 0 { + t.Fatalf("lagging writers = %v, want none — no host is driving the queue", got) + } + got := st.LaggingRoleWriters(CapsSolver, now, "autofix") + if len(got) != 1 || got[0] != "atlas" { + t.Fatalf("lagging = %v, want only the old autofix host", got) + } + + // A report that has aged out speaks for nobody: that machine may not be + // running a watcher at all any more. + if got := st.LaggingRoleWriters(CapsSolver, now.Add(2*HostReportTTL), "autofix"); len(got) != 0 { + t.Errorf("lagging = %v, want none once every report is stale", got) + } + + // And a machine lagging in BOTH registers is listed once, under the writer + // identity that says which process it is. + st.Leader = &LeaderLease{Owner: "host=atlas pid=7", ExpiresAt: now.Add(time.Minute)} + got = st.LaggingRoleWriters(CapsSolver, now, "autofix") + if len(got) != 1 || got[0] != "host=atlas pid=7" { + t.Errorf("lagging = %v, want the machine named once", got) + } +} + +// One machine runs its services on two builds for as long as a rolling upgrade +// takes, and each writes the SAME record. Reading the record's own capabilities +// let whichever wrote last answer for both: a current `serve` heartbeat vouched +// for an old `autofix` watcher that ignores the very setting being saved. +func TestLaggingRoleWritersReadsEachRolesOwnCapabilities(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + st := New() + st.SetHostReport(HostReport{Host: "atlas", Caps: CapsSolver - 1, Roles: []string{"autofix"}}, now) + // The upgraded dashboard on the same machine, reporting after it. + st.SetHostReport(HostReport{Host: "atlas", Caps: CapsSolver, Roles: []string{"serve"}}, now) + + if got := st.LaggingRoleWriters(CapsSolver, now, "autofix"); len(got) != 1 || got[0] != "atlas" { + t.Fatalf("lagging = %v, want the machine named for its old watcher", got) + } + // Nothing is claimed about the role that never reported an old binary. + if got := st.LaggingRoleWriters(CapsSolver, now, "serve"); len(got) != 0 { + t.Errorf("lagging = %v, want none — the dashboard here is current", got) + } + // Upgrading the watcher clears it, without the dashboard having to write. + st.SetHostReport(HostReport{Host: "atlas", Caps: CapsSolver, Roles: []string{"autofix"}}, now) + if got := st.LaggingRoleWriters(CapsSolver, now, "autofix"); len(got) != 0 { + t.Errorf("lagging = %v, want none once the watcher itself reports the capability", got) + } +} diff --git a/llms.txt b/llms.txt index 3ba1a143..3a9ebe67 100644 --- a/llms.txt +++ b/llms.txt @@ -211,21 +211,6 @@ convergence never hides a rebuttal you have not answered. Address it or decline reason; the loop clears it once the bot withdraws (or you stop declining and fix it). Ambiguous bot replies surface too — crq errs toward showing a possible rebuttal rather than burying it. -Choose which bots review one project: - -```bash -crq reviewers REPO # effective reviewers + budgets -crq reviewers set REPO --bots codex --required codex # Codex is the only required co-reviewer -crq reviewers clear REPO # fleet default again -``` - -The primary is fleet-wide: an override chooses the CO-reviewers, so CodeRabbit is still triggered -and still spends account quota even when it is left out of `--required`. Leaving it out means the -round does not WAIT for it, not that it is never asked. - -`budget: account` means the reviewer is serialized against the shared CodeRabbit allowance; -`budget: none` means it runs immediately. Stored in the shared state ref, so the daemon and agents -cannot disagree. The primary reviewer is fleet-wide. Run the whole thing unattended — crq watches every open PR and starts a fix session for the ones that need one: @@ -233,8 +218,9 @@ that need one: crq autofix install # prompt + wrapper + service, started; --dry-run to preview ``` -Session output goes to `$CRQ_WORKSPACE/logs///--