Skip to content

Commit 05b27f8

Browse files
wesmclaude
andauthored
fix: skip PASS-verdict reviews in roborev fix (#382)
## Summary - `roborev fix` now skips reviews with a PASS verdict instead of wastefully invoking an agent on them, matching the existing behavior in `roborev refine` - Both `fixSingleJob` and `runFixBatch` check the verdict (stored or parsed from review output) and auto-mark passing jobs as addressed - Adds `jobVerdict` helper that uses `job.Verdict` when populated, falling back to `storage.ParseVerdict(review.Output)` Closes #372 ## Test plan - [x] `TestJobVerdict` — table-driven: stored P/F, nil fallback, empty fallback - [x] `TestFixSingleJobSkipsPassVerdict` — PASS review returns nil without agent invocation, job marked addressed - [x] `TestFixBatchSkipsPassVerdict` — mixed PASS/FAIL batch: only FAIL job reaches agent, PASS job marked addressed (asserted by recorded job_id) - [x] `go test -race ./cmd/roborev -count=1` — all existing fix tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 44cf6ad commit 05b27f8

File tree

2 files changed

+253
-0
lines changed

2 files changed

+253
-0
lines changed

cmd/roborev/fix.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,15 @@ func firstLine(s string) string {
612612
return truncateString(s, 80)
613613
}
614614

615+
// jobVerdict returns the verdict for a job. Uses the stored verdict
616+
// if available, otherwise parses from the review output.
617+
func jobVerdict(job *storage.ReviewJob, review *storage.Review) string {
618+
if job.Verdict != nil && *job.Verdict != "" {
619+
return *job.Verdict
620+
}
621+
return storage.ParseVerdict(review.Output)
622+
}
623+
615624
func fixSingleJob(cmd *cobra.Command, repoRoot string, jobID int64, opts fixOptions) error {
616625
ctx := cmd.Context()
617626
if ctx == nil {
@@ -634,6 +643,17 @@ func fixSingleJob(cmd *cobra.Command, repoRoot string, jobID int64, opts fixOpti
634643
return fmt.Errorf("fetch review: %w", err)
635644
}
636645

646+
// Skip reviews that passed — no findings to fix
647+
if jobVerdict(job, review) == "P" {
648+
if !opts.quiet {
649+
cmd.Printf("Job %d: review passed, skipping fix\n", jobID)
650+
}
651+
if err := markJobAddressed(serverAddr, jobID); err != nil && !opts.quiet {
652+
cmd.Printf("Warning: could not mark job %d as addressed: %v\n", jobID, err)
653+
}
654+
return nil
655+
}
656+
637657
if !opts.quiet {
638658
cmd.Printf("Job %d analysis output:\n", jobID)
639659
cmd.Println(strings.Repeat("-", 60))
@@ -798,6 +818,15 @@ func runFixBatch(cmd *cobra.Command, jobIDs []int64, branch string, newestFirst
798818
}
799819
continue
800820
}
821+
if jobVerdict(job, review) == "P" {
822+
if !opts.quiet {
823+
cmd.Printf("Skipping job %d (review passed)\n", id)
824+
}
825+
if err := markJobAddressed(serverAddr, id); err != nil && !opts.quiet {
826+
cmd.Printf("Warning: could not mark job %d as addressed: %v\n", id, err)
827+
}
828+
continue
829+
}
801830
entries = append(entries, batchEntry{jobID: id, job: job, review: review})
802831
}
803832

cmd/roborev/fix_test.go

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import (
1111
"os"
1212
"os/exec"
1313
"path/filepath"
14+
"slices"
1415
"strings"
16+
"sync"
1517
"sync/atomic"
1618
"testing"
1719
"time"
@@ -1292,3 +1294,225 @@ func TestFixWorktreeRepoResolution(t *testing.T) {
12921294
}
12931295
})
12941296
}
1297+
1298+
func TestJobVerdict(t *testing.T) {
1299+
pass := "P"
1300+
fail := "F"
1301+
empty := ""
1302+
1303+
tests := []struct {
1304+
name string
1305+
verdict *string
1306+
output string
1307+
want string
1308+
}{
1309+
{
1310+
name: "stored PASS verdict",
1311+
verdict: &pass,
1312+
output: "some output",
1313+
want: "P",
1314+
},
1315+
{
1316+
name: "stored FAIL verdict",
1317+
verdict: &fail,
1318+
output: "No issues found.",
1319+
want: "F",
1320+
},
1321+
{
1322+
name: "nil verdict falls back to parse",
1323+
verdict: nil,
1324+
output: "No issues found.",
1325+
want: "P",
1326+
},
1327+
{
1328+
name: "empty verdict falls back to parse",
1329+
verdict: &empty,
1330+
output: "## Issues\n- Bug in foo.go",
1331+
want: "F",
1332+
},
1333+
}
1334+
1335+
for _, tt := range tests {
1336+
t.Run(tt.name, func(t *testing.T) {
1337+
job := &storage.ReviewJob{Verdict: tt.verdict}
1338+
review := &storage.Review{Output: tt.output}
1339+
got := jobVerdict(job, review)
1340+
if got != tt.want {
1341+
t.Errorf("jobVerdict() = %q, want %q", got, tt.want)
1342+
}
1343+
})
1344+
}
1345+
}
1346+
1347+
func TestFixSingleJobSkipsPassVerdict(t *testing.T) {
1348+
repo := createTestRepo(t, map[string]string{
1349+
"main.go": "package main\n",
1350+
})
1351+
1352+
var addressCalls atomic.Int32
1353+
var agentCalled atomic.Int32
1354+
1355+
ts, _ := newMockServer(t, MockServerOpts{
1356+
ReviewOutput: "No issues found.",
1357+
OnJobs: func(w http.ResponseWriter, r *http.Request) {
1358+
writeJSON(w, map[string]any{
1359+
"jobs": []storage.ReviewJob{{
1360+
ID: 99,
1361+
Status: storage.JobStatusDone,
1362+
Agent: "test",
1363+
}},
1364+
})
1365+
},
1366+
})
1367+
// Wrap the server to track address calls
1368+
wrapper := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1369+
if r.URL.Path == "/api/review/address" {
1370+
addressCalls.Add(1)
1371+
w.WriteHeader(http.StatusOK)
1372+
return
1373+
}
1374+
// Proxy to the original mock
1375+
proxy, err := http.NewRequest(r.Method, ts.URL+r.URL.String(), r.Body)
1376+
if err != nil {
1377+
http.Error(w, err.Error(), http.StatusInternalServerError)
1378+
return
1379+
}
1380+
resp, err := http.DefaultClient.Do(proxy)
1381+
if err != nil {
1382+
http.Error(w, err.Error(), http.StatusBadGateway)
1383+
return
1384+
}
1385+
defer resp.Body.Close()
1386+
w.WriteHeader(resp.StatusCode)
1387+
io.Copy(w, resp.Body)
1388+
}))
1389+
t.Cleanup(wrapper.Close)
1390+
patchServerAddr(t, wrapper.URL)
1391+
1392+
cmd, output := newTestCmd(t)
1393+
1394+
// Use a fake agent that tracks invocations
1395+
agent.Register(&agent.FakeAgent{
1396+
NameStr: "test-pass-skip",
1397+
ReviewFn: func(_ context.Context, _, _, _ string, _ io.Writer) (string, error) {
1398+
agentCalled.Add(1)
1399+
return "", nil
1400+
},
1401+
})
1402+
t.Cleanup(func() { agent.Unregister("test-pass-skip") })
1403+
1404+
opts := fixOptions{agentName: "test-pass-skip"}
1405+
1406+
err := fixSingleJob(cmd, repo.Dir, 99, opts)
1407+
if err != nil {
1408+
t.Fatalf("fixSingleJob: %v", err)
1409+
}
1410+
1411+
outputStr := output.String()
1412+
if !strings.Contains(outputStr, "review passed, skipping fix") {
1413+
t.Errorf("expected skip message, got:\n%s", outputStr)
1414+
}
1415+
if agentCalled.Load() != 0 {
1416+
t.Error("agent should not have been invoked for passing review")
1417+
}
1418+
if addressCalls.Load() != 1 {
1419+
t.Errorf("expected 1 address call, got %d", addressCalls.Load())
1420+
}
1421+
}
1422+
1423+
func TestFixBatchSkipsPassVerdict(t *testing.T) {
1424+
repo := createTestRepo(t, map[string]string{
1425+
"main.go": "package main\n",
1426+
})
1427+
1428+
var mu sync.Mutex
1429+
var addressedJobIDs []int64
1430+
1431+
passVerdict := "P"
1432+
1433+
_ = newMockDaemonBuilder(t).
1434+
WithHandler("/api/jobs", func(w http.ResponseWriter, r *http.Request) {
1435+
q := r.URL.Query()
1436+
if q.Get("id") == "10" {
1437+
writeJSON(w, map[string]any{
1438+
"jobs": []storage.ReviewJob{{
1439+
ID: 10,
1440+
Status: storage.JobStatusDone,
1441+
Agent: "test",
1442+
Verdict: &passVerdict,
1443+
}},
1444+
})
1445+
} else if q.Get("id") == "20" {
1446+
writeJSON(w, map[string]any{
1447+
"jobs": []storage.ReviewJob{{
1448+
ID: 20,
1449+
Status: storage.JobStatusDone,
1450+
Agent: "test",
1451+
}},
1452+
})
1453+
} else {
1454+
writeJSON(w, map[string]any{
1455+
"jobs": []storage.ReviewJob{},
1456+
"has_more": false,
1457+
})
1458+
}
1459+
}).
1460+
WithHandler("/api/review", func(w http.ResponseWriter, r *http.Request) {
1461+
jobID := r.URL.Query().Get("job_id")
1462+
if jobID == "10" {
1463+
writeJSON(w, storage.Review{
1464+
JobID: 10,
1465+
Output: "No issues found.",
1466+
})
1467+
} else {
1468+
writeJSON(w, storage.Review{
1469+
JobID: 20,
1470+
Output: "## Issues\n- Bug in foo.go",
1471+
})
1472+
}
1473+
}).
1474+
WithHandler("/api/review/address", func(w http.ResponseWriter, r *http.Request) {
1475+
var body struct {
1476+
JobID int64 `json:"job_id"`
1477+
}
1478+
if err := json.NewDecoder(r.Body).Decode(&body); err == nil {
1479+
mu.Lock()
1480+
addressedJobIDs = append(addressedJobIDs, body.JobID)
1481+
mu.Unlock()
1482+
}
1483+
w.WriteHeader(http.StatusOK)
1484+
}).
1485+
WithHandler("/api/enqueue", func(w http.ResponseWriter, r *http.Request) {
1486+
w.WriteHeader(http.StatusOK)
1487+
}).
1488+
Build()
1489+
1490+
out, err := runWithOutput(t, repo.Dir, func(cmd *cobra.Command) error {
1491+
return runFixBatch(
1492+
cmd,
1493+
[]int64{10, 20},
1494+
"",
1495+
false,
1496+
fixOptions{agentName: "test", reasoning: "fast"},
1497+
)
1498+
})
1499+
if err != nil {
1500+
t.Fatalf("runFixBatch: %v", err)
1501+
}
1502+
1503+
if !strings.Contains(out, "Skipping job 10 (review passed)") {
1504+
t.Errorf("expected skip message for job 10, got:\n%s", out)
1505+
}
1506+
// Job 20 (FAIL) should be processed — its findings should appear
1507+
if !strings.Contains(out, "Bug in foo.go") {
1508+
t.Errorf("expected FAIL job findings in output, got:\n%s", out)
1509+
}
1510+
1511+
// Verify PASS job 10 was marked addressed during the skip phase
1512+
mu.Lock()
1513+
ids := addressedJobIDs
1514+
mu.Unlock()
1515+
if !slices.Contains(ids, int64(10)) {
1516+
t.Errorf("expected job 10 to be marked addressed, got IDs: %v", ids)
1517+
}
1518+
}

0 commit comments

Comments
 (0)