control: list and dashboard screens (CLI views stage 2b) !554

merged merged by cmc on 2026-10-02 03:51 UTC · krz/gitbay:cli-views-2b into main

14 files changed, +479 −193

Layout: unified · split

.gitbay/wiki/Users.org +11 −5
@@ -1024,11 +1024,17 @@ then prints:
10241024 =krz/gitbay=, SHAs) dim. A marker piped output keeps as its own
10251025 trailing cell (=[archived]=, =primary=) joins the state at a terminal:
10261026 =public, archived=;
1027- lists that say what to do next: =mr list= with checks, review state
1028 and age, =issue list= with labels, assignee, comments and age, a
1029 =dashboard= that opens with what needs you; a column empty on every
1030 row is left out. These columns are terminal-only; piped rows keep
1031 their own;
1027- =mr list=, =issue list= and =build list= as one-section screens:
1028 each row led by =●= when it waits on you (your review requested, an
1029 issue assigned to you), otherwise by its checks' or state's glyph;
1030 =mr list= with review state, branch and age, =issue list= with
1031 labels, assignees, comments and age. A =dashboard= screen opens with
1032 what waits on you, then open merge requests, failed builds (a job's
1033 latest build only), five lines of activity with a count of builds
1034 that passed, and pinned repositories; for admins a =Problems= line
1035 counts background failures, and =admin stats= has the detail. A
1036 column empty on every row is left out. These columns are
1037 terminal-only; piped rows keep their own;
10321038- diffs coloured (file headers bold, hunks cyan, added green, removed
10331039 red), and =build log= with its step lines bold, the failed one red;
10341040 both made safe like any user text, the log's own colour codes dropped;
CHANGELOG.org +5
@@ -20,6 +20,11 @@ anything beyond "replace the binary and restart" is needed.
2020 the files with their line counts; =repo show= its open merge requests,
2121 open issues and latest commits; system events sit in their own
2222 =Events= section. Pending checks lead with =◐=.
23- =mr list=, =issue list=, =build list= and =dashboard= at a terminal
24 are screens (#318). Rows that wait on you lead with =●=. The terminal
25 dashboard drops the server build and queue tables (=admin stats= has
26 them; =--json= is unchanged) and shows a job's failed build only
27 while it is that job's latest.
2328
2429* v1.41.0 — 2026-10-01
2530
internal/control/build.go +18 −2
@@ -213,13 +213,29 @@ func runBuildList(c *Ctx, args []string) int {
213213 for i := range ds {
214214 ds[i].Subject = subjects[ds[i].SHA]
215215 }
216 return c.emitPage(p, ds, next, func(w io.Writer) {
216 return c.emitPageView(p, ds, next, func(w io.Writer) {
217217 tb := c.table(w, "#", "JOB", "STATUS", "SHA", "REF", "TITLE")
218218 for _, d := range ds {
219219 tb.row(cLink(fmt.Sprintf("%d", d.Number), c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10))), cText(d.Job), cState(d.Status), cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Ref), cFlex(d.Subject))
220220 }
221221 tb.flush()
222 })
222 }, func() screen { return buildListScreen(c, repo, ds) })
223}
224
225// buildListScreen is build list at a terminal: one section of builds,
226// each led by its outcome's mark, then job, ref, commit subject and age.
227func buildListScreen(c *Ctx, repo store.Repo, ds []BuildOut) screen {
228 sec := section{title: "Builds", n: len(ds)}
229 for _, d := range ds {
230 n := strconv.FormatInt(d.Number, 10)
231 sec.rows = append(sec.rows, rowOf(cLink(n, c.siteURL(repo.Path(), "builds", n)), cGlyph(d.Status),
232 cText(d.Job), cText(d.Ref), cFlex(d.Subject), cAge(d.CreatedAt)))
233 }
234 s := screen{sections: []section{sec}}
235 if len(ds) > 0 {
236 s.actions = []action{{"Read", []string{"build", "show", repo.Path(), strconv.FormatInt(ds[0].Number, 10)}}}
237 }
238 return s
223239}
224240
225241// buildSubjects reads the commit subject of each distinct sha on a page
internal/control/dashboard.go +142 −110
@@ -95,47 +95,7 @@ type DashboardOut struct {
9595
9696// dashboardActivity is how many feed lines the dashboard shows at a
9797// terminal.
98const dashboardActivity = 8
99
100// needsYou is the dashboard's first line at a terminal: what waits on
101// the viewer, in yellow, or a dim line saying nothing does.
102func (t Term) needsYou(d DashboardOut) string {
103 var parts []string
104 add := func(n int, one, many string) {
105 switch {
106 case n == 1:
107 parts = append(parts, "1 "+one)
108 case n > 1:
109 parts = append(parts, fmt.Sprintf("%d %s", n, many))
110 }
111 }
112 add(len(d.Reviews), "review requested", "reviews requested")
113 add(len(d.Assigned), "assigned issue", "assigned issues")
114 add(failingBuilds(d.Builds), "failing build", "failing builds")
115 add(d.Unread, "unread notification", "unread notifications")
116 if len(parts) == 0 {
117 return t.paint(sgrDim, "Nothing waits on you.")
118 }
119 return t.paint(sgrBold+sgrYellow, "Needs you: "+strings.Join(parts, ", "))
120}
121
122// failingBuilds counts the jobs whose latest build failed: a failure a
123// later build of the same job and ref has already replaced is not one.
124func failingBuilds(builds []DashboardBuild) int {
125 seen := map[string]bool{}
126 n := 0
127 for _, b := range builds {
128 key := b.Repo + "\x00" + b.Job + "\x00" + b.Ref
129 if seen[key] {
130 continue
131 }
132 seen[key] = true
133 if b.Status == "failure" {
134 n++
135 }
136 }
137 return n
138}
98const dashboardActivity = 5
13999
140100func runDashboard(c *Ctx, args []string) int {
141101 if len(args) != 0 {
@@ -220,48 +180,25 @@ func runDashboard(c *Ctx, args []string) int {
220180 d.Queues = &q
221181 }
222182
223 return c.emit(d, func(w io.Writer) {
224 // At a terminal sections are separated by a blank line, and an
225 // empty one is left out.
226 wrote := false
227 heading := func(title string) {
228 if c.Term.Cols == 0 {
229 fmt.Fprintln(w, title)
230 return
231 }
232 if wrote {
233 fmt.Fprintln(w)
234 }
235 wrote = true
236 fmt.Fprintln(w, c.Term.heading(title))
237 }
238 section := func(title string, header []string, rows [][]cell) {
239 if c.Term.Cols > 0 && len(rows) == 0 {
240 return
241 }
183 lines := FeedLines(events)
184 return c.emitView(d, func(w io.Writer) {
185 heading := func(title string) { fmt.Fprintln(w, title) }
186 section := func(title string, rows [][]cell) {
242187 heading(title)
243188 if len(rows) == 0 {
244189 fmt.Fprintln(w, " none")
245190 return
246191 }
247 if c.Term.Cols == 0 {
248 for _, r := range rows {
249 parts := make([]string, len(r))
250 for i, cl := range r {
251 parts[i] = cl.s
252 if cl.kind == kindAge {
253 parts[i] = stamp(cl.s)
254 }
192 for _, r := range rows {
193 parts := make([]string, len(r))
194 for i, cl := range r {
195 parts[i] = cl.s
196 if cl.kind == kindAge {
197 parts[i] = stamp(cl.s)
255198 }
256 fmt.Fprintf(w, " %s\n", strings.Join(parts, "\t"))
257199 }
258 return
259 }
260 tb := c.table(w, header...)
261 for _, r := range rows {
262 tb.row(r...)
200 fmt.Fprintf(w, " %s\n", strings.Join(parts, "\t"))
263201 }
264 tb.flush()
265202 }
266203 itemRows := func(items []DashboardItem, marker string) [][]cell {
267204 rows := make([][]cell, len(items))
@@ -276,17 +213,13 @@ func runDashboard(c *Ctx, args []string) int {
276213 return rows
277214 }
278215
279 if c.Term.Cols > 0 {
280 fmt.Fprintln(w, c.Term.needsYou(d))
281 wrote = true
282 } else if d.Unread > 0 {
216 if d.Unread > 0 {
283217 fmt.Fprintf(w, "unread notifications: %d\n", d.Unread)
284218 }
285 itemHeader := []string{"REF", "TITLE", "AUTHOR"}
286 section("waiting on your review:", itemHeader, itemRows(d.Reviews, "!"))
287 section("assigned to you:", itemHeader, itemRows(d.Assigned, "#"))
288 section("open merge requests:", itemHeader, itemRows(d.MRs, "!"))
289 section("open issues:", itemHeader, itemRows(d.Issues, "#"))
219 section("waiting on your review:", itemRows(d.Reviews, "!"))
220 section("assigned to you:", itemRows(d.Assigned, "#"))
221 section("open merge requests:", itemRows(d.MRs, "!"))
222 section("open issues:", itemRows(d.Issues, "#"))
290223 for _, q := range d.Queries {
291224 rows := make([][]cell, 0, len(q.Items))
292225 for _, it := range q.Items {
@@ -296,7 +229,7 @@ func runDashboard(c *Ctx, args []string) int {
296229 if q.Error != "" {
297230 title = fmt.Sprintf("query %s: %s", q.Name, q.Error)
298231 }
299 section(title, itemHeader, rows)
232 section(title, rows)
300233 }
301234
302235 pinnedRows := make([][]cell, len(d.Pinned))
@@ -307,32 +240,19 @@ func runDashboard(c *Ctx, args []string) int {
307240 }
308241 pinnedRows[i] = cells
309242 }
310 section("pinned:", []string{"PATH", "VISIBILITY", "DESCRIPTION"}, pinnedRows)
243 section("pinned:", pinnedRows)
311244
312 lines := FeedLines(events)
313 if c.Term.Cols > 0 {
314 // The feed has its own command; the dashboard shows the start.
315 rows := make([][]cell, 0, dashboardActivity)
316 for _, l := range lines[:min(len(lines), dashboardActivity)] {
317 rows = append(rows, l.termCells(c))
318 }
319 section("recent activity:", feedHeader, rows)
320 if len(lines) > dashboardActivity {
321 fmt.Fprintln(w, c.Term.paint(sgrDim, "more: gitbay feed"))
322 }
323 } else {
324 activityRows := make([][]cell, len(lines))
325 for i, l := range lines {
326 activityRows[i] = []cell{cAge(l.When), cFlex(l.Sentence())}
327 }
328 section("recent activity:", []string{"WHEN", "EVENT"}, activityRows)
245 activityRows := make([][]cell, len(lines))
246 for i, l := range lines {
247 activityRows[i] = []cell{cAge(l.When), cFlex(l.Sentence())}
329248 }
249 section("recent activity:", activityRows)
330250
331251 buildRows := make([][]cell, len(d.Builds))
332252 for i, b := range d.Builds {
333253 buildRows[i] = []cell{cLink(b.Repo, c.siteURL(b.Repo, "builds", strconv.FormatInt(b.Number, 10))), cNum(b.Number), cText(b.Job), cState(b.Status), cRef(fmt.Sprintf("%.10s", b.SHA)), cText(b.Ref)}
334254 }
335 section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows)
255 section("builds:", buildRows)
336256
337257 if d.Server != nil {
338258 heading("server:")
@@ -377,11 +297,7 @@ func runDashboard(c *Ctx, args []string) int {
377297 if it.Status == "pending" {
378298 since = it.CreatedAt
379299 }
380 if c.Term.Cols == 0 {
381 since = stamp(since)
382 } else {
383 since = relAge(since, termNow())
384 }
300 since = stamp(since)
385301 tbq.row(cRef(" "+it.Repo), cNum(it.Number), cText(it.Job), cText(fmt.Sprintf("%s since %s", it.Status, since)))
386302 }
387303 tbq.flush()
@@ -393,7 +309,7 @@ func runDashboard(c *Ctx, args []string) int {
393309 }
394310 tde.flush()
395311 }
396 })
312 }, func() screen { return dashboardScreen(c, d, lines) })
397313}
398314
399315// feedDefaultLimit caps a bare `feed` call; pagination reaches further
@@ -457,3 +373,119 @@ func runFeed(c *Ctx, args []string) int {
457373 tb.flush()
458374 })
459375}
376
377// dashboardScreen is dashboard at a terminal: what waits on the viewer
378// first, then open merge requests, failed builds, a few lines of
379// activity and the pinned repositories. The operator's queues are
380// admin stats'; a background failure shows as one header line.
381func dashboardScreen(c *Ctx, d DashboardOut, lines []FeedLine) screen {
382 var s screen
383 s.fields = append(s.fields, field{"User", []cell{cText(c.User.Username)}})
384 if host := c.Cfg.SiteHost(); host != "" {
385 s.fields = append(s.fields, field{"Instance", []cell{cText(host)}})
386 }
387 if q := d.Queues; q != nil {
388 if bad := q.Webhooks.Failed + q.Mail.Failed + q.Push.Failed + q.Mirrors.Errors + q.Deps.Errors; bad > 0 {
389 s.fields = append(s.fields, field{"Problems", []cell{cGlyph("failed"), cText(fmt.Sprintf("%d failing in the background", bad))}})
390 }
391 }
392 if d.Unread > 0 {
393 s.fields = append(s.fields, field{"Inbox", []cell{cYou(), cText(fmt.Sprintf("%d unread", d.Unread))}})
394 }
395
396 item := func(it DashboardItem, marker, page string, you bool) row {
397 lead := cell{kind: kindGlyph}
398 if you {
399 lead = cYou()
400 }
401 ref := cLink(fmt.Sprintf("%s%s%d", it.Repo, marker, it.Number), c.siteURL(it.Repo, page, strconv.FormatInt(it.Number, 10)))
402 return rowOf(ref, lead, cFlex(it.Title), cMeta(it.Author))
403 }
404 items := func(title string, its []DashboardItem, marker, page string, you bool) section {
405 sec := section{title: title, n: len(its)}
406 for _, it := range its {
407 sec.rows = append(sec.rows, item(it, marker, page, you))
408 }
409 return sec
410 }
411 s.sections = append(s.sections,
412 items("Review requested", d.Reviews, "!", "mrs", true),
413 items("Assigned issues", d.Assigned, "#", "issues", true),
414 items("Open merge requests", d.MRs, "!", "mrs", false),
415 )
416 for _, q := range d.Queries {
417 sec := section{title: q.Name, n: q.Count, more: []string{"query", "run", q.Name}}
418 if q.Error != "" {
419 sec.note, sec.empty = q.Error, true
420 }
421 for _, it := range q.Items {
422 sec.rows = append(sec.rows, rowOf(cRef(it.Ref()), cFlex(it.Title), cMeta(it.Author)))
423 }
424 s.sections = append(s.sections, sec)
425 }
426
427 // A job's latest build is the one that counts: a failure a later
428 // build of the same job and ref has replaced is not shown.
429 failed := section{title: "Failed builds"}
430 passed := 0
431 seen := map[string]bool{}
432 var firstFailed *DashboardBuild
433 for i, b := range d.Builds {
434 key := b.Repo + "\x00" + b.Job + "\x00" + b.Ref
435 if seen[key] {
436 continue
437 }
438 seen[key] = true
439 switch b.Status {
440 case "success":
441 passed++
442 case "failure", "error":
443 n := strconv.FormatInt(b.Number, 10)
444 failed.n++
445 failed.rows = append(failed.rows, rowOf(cLink(n, c.siteURL(b.Repo, "builds", n)), cGlyph(b.Status),
446 cFlex(b.Job+" "+b.Ref), cMeta(b.Repo, relAge(b.CreatedAt, termNow()))))
447 if firstFailed == nil {
448 firstFailed = &d.Builds[i]
449 }
450 }
451 }
452 s.sections = append(s.sections, failed)
453
454 activity := section{title: "Recent activity", n: len(lines), more: []string{"feed"}}
455 for _, l := range lines[:min(len(lines), dashboardActivity)] {
456 activity.rows = append(activity.rows, rowOf(l.termCells(c)...))
457 }
458 if passed > 0 {
459 word := "builds"
460 if passed == 1 {
461 word = "build"
462 }
463 activity.note, activity.empty = fmt.Sprintf("%d %s passed", passed, word), true
464 }
465 s.sections = append(s.sections, activity)
466
467 if len(d.Pinned) > 0 {
468 paths := make([]string, len(d.Pinned))
469 for i, p := range d.Pinned {
470 paths[i] = p.Path
471 }
472 s.sections = append(s.sections, section{title: "Pinned", n: len(d.Pinned), rows: []row{rowOf(cMeta(strings.Join(paths, " ")))}})
473 }
474
475 if len(d.Reviews) > 0 {
476 it := d.Reviews[0]
477 s.actions = append(s.actions, action{"Next", []string{"mr", "show", it.Repo, strconv.FormatInt(it.Number, 10)}})
478 }
479 if len(d.Assigned) > 0 {
480 it := d.Assigned[0]
481 s.actions = append(s.actions, action{"Next", []string{"issue", "show", it.Repo, strconv.FormatInt(it.Number, 10)}})
482 }
483 if firstFailed != nil {
484 s.actions = append(s.actions, action{"Next", []string{"build", "log", firstFailed.Repo, strconv.FormatInt(firstFailed.Number, 10)}})
485 }
486 s.actions = append(s.actions, action{"More", []string{"feed"}})
487 if c.User.IsAdmin {
488 s.actions = append(s.actions, action{"Instance", []string{"admin", "stats"}})
489 }
490 return s
491}
internal/control/dashscreen_test.go added +99
@@ -0,0 +1,99 @@
1package control
2
3import (
4 "slices"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/protocol"
9 "gitbay.org/gitbay/internal/store"
10)
11
12func TestDashboardPlainPinned(t *testing.T) {
13 st, repo, owner := twoMRTestRepo(t)
14 // Both merge requests are created in the same second; the dashboard
15 // orders by updated_at alone, so give them distinct times.
16 if _, err := st.DB.Exec(`UPDATE merge_requests SET updated_at = '2020-01-01T00:00:00Z' WHERE repo_id = ? AND number = 1`, repo.ID); err != nil {
17 t.Fatal(err)
18 }
19 c, out, errOut := mrTestCtx(st, owner)
20 if code := Dispatch(c, []string{"dashboard"}); code != protocol.ExitOK {
21 t.Fatalf("exit %d: %s", code, errOut)
22 }
23 pinPlain(t, "dashboard", out.String())
24}
25
26func dashFixture() DashboardOut {
27 return DashboardOut{
28 Assigned: []DashboardItem{{Repo: "krz/skunky-art", Number: 33, Title: "LibRedirect listing", Author: "cmc"}},
29 MRs: []DashboardItem{{Repo: "krz/gitbay", Number: 554, Title: "list screens", Author: "cmc"}},
30 Builds: []DashboardBuild{
31 {Repo: "krz/omaha-metro-blotter", Number: 83, Job: "daily-pull", Status: "failure", Ref: "main", CreatedAt: "2026-09-30T12:00:00Z"},
32 {Repo: "krz/gitbay", Number: 1784, Job: "test", Status: "success", Ref: "main", CreatedAt: "2026-10-01T10:00:00Z"},
33 {Repo: "krz/gitbay", Number: 1783, Job: "build", Status: "success", Ref: "main", CreatedAt: "2026-10-01T10:00:00Z"},
34 {Repo: "krz/gitbay", Number: 1700, Job: "build", Status: "failure", Ref: "main", CreatedAt: "2026-09-01T10:00:00Z"},
35 },
36 Server: &ServerOut{Commit: "135f300"},
37 Queues: &store.Queues{},
38 }
39}
40
41func TestDashboardScreen(t *testing.T) {
42 c := screenCtx(110, false)
43 c.User = store.User{Username: "cmc", IsAdmin: true}
44 s := dashboardScreen(c, dashFixture(), nil)
45 var titles []string
46 for _, sec := range s.sections {
47 if len(sec.rows) > 0 || sec.empty {
48 titles = append(titles, sec.title)
49 }
50 }
51 if want := []string{"Assigned issues", "Open merge requests", "Failed builds", "Recent activity"}; !slices.Equal(titles, want) {
52 t.Errorf("sections = %q, want %q", titles, want)
53 }
54 if n := sectionCounts(s)["Failed builds"]; n != 1 {
55 t.Errorf("failed builds = %d, want 1 (an older failure of a job that has since passed is not one)", n)
56 }
57 out := renderString(c, s)
58 for _, unwanted := range []string{"135f300", "Queues", "webhooks"} {
59 if strings.Contains(out, unwanted) {
60 t.Errorf("admin block %q on the dashboard screen:\n%s", unwanted, out)
61 }
62 }
63 if !strings.Contains(out, "2 builds passed") {
64 t.Errorf("no passing-build summary:\n%s", out)
65 }
66 var cmds []string
67 for _, a := range s.actions {
68 cmds = append(cmds, strings.Join(a.argv, " "))
69 }
70 for _, want := range []string{"issue show krz/skunky-art 33", "build log krz/omaha-metro-blotter 83", "feed", "admin stats"} {
71 if !slices.Contains(cmds, want) {
72 t.Errorf("actions %q lack %q", cmds, want)
73 }
74 }
75 checkActions(t, s)
76}
77
78func TestDashboardScreenProblemsLine(t *testing.T) {
79 c := screenCtx(110, false)
80 c.User = store.User{Username: "cmc", IsAdmin: true}
81 d := dashFixture()
82 d.Queues.Mirrors.Errors = 1
83 out := renderString(c, dashboardScreen(c, d, nil))
84 if !strings.Contains(out, "Problems: ✗ 1 failing in the background\n") {
85 t.Errorf("no problems line:\n%s", out)
86 }
87}
88
89func TestDashboardScreenNonAdmin(t *testing.T) {
90 c := screenCtx(110, false)
91 c.User = store.User{Username: "bob"}
92 d := dashFixture()
93 d.Server, d.Queues = nil, nil
94 for _, a := range dashboardScreen(c, d, nil).actions {
95 if a.argv[0] == "admin" {
96 t.Errorf("non-admin offered %q", a.argv)
97 }
98 }
99}
internal/control/issue.go +38 −18
@@ -354,29 +354,13 @@ func runIssueList(c *Ctx, args []string) int {
354354 return c.fail(protocol.ExitFailure, "%v", err)
355355 }
356356 }
357 return c.emitPage(p, ds, next, func(w io.Writer) {
358 if c.Term.Cols > 0 {
359 // At a terminal: who it waits on and how much it has moved,
360 // in place of who opened it.
361 tb := c.table(w, "#", "STATE", "TITLE", "LABELS", "ASSIGNEE", "COMMENTS", "UPDATED")
362 for i, d := range ds {
363 n := ""
364 if k := comments[issues[i].ID]; k > 0 {
365 n = strconv.Itoa(k)
366 }
367 tb.row(cLink(fmt.Sprintf("#%d", d.Number), c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10))), cState(d.State), cFlex(d.Title),
368 cText(labelsMark(labels[issues[i].ID])), assigneesMark(assignees[issues[i].ID], c.User.Username),
369 cText(n), cAge(issues[i].UpdatedAt))
370 }
371 tb.flush()
372 return
373 }
357 return c.emitPageView(p, ds, next, func(w io.Writer) {
374358 tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR")
375359 for _, d := range ds {
376360 tb.row(cRef(fmt.Sprintf("#%d", d.Number)), cState(d.State), cFlex(d.Title), cText(d.Author))
377361 }
378362 tb.flush()
379 })
363 }, func() screen { return issueListScreen(c, repo, f.State, issues, ds, comments, labels, assignees) })
380364}
381365
382366func runIssueShow(c *Ctx, args []string) int {
@@ -769,3 +753,39 @@ func issueShowScreen(c *Ctx, repo store.Repo, d IssueShow, canWrite bool) screen
769753 }
770754 return s
771755}
756
757// issueListScreen is issue list at a terminal: one section of issues,
758// each led by ● when it is assigned to the viewer, then title, labels,
759// assignees, comments and age.
760func issueListScreen(c *Ctx, repo store.Repo, state string, issues []store.Issue, ds []issueOut, comments map[int64]int, labels, assignees map[int64][]string) screen {
761 title, ok := map[string]string{"open": "Open issues", "closed": "Closed issues"}[state]
762 if !ok {
763 title = "Issues"
764 }
765 sec := section{title: title, n: len(ds)}
766 for i, d := range ds {
767 id := issues[i].ID
768 lead := cGlyph(d.State)
769 if slices.Contains(assignees[id], c.User.Username) {
770 lead = cYou()
771 }
772 n := ""
773 switch k := comments[id]; {
774 case k == 1:
775 n = "1 comment"
776 case k > 1:
777 n = fmt.Sprintf("%d comments", k)
778 }
779 ref := cLink(fmt.Sprintf("#%d", d.Number), c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10)))
780 sec.rows = append(sec.rows, rowOf(ref, lead, cFlex(d.Title),
781 cMeta(labelsMark(labels[id]), labelsMark(assignees[id]), n, relAge(issues[i].UpdatedAt, termNow()))))
782 }
783 other := "closed"
784 if state != "open" {
785 other = "open"
786 }
787 return screen{sections: []section{sec}, actions: []action{
788 {"New", []string{"issue", "create", repo.Path()}},
789 {"Filter", []string{"issue", "list", repo.Path(), "--state", other}},
790 }}
791}
internal/control/listscreen_test.go added +111
@@ -0,0 +1,111 @@
1package control
2
3import (
4 "slices"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/protocol"
9 "gitbay.org/gitbay/internal/store"
10)
11
12func TestListsPlainPinned(t *testing.T) {
13 st, repo, owner := twoMRTestRepo(t)
14 if _, err := st.CreateIssue(repo.ID, owner.ID, "Android app", "", "md"); err != nil {
15 t.Fatal(err)
16 }
17 if _, err := st.CreateBuild(repo.ID, "test", "abc111", "main", `["go test ./..."]`, "", "", true); err != nil {
18 t.Fatal(err)
19 }
20 for name, argv := range map[string][]string{
21 "mr-list": {"mr", "list", repo.Path()},
22 "issue-list": {"issue", "list", repo.Path()},
23 "build-list": {"build", "list", repo.Path()},
24 } {
25 c, out, errOut := mrTestCtx(st, owner)
26 if code := Dispatch(c, argv); code != protocol.ExitOK {
27 t.Fatalf("%s: exit %d: %s", name, code, errOut)
28 }
29 pinPlain(t, name, out.String())
30 }
31}
32
33func TestMRListScreen(t *testing.T) {
34 c := screenCtx(100, false)
35 c.User = store.User{Username: "alice"}
36 mrs := []store.MR{{ID: 1, Number: 552, UpdatedAt: "2026-10-01T11:40:00Z"}, {ID: 2, Number: 551, UpdatedAt: "2026-10-01T10:00:00Z"}}
37 ds := []mrOut{
38 {Number: 552, Title: "wire $PAGER", State: "open", Source: "cli-pager", TargetRef: "main"},
39 {Number: 551, Title: "changelog", State: "open", Source: "release", TargetRef: "stable"},
40 }
41 checks := map[int64]cell{1: cMark("✓ 2/2", sgrGreen), 2: cMark("✗ 1 failed", sgrRed)}
42 review := map[int64]cell{1: cMark("review requested", sgrYellow)}
43 s := mrListScreen(c, screenRepo, "open", mrs, ds, checks, review)
44 if len(s.sections) != 1 || s.sections[0].title != "Open merge requests" || s.sections[0].n != 2 {
45 t.Fatalf("sections = %+v", s.sections)
46 }
47 if g := s.sections[0].rows[0].cells[1]; g.s != "●" {
48 t.Errorf("review requested of the viewer should lead with ●, got %q", g.s)
49 }
50 if g := s.sections[0].rows[1].cells[1]; g.s != "✗" || g.sgr != sgrRed {
51 t.Errorf("failed checks should lead with a red ✗, got %+v", g)
52 }
53 out := renderString(c, s)
54 if !strings.Contains(out, "release → stable") {
55 t.Errorf("non-default target not shown:\n%s", out)
56 }
57 var verbs []string
58 for _, a := range s.actions {
59 verbs = append(verbs, strings.Join(a.argv[:2], " "))
60 }
61 if !slices.Equal(verbs, []string{"mr create", "mr list"}) {
62 t.Errorf("actions = %q", verbs)
63 }
64 checkActions(t, s)
65}
66
67func TestIssueListScreen(t *testing.T) {
68 c := screenCtx(100, false)
69 c.User = store.User{Username: "alice"}
70 issues := []store.Issue{{ID: 1, Number: 12, UpdatedAt: "2026-09-01T10:00:00Z"}, {ID: 2, Number: 13, UpdatedAt: "2026-09-02T10:00:00Z"}}
71 ds := []issueOut{{Number: 12, Title: "Android app", State: "open"}, {Number: 13, Title: "Docs", State: "open"}}
72 s := issueListScreen(c, screenRepo, "open", issues, ds, map[int64]int{1: 3}, map[int64][]string{1: {"mobile"}}, map[int64][]string{1: {"alice"}})
73 sec := s.sections[0]
74 if sec.title != "Open issues" || sec.n != 2 {
75 t.Fatalf("section = %+v", sec)
76 }
77 if g := sec.rows[0].cells[1]; g.s != "●" {
78 t.Errorf("assigned to the viewer should lead with ●, got %q", g.s)
79 }
80 if out := renderString(c, s); !strings.Contains(out, "mobile · alice · 3 comments") {
81 t.Errorf("meta:\n%s", out)
82 }
83 var verbs []string
84 for _, a := range s.actions {
85 verbs = append(verbs, strings.Join(a.argv, " "))
86 }
87 if !slices.Equal(verbs, []string{"issue create krz/gitbay", "issue list krz/gitbay --state closed"}) {
88 t.Errorf("actions = %q", verbs)
89 }
90 checkActions(t, s)
91}
92
93func TestBuildListScreen(t *testing.T) {
94 c := screenCtx(100, false)
95 ds := []BuildOut{
96 {Number: 1784, Job: "test", Status: "running", Ref: "cli-views-2a", Subject: "docs: screens", CreatedAt: "2026-10-01T12:00:00Z"},
97 {Number: 1783, Job: "build", Status: "success", Ref: "cli-views-2a", Subject: "docs: screens", CreatedAt: "2026-10-01T12:00:00Z"},
98 }
99 s := buildListScreen(c, screenRepo, ds)
100 sec := s.sections[0]
101 if sec.title != "Builds" || sec.n != 2 {
102 t.Fatalf("section = %+v", sec)
103 }
104 if g := sec.rows[0].cells[1]; g.s != "◐" {
105 t.Errorf("running build glyph = %q", g.s)
106 }
107 if len(s.actions) != 1 || strings.Join(s.actions[0].argv, " ") != "build show krz/gitbay 1784" {
108 t.Errorf("actions = %+v", s.actions)
109 }
110 checkActions(t, s)
111}
internal/control/marks.go −9
@@ -115,12 +115,3 @@ func labelsMark(labels []string) string {
115115 }
116116 return fmt.Sprintf("%s, +%d", strings.Join(labels[:2], ", "), len(labels)-2)
117117}
118
119// assigneesMark is a row's assignees, yellow when the viewer is one.
120func assigneesMark(assignees []string, viewer string) cell {
121 s := labelsMark(assignees)
122 if slices.Contains(assignees, viewer) {
123 return cMark(s, sgrYellow)
124 }
125 return cText(s)
126}
internal/control/marks_test.go +1 −29
@@ -59,38 +59,10 @@ func TestReviewMark(t *testing.T) {
5959 }
6060}
6161
62func TestLabelsAndAssignees(t *testing.T) {
62func TestLabelsMark(t *testing.T) {
6363 if got := labelsMark([]string{"a", "b", "c", "d"}); got != "a, b, +2" {
6464 t.Errorf("labelsMark = %q", got)
6565 }
66 if got := assigneesMark([]string{"me"}, "me"); got.sgr != sgrYellow {
67 t.Errorf("viewer's assignment not yellow: %+v", got)
68 }
69 if got := assigneesMark([]string{"bob"}, "me"); got.sgr != "" {
70 t.Errorf("someone else's assignment coloured: %+v", got)
71 }
72}
73
74func TestFailingBuilds(t *testing.T) {
75 builds := []DashboardBuild{
76 {Repo: "a/x", Job: "test", Ref: "main", Status: "success"},
77 {Repo: "a/x", Job: "test", Ref: "main", Status: "failure"}, // replaced by the newer success
78 {Repo: "a/y", Job: "pull", Ref: "main", Status: "failure"},
79 }
80 if got := failingBuilds(builds); got != 1 {
81 t.Errorf("failingBuilds = %d, want 1", got)
82 }
83}
84
85func TestNeedsYou(t *testing.T) {
86 term := Term{Cols: 80}
87 if got := term.needsYou(DashboardOut{}); got != "Nothing waits on you." {
88 t.Errorf("empty = %q", got)
89 }
90 d := DashboardOut{Reviews: make([]DashboardItem, 2), Unread: 1}
91 if got := term.needsYou(d); got != "Needs you: 2 reviews requested, 1 unread notification" {
92 t.Errorf("needsYou = %q", got)
93 }
9466}
9567
9668func TestStepState(t *testing.T) {
internal/control/mr.go +35 −20
@@ -685,11 +685,7 @@ func runMRList(c *Ctx, args []string) int {
685685 return c.fail(protocol.ExitFailure, "%v", err)
686686 }
687687 }
688 return c.emitPage(p, ds, next, func(w io.Writer) {
689 if c.Term.Cols > 0 {
690 mrListTerm(c, w, repo, mrs, ds, checks, review)
691 return
692 }
688 return c.emitPageView(p, ds, next, func(w io.Writer) {
693689 tb := c.table(w, "!", "STATE", "TITLE", "REF")
694690 for _, d := range ds {
695691 state := d.State
@@ -703,31 +699,50 @@ func runMRList(c *Ctx, args []string) int {
703699 tb.row(cells...)
704700 }
705701 tb.flush()
706 })
702 }, func() screen { return mrListScreen(c, repo, f.State, mrs, ds, checks, review) })
707703}
708704
709// mrListTerm is mr list at a terminal: where checks and review stand
710// for each open merge request, and when it last changed. The source
705// mrListScreen is mr list at a terminal: one section of merge requests,
706// each led by ● when its review waits on the viewer, otherwise by its
707// checks' mark, then title, review state, branch and age. The source
711708// branch alone names a merge request into the default branch.
712func mrListTerm(c *Ctx, w io.Writer, repo store.Repo, mrs []store.MR, ds []mrOut, checks, review map[int64]cell) {
713 tb := c.table(w, "!", "STATE", "TITLE", "BRANCH", "CHECKS", "REVIEW", "UPDATED")
709func mrListScreen(c *Ctx, repo store.Repo, state string, mrs []store.MR, ds []mrOut, checks, review map[int64]cell) screen {
710 title, ok := map[string]string{"open": "Open merge requests", "closed": "Closed merge requests", "merged": "Merged merge requests"}[state]
711 if !ok {
712 title = "Merge requests"
713 }
714 sec := section{title: title, n: len(ds)}
714715 for i, d := range ds {
715 state := d.State
716 if d.Draft {
717 state = "draft"
716 m := mrs[i]
717 lead := cell{kind: kindGlyph}
718 if ch := checks[m.ID]; ch.s != "" {
719 g, _, _ := strings.Cut(ch.s, " ")
720 lead = cell{kind: kindGlyph, s: g, sgr: ch.sgr}
718721 }
719 ref := d.Source
722 if review[m.ID].sgr == sgrYellow {
723 lead = cYou()
724 }
725 branch := d.Source
720726 if d.TargetRef != repo.DefaultBranch {
721 ref += " -> " + d.TargetRef
727 branch += " → " + d.TargetRef
722728 }
723729 if d.StackedOn != nil {
724 ref += fmt.Sprintf(" (on !%d)", d.StackedOn.Number)
730 branch += fmt.Sprintf(" (on !%d)", d.StackedOn.Number)
725731 }
726 m := mrs[i]
727 tb.row(cLink(fmt.Sprintf("!%d", d.Number), c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10))), cState(state), cFlex(d.Title), cText(ref),
728 checks[m.ID], review[m.ID], cAge(m.UpdatedAt))
732 if d.Draft {
733 branch = "draft · " + branch
734 }
735 ref := cLink(fmt.Sprintf("!%d", d.Number), c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10)))
736 sec.rows = append(sec.rows, rowOf(ref, lead, cFlex(d.Title), review[m.ID], cMeta(branch, relAge(m.UpdatedAt, termNow()))))
737 }
738 other := "all"
739 if state == "all" {
740 other = "open"
729741 }
730 tb.flush()
742 return screen{sections: []section{sec}, actions: []action{
743 {"New", []string{"mr", "create", repo.Path()}},
744 {"Filter", []string{"mr", "list", repo.Path(), "--state", other}},
745 }}
731746}
732747
733748// byWhom renders " by <user>", or nothing when the actor is unknown — an
internal/control/testdata/plain/build-list.txt added +1
@@ -0,0 +1 @@
11 test pending abc111 main
internal/control/testdata/plain/dashboard.txt added +15
@@ -0,0 +1,15 @@
1waiting on your review:
2 none
3assigned to you:
4 none
5open merge requests:
6 alice/app!2 two alice
7 alice/app!1 one alice
8open issues:
9 none
10pinned:
11 none
12recent activity:
13 none
14builds:
15 none
internal/control/testdata/plain/issue-list.txt added +1
@@ -0,0 +1 @@
1#1 open Android app alice
internal/control/testdata/plain/mr-list.txt added +2
@@ -0,0 +1,2 @@
1!2 open two feature2 -> main
2!1 open one feature1 -> main