Commit dcbfc5a2c8
Verified · cmc
Layout: unified · split
internal/control/marks.go +2 −2
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | ) | 9 | ) |
| 10 | 10 | ||
| 11 | // checksMark sums a commit's statuses for a list row at a terminal: | 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 | // The glyph leads so the column reads at a glance; the words stay for | 13 | // The glyph leads so the column reads at a glance; the words stay for |
| 14 | // a terminal without colour. | 14 | // a terminal without colour. |
| 15 | func checksMark(sts []store.CommitStatus) cell { | 15 | func checksMark(sts []store.CommitStatus) cell { |
| @@ -30,7 +30,7 @@ func checksMark(sts []store.CommitStatus) cell { | |||
| 30 | case failed > 0: | 30 | case failed > 0: |
| 31 | return cMark(fmt.Sprintf("✗ %d failed", failed), sgrRed) | 31 | return cMark(fmt.Sprintf("✗ %d failed", failed), sgrRed) |
| 32 | case pending > 0: | 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 | return cMark(fmt.Sprintf("✓ %d/%d", passed, len(sts)), sgrGreen) | 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 | {nil, "", ""}, | 21 | {nil, "", ""}, |
| 22 | {st("success", "success", "skipped"), "✓ 3/3", sgrGreen}, | 22 | {st("success", "success", "skipped"), "✓ 3/3", sgrGreen}, |
| 23 | {st("success", "pending"), "• 1 pending", sgrDim}, | 23 | {st("success", "pending"), "◐ 1 pending", sgrDim}, |
| 24 | {st("success", "failure", "error", "pending"), "✗ 2 failed", sgrRed}, | 24 | {st("success", "failure", "error", "pending"), "✗ 2 failed", sgrRed}, |
| 25 | } | 25 | } |
| 26 | for _, tc := range cases { | 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 | state := d.State | 824 | state := d.State |
| 821 | if d.Draft { | 825 | if d.Draft { |
| 822 | state = "draft" | 826 | state = "draft" |
| @@ -967,7 +971,7 @@ func runMRShow(c *Ctx, args []string) int { | |||
| 967 | v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) | 971 | v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) |
| 968 | v.reactions(cm.Reactions) | 972 | v.reactions(cm.Reactions) |
| 969 | } | 973 | } |
| 970 | }) | 974 | }, func() screen { return mrShowScreen(c, repo, d, files) }) |
| 971 | } | 975 | } |
| 972 | 976 | ||
| 973 | // reviewLine renders one review as fields prose: "reviewer verdict | 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 | return counts | 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 | value []cell | 27 | value []cell |
| 28 | } | 28 | } |
| 29 | 29 | ||
| 30 | // section is a titled run of rows. n is the total, shown as "(n)"; when | 30 | // section is a titled run of rows. n is the total, shown as "(n)", and |
| 31 | // it is more than the rows shown, more is the command for the rest. An | 31 | // note follows it dim ("+86 −12"); when n is more than the rows shown, |
| 32 | // empty section is left out unless empty says to draw "(0)". | 32 | // more is the command for the rest. An empty section is left out unless |
| 33 | // empty says to draw "(0)". | ||
| 33 | type section struct { | 34 | type section struct { |
| 34 | title string | 35 | title string |
| 35 | n int | 36 | n int |
| 37 | note string | ||
| 36 | rows []row | 38 | rows []row |
| 37 | more []string | 39 | more []string |
| 38 | empty bool | 40 | empty bool |
| @@ -181,7 +183,11 @@ func (c *Ctx) renderSection(s section) string { | |||
| 181 | return "" | 183 | return "" |
| 182 | } | 184 | } |
| 183 | var b strings.Builder | 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 | if len(s.rows) > 0 { | 191 | if len(s.rows) > 0 { |
| 186 | tb := &table{term: c.Term, w: io.Discard} | 192 | tb := &table{term: c.Term, w: io.Discard} |
| 187 | for _, r := range s.rows { | 193 | for _, r := range s.rows { |
| @@ -277,3 +283,18 @@ func (c *Ctx) renderLegend(as []action) string { | |||
| 277 | } | 283 | } |
| 278 | return b.String() | 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. | ||