Skip to content

Commit 44cf6ad

Browse files
mariusvniekerkclaudewesm
authored
feat: support multiple job IDs in roborev wait (#377)
## Summary - Extend `roborev wait` to accept multiple job IDs/refs, waiting for all concurrently - `roborev wait --job 251 252 253` or `roborev wait 251 252 253` now works - Exit code 1 if any job fails; 0 only if all pass - Goroutines poll in quiet mode to avoid interleaved output ## Test plan - [x] `TestWaitMultipleJobIDs` — all jobs pass, expect exit 0 - [x] `TestWaitMultipleJobIDsOneFails` — one fails, expect exit 1 - [x] `TestWaitMultipleJobIDsValidation` — `--sha` incompatible with multiple args - [x] All 52 existing wait tests still pass - [x] Full suite (3503 tests) passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com>
1 parent 566dbb6 commit 44cf6ad

2 files changed

Lines changed: 375 additions & 4 deletions

File tree

cmd/roborev/wait.go

Lines changed: 151 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66
"strconv"
7+
"sync"
78

89
"github.com/roborev-dev/roborev/internal/git"
910
"github.com/spf13/cobra"
@@ -17,7 +18,7 @@ func waitCmd() *cobra.Command {
1718
)
1819

1920
cmd := &cobra.Command{
20-
Use: "wait [job_id|sha]",
21+
Use: "wait [job_id|sha ...]",
2122
Short: "Wait for an existing review job to complete",
2223
Long: `Wait for an already-running review job to complete, without enqueuing a new one.
2324
@@ -29,17 +30,21 @@ calls wait to block until the result is ready.
2930
The argument can be a job ID (numeric) or a git ref (commit SHA, branch, HEAD).
3031
If no argument is given, defaults to HEAD.
3132
33+
Multiple arguments can be given to wait for several jobs concurrently.
34+
With --job, all arguments are treated as job IDs.
35+
3236
Exit codes:
33-
0 Review completed with verdict PASS
37+
0 All reviews completed with verdict PASS
3438
1 Any failure (FAIL verdict, no job found, job error)
3539
3640
Examples:
3741
roborev wait # Wait for most recent job for HEAD
3842
roborev wait abc123 # Wait for most recent job for commit
3943
roborev wait 42 # Job ID (if "42" is not a valid git ref)
4044
roborev wait --job 42 # Force as job ID
45+
roborev wait --job 10 20 30 # Wait for multiple job IDs
4146
roborev wait --sha HEAD~1 # Wait for job matching HEAD~1`,
42-
Args: cobra.MaximumNArgs(1),
47+
Args: cobra.ArbitraryArgs,
4348
RunE: func(cmd *cobra.Command, args []string) error {
4449
// In quiet mode, suppress cobra's error output
4550
if quiet {
@@ -55,6 +60,11 @@ Examples:
5560
return fmt.Errorf("--job requires a job ID argument")
5661
}
5762

63+
// Multiple args: wait for all concurrently
64+
if len(args) > 1 {
65+
return waitMultiple(cmd, args, forceJobID, quiet)
66+
}
67+
5868
// Resolve the target to a job ID (local validation first,
5969
// daemon contact deferred until actually needed)
6070
var jobID int64
@@ -151,8 +161,145 @@ Examples:
151161
}
152162

153163
cmd.Flags().StringVar(&shaFlag, "sha", "", "git ref to find the most recent job for")
154-
cmd.Flags().BoolVar(&forceJobID, "job", false, "force argument to be treated as job ID")
164+
cmd.Flags().BoolVar(&forceJobID, "job", false, "force arguments to be treated as job IDs")
155165
cmd.Flags().BoolVarP(&quiet, "quiet", "q", false, "suppress output (for use in hooks)")
156166

157167
return cmd
158168
}
169+
170+
// resolvedArg holds locally-validated arg data before daemon contact.
171+
type resolvedArg struct {
172+
jobID int64 // set if arg is a numeric job ID
173+
sha string // set if arg is a git ref (resolved SHA)
174+
ref string // original ref string (for error messages)
175+
}
176+
177+
// waitMultiple resolves multiple args to job IDs and waits for all
178+
// concurrently. Returns exit code 1 if any job fails or has a FAIL
179+
// verdict.
180+
func waitMultiple(
181+
cmd *cobra.Command,
182+
args []string,
183+
forceJobID, quiet bool,
184+
) error {
185+
// Phase 1: Local validation (no daemon contact).
186+
repoRoot, _ := git.GetRepoRoot(".")
187+
resolved := make([]resolvedArg, 0, len(args))
188+
for _, arg := range args {
189+
if forceJobID {
190+
id, err := strconv.ParseInt(arg, 10, 64)
191+
if err != nil || id <= 0 {
192+
return fmt.Errorf("invalid job ID: %s", arg)
193+
}
194+
resolved = append(resolved, resolvedArg{jobID: id})
195+
} else {
196+
// Try git ref first
197+
var sha string
198+
if repoRoot != "" {
199+
if s, err := git.ResolveSHA(repoRoot, arg); err == nil {
200+
sha = s
201+
}
202+
}
203+
if sha != "" {
204+
resolved = append(resolved, resolvedArg{sha: sha, ref: arg})
205+
} else {
206+
id, err := strconv.ParseInt(arg, 10, 64)
207+
if err != nil || id <= 0 {
208+
return fmt.Errorf(
209+
"argument %q is not a valid git ref or job ID",
210+
arg,
211+
)
212+
}
213+
resolved = append(resolved, resolvedArg{jobID: id})
214+
}
215+
}
216+
}
217+
218+
// Phase 2: Ensure daemon is running.
219+
if err := ensureDaemon(); err != nil {
220+
return fmt.Errorf("daemon not running: %w", err)
221+
}
222+
223+
// Phase 3: Resolve git refs to job IDs via daemon.
224+
jobIDs := make([]int64, 0, len(resolved))
225+
for _, r := range resolved {
226+
if r.jobID != 0 {
227+
jobIDs = append(jobIDs, r.jobID)
228+
continue
229+
}
230+
mainRoot, _ := git.GetMainRepoRoot(".")
231+
if mainRoot == "" {
232+
mainRoot = repoRoot
233+
}
234+
job, err := findJobForCommit(mainRoot, r.sha)
235+
if err != nil {
236+
return err
237+
}
238+
if job == nil {
239+
if !quiet {
240+
cmd.Printf("No job found for %s\n", r.ref)
241+
}
242+
cmd.SilenceErrors = true
243+
cmd.SilenceUsage = true
244+
return &exitError{code: 1}
245+
}
246+
jobIDs = append(jobIDs, job.ID)
247+
}
248+
249+
addr := getDaemonAddr()
250+
251+
// Wait for all jobs concurrently.
252+
// Always poll in quiet mode to avoid interleaved output from
253+
// goroutines; we display results serially after all complete.
254+
type result struct {
255+
jobID int64
256+
err error
257+
}
258+
results := make([]result, len(jobIDs))
259+
var wg sync.WaitGroup
260+
for i, id := range jobIDs {
261+
wg.Add(1)
262+
go func(idx int, jobID int64) {
263+
defer wg.Done()
264+
err := waitForJob(cmd, addr, jobID, true)
265+
results[idx] = result{jobID: jobID, err: err}
266+
}(i, id)
267+
}
268+
wg.Wait()
269+
270+
// Report results serially.
271+
var hasErr bool
272+
for _, r := range results {
273+
if r.err == nil {
274+
continue
275+
}
276+
hasErr = true
277+
if !quiet {
278+
switch {
279+
case errors.Is(r.err, ErrJobNotFound):
280+
cmd.Printf("Job %d: no job found\n", r.jobID)
281+
case isExitCode(r.err, 1):
282+
cmd.Printf("Job %d: review has issues\n", r.jobID)
283+
default:
284+
cmd.Printf("Job %d: %v\n", r.jobID, r.err)
285+
}
286+
}
287+
}
288+
289+
if hasErr {
290+
cmd.SilenceErrors = true
291+
cmd.SilenceUsage = true
292+
return &exitError{code: 1}
293+
}
294+
295+
return nil
296+
}
297+
298+
// isExitCode reports whether err is an *exitError with the given code.
299+
func isExitCode(err error, code int) bool {
300+
var e *exitError
301+
if errors.As(err, &e) {
302+
return e.code == code
303+
}
304+
return false
305+
}

0 commit comments

Comments
 (0)