Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
420cf43
feat(storage): add CountFindings to extract severity counts from revi…
cpcloud Apr 26, 2026
5cc2065
feat(storage): add finding count columns + backfill migration
cpcloud Apr 26, 2026
71ecfc8
feat(storage): expose finding counts on Review struct
cpcloud Apr 26, 2026
439378b
feat(storage): compute finding counts when completing jobs
cpcloud Apr 26, 2026
648766d
feat(storage): join finding counts (own + parent) into ReviewJob queries
cpcloud Apr 26, 2026
9d64f90
feat(storage): aggregate finding counts into JobStats
cpcloud Apr 26, 2026
8ef7881
feat(storage): mirror finding counts in PostgreSQL schema and sync
cpcloud Apr 26, 2026
fa61f61
feat(tui): add severity styles and renderSeverityBadge helper
cpcloud Apr 26, 2026
93c9a77
feat(tui): add Findings column to queue table
cpcloud Apr 26, 2026
ce1aa8d
feat(tui): show finding counts in review detail header
cpcloud Apr 26, 2026
61340a9
feat(tui): show aggregate finding counts in queue status header
cpcloud Apr 26, 2026
0118d57
feat(tui): show parent review finding counts in tasks view
cpcloud Apr 26, 2026
0910038
fix(tui): guard partial nil-pointer population in finding badges
cpcloud Apr 26, 2026
2dc0044
fix: address roborev-ci review findings and CI failures
cpcloud Apr 26, 2026
98512c9
test(tui): cover derefOrZero nil and findings task-column backfill
cpcloud Apr 26, 2026
61b8d70
feat(tui): tighten finding badge to '3/2/5' format
cpcloud Apr 26, 2026
042d921
test(storage): add v13 postgres migration test + backfill edge cases
cpcloud Apr 26, 2026
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
54 changes: 39 additions & 15 deletions cmd/roborev/tui/queue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -598,9 +598,11 @@ func TestTUIJobCellsContent(t *testing.T) {
job.Closed = &handled

cells := m.jobCells(job)
// cells: ref(0), branch(1), repo(2), agent(3), queued(4),
// elapsed(5), status(6), pf(7), findings(8), handled(9), session(10), ...
assert.Equal(t, "Done", cells[6])
assert.Equal(t, "P", cells[7])
assert.Equal(t, "yes", cells[8])
assert.Equal(t, "yes", cells[9])
})
}

Expand Down Expand Up @@ -2160,14 +2162,16 @@ func TestClosedKeyShortcut(t *testing.T) {

func TestMigrateColumnConfig(t *testing.T) {
tests := []struct {
name string
columnOrder []string
hiddenCols []string
version int
wantDirty bool
wantColOrder []string
wantHidden []string
wantVersion int
name string
columnOrder []string
taskColumnOrder []string
hiddenCols []string
version int
wantDirty bool
wantColOrder []string
wantTaskColOrder []string
wantHidden []string
wantVersion int
}{
{
name: "nil config unchanged",
Expand Down Expand Up @@ -2200,16 +2204,16 @@ func TestMigrateColumnConfig(t *testing.T) {
wantColOrder: nil,
},
{
name: "custom order preserved",
name: "custom order has findings inserted after pf",
columnOrder: []string{"repo", "ref", "agent", "status", "pf", "queued", "elapsed", "branch", "closed"},
wantDirty: false,
wantColOrder: []string{"repo", "ref", "agent", "status", "pf", "queued", "elapsed", "branch", "closed"},
wantDirty: true,
wantColOrder: []string{"repo", "ref", "agent", "status", "pf", "findings", "queued", "elapsed", "branch", "closed"},
},
{
name: "current default order preserved",
name: "current default order has findings inserted after pf",
columnOrder: []string{"ref", "branch", "repo", "agent", "queued", "elapsed", "status", "pf", "closed"},
wantDirty: false,
wantColOrder: []string{"ref", "branch", "repo", "agent", "queued", "elapsed", "status", "pf", "closed"},
wantDirty: true,
wantColOrder: []string{"ref", "branch", "repo", "agent", "queued", "elapsed", "status", "pf", "findings", "closed"},
},
{
name: "stale hidden_columns backfills only new columns",
Expand Down Expand Up @@ -2246,18 +2250,38 @@ func TestMigrateColumnConfig(t *testing.T) {
wantHidden: []string{"branch"},
wantVersion: 1,
},
{
name: "task column order has findings inserted after parent",
taskColumnOrder: []string{"id", "ref", "agent", "status", "parent", "created"},
wantDirty: true,
wantTaskColOrder: []string{"id", "ref", "agent", "status", "parent", "findings", "created"},
},
{
name: "task column order without parent appends findings",
taskColumnOrder: []string{"id", "ref", "agent", "status", "created"},
wantDirty: true,
wantTaskColOrder: []string{"id", "ref", "agent", "status", "created", "findings"},
},
{
name: "task column order already has findings is preserved",
taskColumnOrder: []string{"id", "ref", "parent", "findings", "agent"},
wantDirty: false,
wantTaskColOrder: []string{"id", "ref", "parent", "findings", "agent"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.Config{
ColumnOrder: slices.Clone(tt.columnOrder),
TaskColumnOrder: slices.Clone(tt.taskColumnOrder),
HiddenColumns: slices.Clone(tt.hiddenCols),
ColumnConfigVersion: tt.version,
}
dirty := migrateColumnConfig(cfg)
assert.Equal(t, tt.wantDirty, dirty)
assert.True(t, slices.Equal(cfg.ColumnOrder, tt.wantColOrder))
assert.True(t, slices.Equal(cfg.TaskColumnOrder, tt.wantTaskColOrder))
assert.True(t, slices.Equal(cfg.HiddenColumns, tt.wantHidden))
assert.Equal(t, tt.wantVersion, cfg.ColumnConfigVersion)
})
Expand Down
57 changes: 54 additions & 3 deletions cmd/roborev/tui/render_queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const (
colElapsed // Elapsed time
colStatus // Job status
colPF // Pass/Fail verdict
colFindings // Severity finding counts (H3 M2 L5)
colHandled // Done status
colSessionID // Session ID
colRequestedModel // Explicitly requested model
Expand Down Expand Up @@ -153,6 +154,7 @@ func (m model) renderQueueView() string {
// fall back to client-side counting for multi-repo filters (which load all jobs)
var statusLine string
var done, closed, open int
var aggH, aggM, aggL int
if len(m.activeRepoFilter) > 1 || m.activeBranchFilter == branchNone {
// Client-side filtered views load all jobs, so count locally
for _, job := range m.jobs {
Expand All @@ -172,11 +174,17 @@ func (m model) renderQueueView() string {
}
}
}
aggH += derefOrZero(job.HighFindings)
aggM += derefOrZero(job.MediumFindings)
aggL += derefOrZero(job.LowFindings)
}
} else {
done = m.jobStats.Done
closed = m.jobStats.Closed
open = m.jobStats.Open
aggH = m.jobStats.HighFindings
aggM = m.jobStats.MediumFindings
aggL = m.jobStats.LowFindings
}
b.WriteString(m.renderDaemonStatus())
if len(m.activeRepoFilter) > 0 || m.activeBranchFilter != "" {
Expand All @@ -188,6 +196,11 @@ func (m model) renderQueueView() string {
done, closed, open)
}
b.WriteString(statusStyle.Render(statusLine))
if aggH+aggM+aggL > 0 {
b.WriteString(statusStyle.Render(" |"))
b.WriteString(" Findings: ")
b.WriteString(renderSeverityBadge(aggH, aggM, aggL))
}
b.WriteString("\x1b[K\n") // Clear status line

// Update notification on line 3 (above the table)
Expand Down Expand Up @@ -263,7 +276,7 @@ func (m model) renderQueueView() string {
visCols := m.visibleColumns()

// Compute per-column max content widths, using cache when data hasn't changed.
allHeaders := [colCount]string{"", "JobID", "Ref", "Branch", "Repo", "Agent", "Queued", "Elapsed", "Status", "P/F", "Closed", "Session", "Req Model", "Req Provider"}
allHeaders := [colCount]string{"", "JobID", "Ref", "Branch", "Repo", "Agent", "Queued", "Elapsed", "Status", "P/F", "Findings", "Closed", "Session", "Req Model", "Req Provider"}
allFullRows := make([][]string, len(visibleJobList))
for i, job := range visibleJobList {
cells := m.jobCells(job)
Expand Down Expand Up @@ -317,6 +330,7 @@ func (m model) renderQueueView() string {
colQueued: 12,
colElapsed: 8,
colPF: 3, // "P/F" header = 3
colFindings: 8, // "Findings" header = 8 (badge "3/2/5" = 5; header dominates)
colHandled: max(contentWidth[colHandled], 6), // "Closed" header = 6
colAgent: min(max(contentWidth[colAgent], 5), 12), // "Agent" header = 5, cap at 12
colSessionID: min(max(contentWidth[colSessionID], 7), 12), // "Session" header = 7, cap at 12
Expand Down Expand Up @@ -662,7 +676,16 @@ func (m model) jobCells(job storage.ReviewJob) []string {
requestedModel := stripControlChars(job.RequestedModel)
requestedProvider := stripControlChars(job.RequestedProvider)

return []string{ref, branch, repo, agentName, enqueued, elapsed, status, verdict, handled, sessionID, requestedModel, requestedProvider}
findings := ""
if job.HighFindings != nil || job.MediumFindings != nil || job.LowFindings != nil {
findings = renderSeverityBadge(
derefOrZero(job.HighFindings),
derefOrZero(job.MediumFindings),
derefOrZero(job.LowFindings),
)
}

return []string{ref, branch, repo, agentName, enqueued, elapsed, status, verdict, findings, handled, sessionID, requestedModel, requestedProvider}
}

// statusLabel returns a capitalized display label for the job status.
Expand Down Expand Up @@ -830,12 +853,38 @@ func migrateColumnConfig(cfg *config.Config) bool {
dirty = true
}

// Backfill "findings" into custom queue and task column orders so users
// with pre-existing customisation see the new column next to its sibling
// (after "pf" for queue, after "parent" for tasks). Missing entries would
// otherwise be appended at the end by resolveColumnOrder.
if len(cfg.ColumnOrder) > 0 && !slices.Contains(cfg.ColumnOrder, "findings") {
insertAt := len(cfg.ColumnOrder)
for i, name := range cfg.ColumnOrder {
if name == "pf" {
insertAt = i + 1
break
}
}
cfg.ColumnOrder = slices.Insert(cfg.ColumnOrder, insertAt, "findings")
dirty = true
}
if len(cfg.TaskColumnOrder) > 0 && !slices.Contains(cfg.TaskColumnOrder, "findings") {
insertAt := len(cfg.TaskColumnOrder)
for i, name := range cfg.TaskColumnOrder {
if name == "parent" {
insertAt = i + 1
break
}
}
cfg.TaskColumnOrder = slices.Insert(cfg.TaskColumnOrder, insertAt, "findings")
dirty = true
}
return dirty
}

// toggleableColumns is the ordered list of columns the user can show/hide.
// colSel and colJobID are always visible and not included here.
var toggleableColumns = []int{colRef, colBranch, colRepo, colAgent, colQueued, colElapsed, colStatus, colPF, colHandled, colSessionID, colRequestedModel, colRequestedProvider}
var toggleableColumns = []int{colRef, colBranch, colRepo, colAgent, colQueued, colElapsed, colStatus, colPF, colFindings, colHandled, colSessionID, colRequestedModel, colRequestedProvider}

// columnNames maps column constants to display names.
var columnNames = map[int]string{
Expand All @@ -847,6 +896,7 @@ var columnNames = map[int]string{
colQueued: "Queued",
colElapsed: "Elapsed",
colPF: "P/F",
colFindings: "Findings",
colHandled: "Closed",
colSessionID: "Session",
colRequestedModel: "Req Model",
Expand All @@ -863,6 +913,7 @@ var columnConfigNames = map[int]string{
colQueued: "queued",
colElapsed: "elapsed",
colPF: "pf",
colFindings: "findings",
colHandled: "closed",
colSessionID: "session_id",
colRequestedModel: "requested_model",
Expand Down
27 changes: 20 additions & 7 deletions cmd/roborev/tui/render_review.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,17 @@ func (m model) renderReviewView() string {
b.WriteString(statusStyle.Render(locationLine))
b.WriteString("\x1b[K") // Clear to end of line

// Show verdict, closed status, and token usage on next line (skip verdict for fix jobs)
// Show verdict, findings counts, closed status, and token usage on next line (skip verdict for fix jobs)
hasVerdict := review.Job.Verdict != nil && *review.Job.Verdict != "" && !review.Job.IsFixJob()
findHigh := derefOrZero(review.Job.HighFindings)
findMed := derefOrZero(review.Job.MediumFindings)
findLow := derefOrZero(review.Job.LowFindings)
hasFindings := findHigh+findMed+findLow > 0
tokenSummary := ""
if tu := tokens.ParseJSON(review.Job.TokenUsage); tu != nil {
tokenSummary = tu.FormatSummary()
}
if hasVerdict || review.Closed || tokenSummary != "" {
if hasVerdict || hasFindings || review.Closed || tokenSummary != "" {
b.WriteString("\n")
if hasVerdict {
v := *review.Job.Verdict
Expand All @@ -73,15 +77,22 @@ func (m model) renderReviewView() string {
b.WriteString(failStyle.Render("Verdict: Fail"))
}
}
// Show [CLOSED] with distinct color (after verdict if present)
if review.Closed {
if hasFindings {
if hasVerdict {
b.WriteString(" ")
}
b.WriteString(statusStyle.Render("Findings: "))
b.WriteString(renderSeverityBadge(findHigh, findMed, findLow))
}
// Show [CLOSED] with distinct color (after verdict/findings if present)
if review.Closed {
if hasVerdict || hasFindings {
b.WriteString(" ")
}
b.WriteString(closedStyle.Render("[CLOSED]"))
}
if tokenSummary != "" {
if hasVerdict || review.Closed {
if hasVerdict || hasFindings || review.Closed {
b.WriteString(" ")
}
b.WriteString(statusStyle.Render("[" + tokenSummary + "]"))
Expand Down Expand Up @@ -151,9 +162,11 @@ func (m model) renderReviewView() string {
// Reserve title, location, footer status, help, and optional verdict.
headerHeight := titleLines + locationLines + 1 + helpLines
hasVerdict := review.Job != nil && review.Job.Verdict != nil && *review.Job.Verdict != "" && !review.Job.IsFixJob()
hasFindings := review.Job != nil &&
(derefOrZero(review.Job.HighFindings)+derefOrZero(review.Job.MediumFindings)+derefOrZero(review.Job.LowFindings) > 0)
hasTokens := review.Job != nil && tokens.ParseJSON(review.Job.TokenUsage) != nil
if hasVerdict || review.Closed || hasTokens {
headerHeight++ // Add 1 for verdict/closed/tokens line
if hasVerdict || hasFindings || review.Closed || hasTokens {
headerHeight++ // Add 1 for verdict/findings/closed/tokens line
}
panelReserve := 0
if m.reviewFixPanelOpen {
Expand Down
36 changes: 25 additions & 11 deletions cmd/roborev/tui/render_tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const (
tcolStatus // Job status
tcolJobID // Job ID
tcolParent // Parent job reference
tcolFindings // Parent review's finding counts (H3 M2 L5)
tcolQueued // Enqueue timestamp
tcolElapsed // Elapsed time
tcolBranch // Branch name
Expand All @@ -28,13 +29,14 @@ const (

// taskToggleableColumns is the ordered list of task columns the user can show/hide/reorder.
// tcolSel is always visible and not included here.
var taskToggleableColumns = []int{tcolStatus, tcolJobID, tcolParent, tcolQueued, tcolElapsed, tcolBranch, tcolRepo, tcolRefSubject}
var taskToggleableColumns = []int{tcolStatus, tcolJobID, tcolParent, tcolFindings, tcolQueued, tcolElapsed, tcolBranch, tcolRepo, tcolRefSubject}

// taskColumnNames maps task column constants to display names.
var taskColumnNames = map[int]string{
tcolStatus: "Status",
tcolJobID: "Job",
tcolParent: "Parent",
tcolFindings: "Findings",
tcolQueued: "Queued",
tcolElapsed: "Elapsed",
tcolBranch: "Branch",
Expand All @@ -47,6 +49,7 @@ var taskColumnConfigNames = map[int]string{
tcolStatus: "status",
tcolJobID: "job",
tcolParent: "parent",
tcolFindings: "findings",
tcolQueued: "queued",
tcolElapsed: "elapsed",
tcolBranch: "branch",
Expand Down Expand Up @@ -78,8 +81,9 @@ func (m model) visibleTaskColumns() []int {
}

// taskCells returns plain text cell values for a fix job row.
// Order: status, jobID, parent, queued, elapsed, branch, repo, refSubject
// (tcolStatus through tcolRefSubject, 8 values).
// Order: status, jobID, parent, findings, queued, elapsed, branch, repo, refSubject
// (tcolStatus through tcolRefSubject, 9 values). The findings column shows the
// parent review's counts since fix jobs themselves don't produce findings.
func (m model) taskCells(job storage.ReviewJob) []string {
var statusLabel string
switch job.Status {
Expand Down Expand Up @@ -139,7 +143,16 @@ func (m model) taskCells(job storage.ReviewJob) []string {
refSubject += job.CommitSubject
}

return []string{statusLabel, jobID, parentRef, queued, elapsed, branch, repo, refSubject}
findings := ""
if job.ParentHighFindings != nil || job.ParentMediumFindings != nil || job.ParentLowFindings != nil {
findings = renderSeverityBadge(
derefOrZero(job.ParentHighFindings),
derefOrZero(job.ParentMediumFindings),
derefOrZero(job.ParentLowFindings),
)
}

return []string{statusLabel, jobID, parentRef, findings, queued, elapsed, branch, repo, refSubject}
}

func (m model) renderTasksView() string {
Expand Down Expand Up @@ -178,7 +191,7 @@ func (m model) renderTasksView() string {
visCols := m.visibleTaskColumns()

// Compute per-column max content widths, using cache when data hasn't changed.
allHeaders := [tcolCount]string{tcolSel: "", tcolStatus: "Status", tcolJobID: "Job", tcolParent: "Parent", tcolQueued: "Queued", tcolElapsed: "Elapsed", tcolBranch: "Branch", tcolRepo: "Repo", tcolRefSubject: "Ref/Subject"}
allHeaders := [tcolCount]string{tcolSel: "", tcolStatus: "Status", tcolJobID: "Job", tcolParent: "Parent", tcolFindings: "Findings", tcolQueued: "Queued", tcolElapsed: "Elapsed", tcolBranch: "Branch", tcolRepo: "Repo", tcolRefSubject: "Ref/Subject"}
allFullRows := make([][]string, len(m.fixJobs))
for i, job := range m.fixJobs {
cells := m.taskCells(job)
Expand Down Expand Up @@ -222,12 +235,13 @@ func (m model) renderTasksView() string {
}

fixedWidth := map[int]int{
tcolSel: 2,
tcolStatus: 8,
tcolJobID: 5,
tcolParent: 11,
tcolQueued: 12,
tcolElapsed: 8,
tcolSel: 2,
tcolStatus: 8,
tcolJobID: 5,
tcolParent: 11,
tcolFindings: 8,
tcolQueued: 12,
tcolElapsed: 8,
}

flexCols := []int{tcolBranch, tcolRepo, tcolRefSubject}
Expand Down
Loading
Loading