Commit d35194d66f

d35194d66f62ef352d24ce5c3da0a6c05f26db7b

parent: 3c3e6057d5

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 03:36 UTC

control: mr, issue and build lists as screens

Ref #318

Layout: unified · split

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/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 −7
@@ -59,16 +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 }
7266}
7367
7468func TestFailingBuilds(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/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