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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ Read `.action`, do exactly that, call it again.
| `done` | converged |
| `blocked` | needs a human; `.reason` says why |

Every rule this used to spell out is now a value crq computes. Drain-before-review is `fix` coming
Every rule this used to spell out is now a value crq computes. Fix-before-review is `fix` coming
before everything else. Hold-the-head is the `hold` action, including the exception where a
CodeRabbit rate-limit degrade releases the head because the queued review will fire on whatever head
exists when the window opens. The wait is `.recheck_after`, derived from the account-quota window,
Expand Down Expand Up @@ -397,12 +397,12 @@ crq feedback <repo> <pr> # current normalized findings as JSON, WITHOUT trigger
crq threads <repo> <pr> # every unresolved thread, outdated included
crq resolve <thread-id> [<thread-id>...] # resolve addressed review threads
crq decline <thread-id> [...] --reason "<why>" [--resolve] # record why a finding is declined
crq drain install # ⭐ unattended: watch every PR and fix what needs fixing
crq watch # what the drain runs: drive open PRs through crq next, one JSON
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 drain # which repositories crq may fix
crq drain off <repo> --reason "<why>" # stop fixing there; watching and reviewing continue
crq drain on <repo> | crq drain default <repo>
crq autofix # which repositories crq may fix
crq autofix off <repo> --reason "<why>" # stop fixing there; watching and reviewing continue
crq autofix on <repo> | crq autofix default <repo>
crq hold <repo> <pr> --reason "<why>" # persistently stop reviews for a PR
crq unhold <repo> <pr> # resume reviews for a held PR
crq hold # list held PRs
Expand Down
85 changes: 43 additions & 42 deletions cmd/crq/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,20 +64,20 @@ func run(ctx context.Context, args []string) int {
return 1
case "preflight":
return preflight(ctx, args[1:])
case "drain":
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 := parseDrainArgs(args[1:]); perr == nil && opts.dryRun {
if opts, perr := parseAutofixArgs(args[1:]); perr == nil && opts.dryRun {
cfg, cerr := crq.LoadConfig()
if cerr != nil {
fatal(cerr)
return 1
}
plan, ierr := crq.DrainPlan(cfg, opts.agent, crq.SplitArgv(opts.agentArgs), opts.repos, true)
plan, ierr := crq.AutofixPlan(cfg, opts.agent, crq.SplitArgv(opts.agentArgs), opts.repos, true)
if ierr != nil {
fatal(ierr)
return 1
Expand Down Expand Up @@ -276,51 +276,51 @@ func run(ctx context.Context, args []string) int {
}
printJSON(result)
return 0
case "drain":
switch sub := drainSubcommand(args[1:]); sub {
case "autofix":
switch sub := autofixSubcommand(args[1:]); sub {
case "?":
fatal(fmt.Errorf("unknown drain subcommand %q (try: crq drain, crq drain on|off|default <repo>, crq drain install)", args[1]))
fatal(fmt.Errorf("unknown autofix subcommand %q (try: crq autofix, crq autofix on|off|default <repo>, crq autofix install)", args[1]))
return 1
case "", "list":
if err := cfg.RequireState(); err != nil {
fatal(err)
return 1
}
settings, derr := service.DrainSettings(ctx)
settings, derr := service.AutofixSettings(ctx)
if derr != nil {
fatal(derr)
return 1
}
printJSON(settings)
return 0
case "on", "off", "default":
rest, reason, ok := parseDrainReason(args[2:])
rest, reason, ok := parseAutofixReason(args[2:])
if !ok || len(rest) != 1 {
fatal(errors.New(`usage: crq drain on|off|default <repo> [--reason "<why>"]`))
fatal(errors.New(`usage: crq autofix on|off|default <repo> [--reason "<why>"]`))
return 1
}
if err := cfg.RequireState(); err != nil {
fatal(err)
return 1
}
if sub == "default" {
cleared, cerr := service.ClearDrainEnabled(ctx, rest[0])
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})
return 0
}
setting, serr := service.SetDrainEnabled(ctx, rest[0], sub == "on", reason)
setting, serr := service.SetAutofixEnabled(ctx, rest[0], sub == "on", reason)
if serr != nil {
fatal(serr)
return 1
}
printJSON(setting)
return 0
}
opts, perr := parseDrainArgs(args[1:])
opts, perr := parseAutofixArgs(args[1:])
if perr != nil {
fatal(perr)
return 1
Expand All @@ -329,7 +329,7 @@ func run(ctx context.Context, args []string) int {
fatal(err)
return 1
}
plan, ierr := service.InstallDrain(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)
if ierr != nil {
fatal(ierr)
return 1
Expand Down Expand Up @@ -626,9 +626,10 @@ USAGE
crq decline <thread-id> [...] --reason "<why>" [--keep-open]
reply on a thread to record why a finding is declined
(resolves it; --keep-open leaves it open)
crq drain install [--agent <path>] [--dry-run] [<repo>...]
crq drain [on|off|default <repo>] which repositories crq may fix (on by default)
install and start the unattended review drain
crq autofix install [--agent <path>] [--dry-run] [<repo>...]
install and start unattended autofix
crq autofix [on|off|default <repo>]
which repositories crq may fix (on by default)
crq watch [--no-dispatch] [--once] [<repo>...] [-- <fix command>]
drive open PRs through crq next; --dispatch starts a
session to fix the ones that need it
Expand Down Expand Up @@ -731,7 +732,7 @@ more of the shared budget — run the daemon.

Review round primitive for humans and agents. crq coordinates the review trigger,
waits for real feedback on the current PR head, and emits one JSON report to stdout.
It returns unresolved findings before queueing a new round, so agents must drain
It returns unresolved findings before queueing a new round, so agents must clear
current feedback before waiting for another review.

Exit codes:
Expand Down Expand Up @@ -797,17 +798,17 @@ 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 "drain":
fmt.Print(`crq drain install [--agent <path>] [--dry-run] [<repo>...]
crq drain (which repositories crq may fix)
crq drain off <repo> [--reason "<why>"]
crq drain on <repo>
crq drain default <repo> (back to the fleet default, which is on)

Install and start the unattended review drain: crq watches every open PR and
case "autofix":
fmt.Print(`crq autofix install [--agent <path>] [--dry-run] [<repo>...]
crq autofix (which repositories crq may fix)
crq autofix off <repo> [--reason "<why>"]
crq autofix on <repo>
crq autofix default <repo> (back to the fleet default, which is on)

Install and start unattended autofix: crq watches every open PR and
starts a fix session for the ones that need one.

Draining is ON for every repository in scope. That is the point of watching: a
Autofix is ON for every repository in scope. That is the point of watching: a
pull request nobody fixes is a queue that reports work and does none. Turn it off
where you do not want crq writing code — a release branch, a repository you are
hand-tuning — and watching continues there regardless, so reviews still arrive
Expand Down Expand Up @@ -841,7 +842,7 @@ line per PR per pass, and start a fix session for each PR whose action is "fix".

Fixing is the default — watching a pull request nobody fixes is a queue that
reports work and does none. Three things turn it off: --no-dispatch for one run,
"crq drain off <repo>" for one repository, and having no fix command configured
"crq autofix off <repo>" for one repository, and having no fix command configured
at all, which observes and says so rather than refusing to start.

A dispatched session runs in a worktree crq checked out at that head, with:
Expand Down Expand Up @@ -969,7 +970,7 @@ stops blocking the 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 drain blocks every future round, leaving
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
Expand Down Expand Up @@ -1776,18 +1777,18 @@ func configPath() string {
return home + "/.config/crq/env"
}

// drainArgs is `crq drain install`'s parsed command line.
type drainArgs struct {
// autofixArgs is `crq autofix install`'s parsed command line.
type autofixArgs struct {
agent string
agentArgs string
dryRun bool
repos []string
}

// parseDrainArgs is shared by the pre-authentication dry-run path and the
// 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 parseDrainArgs(args []string) (drainArgs, error) {
fs := flag.NewFlagSet("drain", flag.ContinueOnError)
func parseAutofixArgs(args []string) (autofixArgs, error) {
fs := flag.NewFlagSet("autofix", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
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")
Expand All @@ -1797,22 +1798,22 @@ func parseDrainArgs(args []string) (drainArgs, error) {
sub, rest = rest[0], rest[1:]
}
if err := fs.Parse(rest); err != nil {
return drainArgs{}, err
return autofixArgs{}, err
}
if sub != "install" {
return drainArgs{}, errors.New("usage: crq drain install [--agent <path>] [--dry-run] [<repo>...]")
return autofixArgs{}, errors.New("usage: crq autofix install [--agent <path>] [--dry-run] [<repo>...]")
}
return drainArgs{agent: *agent, agentArgs: *agentArgs, dryRun: *dryRun, repos: fs.Args()}, nil
return autofixArgs{agent: *agent, agentArgs: *agentArgs, dryRun: *dryRun, repos: fs.Args()}, nil
}

// drainSubcommand names what `crq drain ...` was asked to do. An empty string
// autofixSubcommand names what `crq autofix ...` was asked to do. An empty string
// means the bare command, which lists; "?" means something this command does not
// have.
//
// A typo must not list. `crq drain of owner/name` reading as the bare listing
// would report the repository as drained and leave it drained — an answer to a
// A typo must not list. `crq autofix of owner/name` reading as the bare listing
// would report the repository as fixable and leave it fixable — an answer to a
// question nobody asked, in place of the instruction that was meant.
func drainSubcommand(args []string) string {
func autofixSubcommand(args []string) string {
if len(args) == 0 {
return ""
}
Expand All @@ -1823,10 +1824,10 @@ func drainSubcommand(args []string) string {
return "?"
}

// parseDrainReason splits `<repo>` from an optional --reason value. An
// parseAutofixReason splits `<repo>` from an optional --reason value. An
// unrecognized flag is an error rather than a positional: a typo like --resaon
// must fail loudly, not silently become the repository name.
func parseDrainReason(args []string) (rest []string, reason string, ok bool) {
func parseAutofixReason(args []string) (rest []string, reason string, ok bool) {
for i := 0; i < len(args); i++ {
switch arg := args[i]; {
case arg == "--reason":
Expand Down
14 changes: 9 additions & 5 deletions examples/dispatch/README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# Unattended review drain
# Unattended autofix

`crq watch --dispatch` turns "this PR needs fixing" into a session that fixes it.

```bash
crq drain install # prompt + wrapper + service, started
crq drain install --dry-run # print what it would write first
crq autofix install # prompt + wrapper + service, started
crq autofix install --dry-run # print what it would write first

# a different agent, and its own model/effort settings
crq drain install --agent codex --agent-args '-c model_reasoning_effort=high'
crq autofix install --agent codex --agent-args '-c model_reasoning_effort=high'
```

crq knows how to CALL claude and codex, and nothing about which model either
Expand All @@ -19,6 +19,10 @@ agent on macOS), enables lingering so it survives a logout, and starts it. There
are no files in this directory to copy: the prompt crq installs is embedded in
the binary, so it cannot drift from the one documented here.

On a host that ran the pre-rename `crq drain install`, it also stops and
disables the leftover `crq-drain` service — two watchers scanning the same
fleet would race each other's dispatch claims.

## Two rules the prompt earned the hard way

**Sessions stay on a detached HEAD and push by ref.** crq's worktrees are backed
Expand All @@ -45,6 +49,6 @@ somebody else actively holds stops a session now.

- `$CRQ_WORKSPACE/logs/<owner>/<name>/<pr>-<head>-<time>.log` — each session's own
output, the last five per PR. A failed session also keeps its worktree.
- `~/.local/state/crq/drain.err` — one line per dispatch, naming the session log.
- `~/.local/state/crq/autofix.err` — one line per dispatch, naming the session log.
- The dashboard issue and `crq status --line` — three passes in a row that start
nothing and both say `dispatch failing`, rather than leaving it to be noticed.
Loading