Commit dcbfc5a2c8
Verified · cmc
Layout: unified · split
internal/control/marks.go +2 −2
| @@ -9,7 +9,7 @@ import ( | ||
| 9 | 9 | ) |
| 10 | 10 | |
| 11 | 11 | // 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. | |
| 13 | 13 | // The glyph leads so the column reads at a glance; the words stay for |
| 14 | 14 | // a terminal without colour. |
| 15 | 15 | func checksMark(sts []store.CommitStatus) cell { |
| @@ -30,7 +30,7 @@ func checksMark(sts []store.CommitStatus) cell { | ||
| 30 | 30 | case failed > 0: |
| 31 | 31 | return cMark(fmt.Sprintf("✗ %d failed", failed), sgrRed) |
| 32 | 32 | case pending > 0: |
| 33 | return cMark(fmt.Sprintf("• %d pending", pending), sgrDim) | |
| 33 | return cMark(fmt.Sprintf("◐ %d pending", pending), sgrDim) | |
| 34 | 34 | } |
| 35 | 35 | return cMark(fmt.Sprintf("✓ %d/%d", passed, len(sts)), sgrGreen) |
| 36 | 36 | } |
internal/control/marks_test.go +1 −1
| @@ -20,7 +20,7 @@ func TestChecksMark(t *testing.T) { | ||
| 20 | 20 | }{ |
| 21 | 21 | {nil, "", ""}, |
| 22 | 22 | {st("success", "success", "skipped"), "✓ 3/3", sgrGreen}, |
| 23 | {st("success", "pending"), "• 1 pending", sgrDim}, | |
| 23 | {st("success", "pending"), "◐ 1 pending", sgrDim}, | |
| 24 | 24 | {st("success", "failure", "error", "pending"), "✗ 2 failed", sgrRed}, |
| 25 | 25 | } |
| 26 | 26 | for _, tc := range cases { |
internal/control/mr.go +94 −2
| @@ -816,7 +816,11 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 816 | 816 | } |
| 817 | 817 | } |
| 818 | 818 | } |
| 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) { | |
| 820 | 824 | state := d.State |
| 821 | 825 | if d.Draft { |
| 822 | 826 | state = "draft" |
| @@ -967,7 +971,7 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 967 | 971 | v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) |
| 968 | 972 | v.reactions(cm.Reactions) |
| 969 | 973 | } |
| 970 | }) | |
| 974 | }, func() screen { return mrShowScreen(c, repo, d, files) }) | |
| 971 | 975 | } |
| 972 | 976 | |
| 973 | 977 | // reviewLine renders one review as fields prose: "reviewer verdict |
| @@ -2196,3 +2200,91 @@ func ReviewersWhoCount(st *store.Store, repo store.Repo, reviews []store.MRRevie | ||
| 2196 | 2200 | } |
| 2197 | 2201 | return counts |
| 2198 | 2202 | } |
| 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. | |
| 2207 | func 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 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 13 | func 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 | ||
| 34 | func 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 | ||
| 48 | func 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 | ||
| 58 | func 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 | ||
| 66 | var screenRepo = store.Repo{OwnerName: "krz", Name: "gitbay", DefaultBranch: "main"} | |
| 67 | ||
| 68 | func 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 | ||
| 83 | func 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 | ||
| 97 | func 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 | ||
| 109 | func 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 { | ||
| 27 | 27 | value []cell |
| 28 | 28 | } |
| 29 | 29 | |
| 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)". | |
| 33 | 34 | type section struct { |
| 34 | 35 | title string |
| 35 | 36 | n int |
| 37 | note string | |
| 36 | 38 | rows []row |
| 37 | 39 | more []string |
| 38 | 40 | empty bool |
| @@ -181,7 +183,11 @@ func (c *Ctx) renderSection(s section) string { | ||
| 181 | 183 | return "" |
| 182 | 184 | } |
| 183 | 185 | 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") | |
| 185 | 191 | if len(s.rows) > 0 { |
| 186 | 192 | tb := &table{term: c.Term, w: io.Discard} |
| 187 | 193 | for _, r := range s.rows { |
| @@ -277,3 +283,18 @@ func (c *Ctx) renderLegend(as []action) string { | ||
| 277 | 283 | } |
| 278 | 284 | return b.String() |
| 279 | 285 | } |
| 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)". | |
| 290 | func 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 | ||
| 8 | checks (2): | |
| 9 | build success <time> | |
| 10 | test success <time> | |
| 11 | ||
| 12 | ── alice, <time> (comment 1) | |
| 13 | ||
| 14 | Looks right. | |