Commit dcbfc5a2c8

dcbfc5a2c853244138682cd35930a11f5050fa83

parent: bc635c981b

Verified · cmc

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

control: mr show as a screen

Pending checks lead with ◐, the screen glyph for something still going.

Ref #318

Layout: unified · split

internal/control/marks.go +2 −2
@@ -9,7 +9,7 @@ import (
99)
1010
1111// checksMark sums a commit's statuses for a list row at a terminal:
12// "✗ 2 failed" red, "• 1 pending" dim, "✓ 3/3" green, blank with none.
12// "✗ 2 failed" red, "◐ 1 pending" dim, "✓ 3/3" green, blank with none.
1313// The glyph leads so the column reads at a glance; the words stay for
1414// a terminal without colour.
1515func checksMark(sts []store.CommitStatus) cell {
@@ -30,7 +30,7 @@ func checksMark(sts []store.CommitStatus) cell {
3030 case failed > 0:
3131 return cMark(fmt.Sprintf("✗ %d failed", failed), sgrRed)
3232 case pending > 0:
33 return cMark(fmt.Sprintf("• %d pending", pending), sgrDim)
33 return cMark(fmt.Sprintf("◐ %d pending", pending), sgrDim)
3434 }
3535 return cMark(fmt.Sprintf("✓ %d/%d", passed, len(sts)), sgrGreen)
3636}
internal/control/marks_test.go +1 −1
@@ -20,7 +20,7 @@ func TestChecksMark(t *testing.T) {
2020 }{
2121 {nil, "", ""},
2222 {st("success", "success", "skipped"), "✓ 3/3", sgrGreen},
23 {st("success", "pending"), "• 1 pending", sgrDim},
23 {st("success", "pending"), "◐ 1 pending", sgrDim},
2424 {st("success", "failure", "error", "pending"), "✗ 2 failed", sgrRed},
2525 }
2626 for _, tc := range cases {
internal/control/mr.go +94 −2
@@ -816,7 +816,11 @@ func runMRShow(c *Ctx, args []string) int {
816816 }
817817 }
818818 }
819 return c.emit(d, func(w io.Writer) {
819 var files []gitutil.NumStat
820 if c.Term.Cols > 0 && !c.JSON && base != "" {
821 files, _ = gitutil.DiffNumstat(dir, base, mrHeadRef(mr.Number))
822 }
823 return c.emitView(d, func(w io.Writer) {
820824 state := d.State
821825 if d.Draft {
822826 state = "draft"
@@ -967,7 +971,7 @@ func runMRShow(c *Ctx, args []string) int {
967971 v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat)
968972 v.reactions(cm.Reactions)
969973 }
970 })
974 }, func() screen { return mrShowScreen(c, repo, d, files) })
971975}
972976
973977// reviewLine renders one review as fields prose: "reviewer verdict
@@ -2196,3 +2200,91 @@ func ReviewersWhoCount(st *store.Store, repo store.Repo, reviews []store.MRRevie
21962200 }
21972201 return counts
21982202}
2203
2204// mrShowScreen is mr show at a terminal: where the merge request stands,
2205// its description, its commits, files and discussion, and what can be
2206// done about it now.
2207func mrShowScreen(c *Ctx, repo store.Repo, d MRShow, files []gitutil.NumStat) screen {
2208 n := strconv.FormatInt(d.Number, 10)
2209 path := repo.Path()
2210 state := d.State
2211 if d.Draft {
2212 state = "draft"
2213 }
2214 s := screen{body: d.Body, format: d.BodyFormat}
2215 s.fields = append(s.fields,
2216 field{"Merge", []cell{cLink("!"+n, c.siteURL(path, "mrs", n)), cText(d.Title)}},
2217 field{"State", []cell{cState(state), cMeta(d.Source+" → "+d.TargetRef, d.Author, relAge(d.CreatedAt, termNow()))}},
2218 )
2219 if len(d.Checks) > 0 {
2220 sts := make([]store.CommitStatus, len(d.Checks))
2221 for i, x := range d.Checks {
2222 sts[i] = store.CommitStatus{Context: x.Context, State: x.State}
2223 }
2224 s.fields = append(s.fields, field{"Checks", []cell{checksMark(sts)}})
2225 }
2226 open := d.State == "open" || d.State == "source_gone"
2227 behind := false
2228 if g := d.Gates; g != nil && open {
2229 behind = !g.FastForward
2230 if len(g.Unmet) == 0 {
2231 s.fields = append(s.fields, field{"Gates", []cell{cMark("✓ ready to merge", sgrGreen)}})
2232 } else {
2233 parts := make([]string, len(g.Unmet))
2234 for i, u := range g.Unmet {
2235 parts[i] = "✗ " + u
2236 }
2237 s.fields = append(s.fields, field{"Gates", []cell{cMark(strings.Join(parts, " · "), sgrRed)}})
2238 }
2239 }
2240 if len(d.ReviewRequests) > 0 {
2241 v := cText("requested of " + strings.Join(d.ReviewRequests, ", "))
2242 if slices.Contains(d.ReviewRequests, c.User.Username) {
2243 v.sgr = sgrYellow
2244 }
2245 s.fields = append(s.fields, field{"Review", []cell{v}})
2246 }
2247 if d.Milestone != "" {
2248 s.fields = append(s.fields, field{"Milestone", []cell{cText(d.Milestone)}})
2249 }
2250 if len(d.Labels) > 0 {
2251 s.fields = append(s.fields, field{"Labels", []cell{cText(strings.Join(d.Labels, ", "))}})
2252 }
2253 if !c.Term.Links {
2254 s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "mrs", n))}})
2255 }
2256
2257 commits := section{title: "Commits", n: len(d.Commits)}
2258 for _, cm := range d.Commits {
2259 commits.rows = append(commits.rows, rowOf(cRef(fmt.Sprintf("%.7s", cm.SHA)), cFlex(cm.Subject)))
2260 }
2261 fs := section{title: "Files", n: len(files)}
2262 add, del := 0, 0
2263 for _, f := range files {
2264 counts := "binary"
2265 if f.Added >= 0 {
2266 add, del = add+f.Added, del+f.Deleted
2267 counts = fmt.Sprintf("+%d −%d", f.Added, f.Deleted)
2268 }
2269 fs.rows = append(fs.rows, rowOf(cRef(f.Status), cFlex(f.Path), cMeta(counts)))
2270 }
2271 if len(files) > 0 {
2272 fs.note = fmt.Sprintf("+%d −%d", add, del)
2273 }
2274 s.sections = []section{commits, fs, discussion(d.Comments)}
2275
2276 if open {
2277 if behind {
2278 s.actions = append(s.actions, action{"Unblock", []string{"mr", "rebase", n}})
2279 }
2280 s.actions = append(s.actions,
2281 action{"Review", []string{"mr", "review", path, n, "--approve"}},
2282 action{"Review", []string{"mr", "comment", path, n}},
2283 )
2284 if d.Gates != nil && len(d.Gates.Unmet) == 0 {
2285 s.actions = append(s.actions, action{"Merge", []string{"mr", "merge", path, n}})
2286 }
2287 }
2288 s.actions = append(s.actions, action{"Read", []string{"mr", "diff", path, n}})
2289 return s
2290}
internal/control/mrscreen_test.go added +132
@@ -0,0 +1,132 @@
1package control
2
3import (
4 "strings"
5 "testing"
6 "time"
7
8 "gitbay.org/gitbay/internal/gitutil"
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func TestMRShowPlainPinned(t *testing.T) {
14 st, repo, owner := twoMRTestRepo(t)
15 mr, err := st.MRByNumber(repo.ID, 1)
16 if err != nil {
17 t.Fatal(err)
18 }
19 if err := st.AddMRComment(mr.ID, owner.ID, "Looks right.", "md"); err != nil {
20 t.Fatal(err)
21 }
22 for _, ctx := range []string{"test", "build"} {
23 if err := st.SetCommitStatus(repo.ID, "abc111", ctx, "success", "", "", owner.ID); err != nil {
24 t.Fatal(err)
25 }
26 }
27 c, out, errOut := mrTestCtx(st, owner)
28 if code := Dispatch(c, []string{"mr", "show", repo.Path(), "1"}); code != protocol.ExitOK {
29 t.Fatalf("exit %d: %s", code, errOut)
30 }
31 pinPlain(t, "mr-show", out.String())
32}
33
34func mrShowFixture() MRShow {
35 d := MRShow{mrOut: mrOut{Number: 552, Title: "wire $PAGER through long views", State: "open",
36 Author: "cmc", Source: "cli-pager", TargetRef: "main", Body: "Pages long views.", BodyFormat: "md",
37 CreatedAt: "2026-10-01T11:40:00Z"}}
38 d.Commits = []CommitOut{{"8f3a1c2aaaaaaaa", "cli: page long output"}, {"2b77e90bbbbbbbb", "control: mark views"}}
39 d.Checks = []CheckOut{{Context: "test", State: "pending"}, {Context: "build", State: "success"}}
40 d.Gates = &GatesOut{ApprovalsRequired: 1, FastForward: false, Unmet: []string{"needs 1 approval", "behind main"}}
41 d.Comments = []commentOut{
42 {ID: 1, Author: "alice", Body: "Looks right.", BodyFormat: "md", CreatedAt: "2026-10-01T11:50:00Z", Kind: "comment"},
43 {ID: 2, Author: "cmc", Body: "marked ready", CreatedAt: "2026-10-01T11:51:00Z", Kind: "system"},
44 }
45 return d
46}
47
48func actionArgvs(s screen, group string) [][]string {
49 var out [][]string
50 for _, a := range s.actions {
51 if a.group == group {
52 out = append(out, a.argv)
53 }
54 }
55 return out
56}
57
58func sectionCounts(s screen) map[string]int {
59 m := map[string]int{}
60 for _, sec := range s.sections {
61 m[sec.title] = sec.n
62 }
63 return m
64}
65
66var screenRepo = store.Repo{OwnerName: "krz", Name: "gitbay", DefaultBranch: "main"}
67
68func TestMRShowScreenBehind(t *testing.T) {
69 c := screenCtx(100, false)
70 s := mrShowScreen(c, screenRepo, mrShowFixture(), []gitutil.NumStat{{Path: "cmd/gitbay/ssh.go", Added: 41, Deleted: 6, Status: "M"}})
71 if got := actionArgvs(s, "Unblock"); len(got) != 1 || got[0][1] != "rebase" {
72 t.Errorf("Unblock = %q, want mr rebase", got)
73 }
74 if got := actionArgvs(s, "Merge"); len(got) != 0 {
75 t.Errorf("Merge offered with unmet gates: %q", got)
76 }
77 if n := sectionCounts(s); n["Commits"] != 2 || n["Files"] != 1 || n["Discussion"] != 1 {
78 t.Errorf("sections = %v", n)
79 }
80 checkActions(t, s)
81}
82
83func TestMRShowScreenReady(t *testing.T) {
84 c := screenCtx(100, false)
85 d := mrShowFixture()
86 d.Gates = &GatesOut{FastForward: true}
87 s := mrShowScreen(c, screenRepo, d, nil)
88 if len(actionArgvs(s, "Unblock")) != 0 {
89 t.Error("Unblock offered when nothing blocks")
90 }
91 if got := actionArgvs(s, "Merge"); len(got) != 1 || got[0][1] != "merge" {
92 t.Errorf("Merge = %q", got)
93 }
94 checkActions(t, s)
95}
96
97func TestMRShowScreenMergedOffersOnlyReading(t *testing.T) {
98 c := screenCtx(100, false)
99 d := mrShowFixture()
100 d.State, d.Gates = "merged", nil
101 s := mrShowScreen(c, screenRepo, d, nil)
102 for _, a := range s.actions {
103 if a.group != "Read" {
104 t.Errorf("merged MR offers %q", a.argv)
105 }
106 }
107}
108
109func TestMRShowScreenRenders(t *testing.T) {
110 termNow = func() time.Time { return time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) }
111 t.Cleanup(func() { termNow = time.Now })
112 c := screenCtx(100, false)
113 c.Term.Here = "krz/gitbay"
114 s := mrShowScreen(c, screenRepo, mrShowFixture(), []gitutil.NumStat{{Path: "cmd/gitbay/ssh.go", Added: 41, Deleted: 6, Status: "M"}})
115 out := renderString(c, s)
116 for _, want := range []string{
117 "Merge: !552 wire $PAGER through long views\n",
118 "Gates: ✗ needs 1 approval · ✗ behind main\n",
119 "Pages long views.\n",
120 "Files (1) +41 −6\n",
121 "alice 10m ago\n Looks right.\n",
122 "gitbay mr rebase 552",
123 "gitbay mr diff 552",
124 } {
125 if !strings.Contains(out, want) {
126 t.Errorf("missing %q in:\n%s", want, out)
127 }
128 }
129 if strings.Contains(out, "marked ready") {
130 t.Errorf("system event in the discussion:\n%s", out)
131 }
132}
internal/control/screen.go +25 −4
@@ -27,12 +27,14 @@ type field struct {
2727 value []cell
2828}
2929
30// section is a titled run of rows. n is the total, shown as "(n)"; when
31// it is more than the rows shown, more is the command for the rest. An
32// empty section is left out unless empty says to draw "(0)".
30// section is a titled run of rows. n is the total, shown as "(n)", and
31// note follows it dim ("+86 −12"); when n is more than the rows shown,
32// more is the command for the rest. An empty section is left out unless
33// empty says to draw "(0)".
3334type section struct {
3435 title string
3536 n int
37 note string
3638 rows []row
3739 more []string
3840 empty bool
@@ -181,7 +183,11 @@ func (c *Ctx) renderSection(s section) string {
181183 return ""
182184 }
183185 var b strings.Builder
184 b.WriteString(c.Term.paint(sgrBold+sgrBlue, fmt.Sprintf("%s (%d)", s.title, s.n)) + "\n")
186 b.WriteString(c.Term.paint(sgrBold+sgrBlue, fmt.Sprintf("%s (%d)", s.title, s.n)))
187 if s.note != "" {
188 b.WriteString(" " + c.Term.paint(sgrDim, termSafe(s.note)))
189 }
190 b.WriteString("\n")
185191 if len(s.rows) > 0 {
186192 tb := &table{term: c.Term, w: io.Discard}
187193 for _, r := range s.rows {
@@ -277,3 +283,18 @@ func (c *Ctx) renderLegend(as []action) string {
277283 }
278284 return b.String()
279285}
286
287// discussion is a thread's comments as a section, each author and age
288// with its body beneath. System events are left out. It is drawn when
289// empty, as "(0)".
290func discussion(cs []commentOut) section {
291 sec := section{title: "Discussion", empty: true}
292 for _, cm := range cs {
293 if cm.Kind == "system" {
294 continue
295 }
296 sec.n++
297 sec.rows = append(sec.rows, row{cells: []cell{cText(cm.Author), cAge(cm.CreatedAt)}, body: cm.Body, format: cm.BodyFormat})
298 }
299 return sec
300}
internal/control/testdata/plain/mr-show.txt added +14
@@ -0,0 +1,14 @@
1!1 one open
2
3 author alice, <time>
4 source feature1 -> main
5 head abc111
6 url /alice/app/mrs/1
7
8checks (2):
9build success <time>
10test success <time>
11
12── alice, <time> (comment 1)
13
14 Looks right.