| @@ -0,0 +1,2553 @@ |
| |
1 | # CLI views implementation plan (stages 1 and 2) |
| |
2 | |
| |
3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| |
4 | |
| |
5 | **Goal:** Terminal output drawn as magit-style screens (header block, body, counted sections, action legend), with every unmigrated list restyled through the same row renderer. |
| |
6 | |
| |
7 | **Architecture:** A declarative `screen` value built by a command at a terminal and drawn by one renderer in `internal/control/screen.go`. The row layout reuses `table`'s width logic; `table` itself drops its header row and cyan. `emitView` and `emitPageView` route `--json` and piped output to the existing code unchanged. The CLI tells the server which repository it inferred (`here=` in `--term`) so legend commands can omit it. |
| |
8 | |
| |
9 | **Tech Stack:** Go, the `internal/control` registry, `internal/termtext` for markup, `git` via `internal/gitutil`. |
| |
10 | |
| |
11 | **Spec:** `docs/specs/2026-10-01-cli-views-design.md` |
| |
12 | |
| |
13 | **Issues:** #316 (tracking), #317 (stage 1), #318 (stage 2), #319 (stage 3) |
| |
14 | |
| |
15 | ## Global Constraints |
| |
16 | |
| |
17 | - Piped output and `--json` are byte-identical before and after every task. |
| |
18 | - All rendering is server-side in `internal/control`; `cmd/gitbay` only adds `here=` to `--term`. |
| |
19 | - Colour is never the only signal; `stripSGR` of a colour render equals the colourless render. |
| |
20 | - Colours: blue (`\x1b[34m`) for section headings and legend commands; yellow for what waits on the viewer; green/red/none for state. Refs are dim, never cyan. |
| |
21 | - Glyphs: `✓` passed/ok, `✗` failed/blocked, `◐` running/pending, `●` waiting on you, `○` closed/draft. |
| |
22 | - Commit messages end with `Ref #N` / `Closes #N` for the stage issue. No attribution lines. |
| |
23 | - Locally: `go build ./...`, `go vet ./...`, `go test ./internal/control/ ./internal/gitutil/ ./cmd/gitbay/`. The e2e suite runs in CI. |
| |
24 | - Commits are signed (the repository requires it); MRs merge with `--strategy ff`. |
| |
25 | |
| |
26 | ## Corrections to the spec found while planning |
| |
27 | |
| |
28 | These are applied to the spec in the same commit as this plan: |
| |
29 | |
| |
30 | 1. There is no `browse` command. The legend never offers one; the header's |
| |
31 | first field links to the page (OSC 8) where the terminal shows links, |
| |
32 | and a `URL:` field carries it otherwise. |
| |
33 | 2. `mr rebase`, `mr checkout` and `repo clone` are CLI-local (`cmd/gitbay/local.go`), |
| |
34 | not registry commands. The legend may name them; the resolve test |
| |
35 | accepts them from a fixed list. |
| |
36 | 3. The server cannot see the client's working directory. The CLI sends |
| |
37 | `here=<owner/name>` in `--term` when it is inside a clone it can infer; |
| |
38 | legend commands drop an argument equal to it. |
| |
39 | 4. Actions carry no label: the legend prints group names and commands. |
| |
40 | 5. A table that has not migrated keeps its dim header row when it has a |
| |
41 | number or size column (`admin runners`, `admin stats`): without a |
| |
42 | header those columns are unreadable. Every other table drops it. |
| |
43 | 6. `mr show` commits carry no signature glyph: `CommitOut` has no |
| |
44 | signature state, and verifying each commit on every show is not worth |
| |
45 | the cost. `repo log` already shows it. |
| |
46 | 7. Discussion rows carry their full body beneath them. A show screen that |
| |
47 | printed only first lines would lose what `issue show` prints today. |
| |
48 | |
| |
49 | ## File structure |
| |
50 | |
| |
51 | | File | Responsibility | |
| |
52 | |---|---| |
| |
53 | | `internal/control/screen.go` (new) | `screen`, `field`, `section`, `row`, `action`; `render`; legend layout; `cmdline` | |
| |
54 | | `internal/control/screen_test.go` (new) | renderer goldens, legend layout, `checkActions` helper | |
| |
55 | | `internal/control/plain_test.go` (new) | `pinPlain` golden helper for piped output | |
| |
56 | | `internal/control/testdata/plain/*.txt` (new) | pinned piped output per migrated command | |
| |
57 | | `internal/control/table.go` | new cell kinds, header rule, dim refs, `lines()` split from `flush()` | |
| |
58 | | `internal/control/term.go` | `sgrBlue`, `glyph()`, `Term.Here` | |
| |
59 | | `internal/control/view.go` | ref painted dim in `title` | |
| |
60 | | `internal/control/control.go` | `emitView` | |
| |
61 | | `internal/control/cursor.go` | `emitPageView`, `nextArgv`, restyled next-page line | |
| |
62 | | `internal/gitutil/numstat.go` (new) | `DiffNumstat` | |
| |
63 | | `internal/control/mr.go`, `issue.go`, `repo.go`, `build.go`, `dashboard.go` | screen builders and `emitView` calls | |
| |
64 | | `cmd/gitbay/ssh.go` | `here=` | |
| |
65 | | `.gitbay/wiki/Users.org` | "Output rules" rewritten | |
| |
66 | | `CHANGELOG.org` | unreleased entries | |
| |
67 | |
| |
68 | --- |
| |
69 | |
| |
70 | ## Stage 1 (one MR, Ref #317) |
| |
71 | |
| |
72 | ### Task 1: Palette, glyph cells, dim refs |
| |
73 | |
| |
74 | **Files:** |
| |
75 | - Modify: `internal/control/term.go` (const block at line 53, add `glyph`) |
| |
76 | - Modify: `internal/control/table.go` (cell kinds, constructors, `joinRow`, `fit`) |
| |
77 | - Modify: `internal/control/view.go:99,106` |
| |
78 | - Test: `internal/control/term_test.go`, `internal/control/table_test.go` |
| |
79 | |
| |
80 | **Interfaces:** |
| |
81 | - Produces: `sgrBlue`; `glyph(state string) (g, sgr string)`; `kindGlyph`, `kindMeta`; `cGlyph(state string) cell`; `cYou() cell`; `cMeta(parts ...string) cell`. |
| |
82 | |
| |
83 | - [ ] **Step 1: Write failing tests** |
| |
84 | |
| |
85 | Append to `internal/control/term_test.go`: |
| |
86 | |
| |
87 | ```go |
| |
88 | func TestGlyph(t *testing.T) { |
| |
89 | for _, c := range []struct{ state, g, sgr string }{ |
| |
90 | {"success", "✓", sgrGreen}, |
| |
91 | {"approved", "✓", sgrGreen}, |
| |
92 | {"merged", "✓", ""}, |
| |
93 | {"failure", "✗", sgrRed}, |
| |
94 | {"changes requested", "✗", sgrRed}, |
| |
95 | {"signed_key_revoked", "✗", sgrRed}, |
| |
96 | {"running", "◐", ""}, |
| |
97 | {"pending", "◐", ""}, |
| |
98 | {"closed", "○", ""}, |
| |
99 | {"draft", "○", ""}, |
| |
100 | {"open", "", ""}, |
| |
101 | {"", "", ""}, |
| |
102 | } { |
| |
103 | g, sgr := glyph(c.state) |
| |
104 | if g != c.g || sgr != c.sgr { |
| |
105 | t.Errorf("glyph(%q) = %q %q, want %q %q", c.state, g, sgr, c.g, c.sgr) |
| |
106 | } |
| |
107 | } |
| |
108 | } |
| |
109 | ``` |
| |
110 | |
| |
111 | Append to `internal/control/table_test.go`: |
| |
112 | |
| |
113 | ```go |
| |
114 | func TestRefsDimNotCyan(t *testing.T) { |
| |
115 | var b strings.Builder |
| |
116 | tb := (&Ctx{Term: Term{Cols: 80, Color: true}}).table(&b, "#", "TITLE") |
| |
117 | tb.row(cRef("#12"), cFlex("Android app")) |
| |
118 | tb.flush() |
| |
119 | if strings.Contains(b.String(), sgrCyan) { |
| |
120 | t.Errorf("ref painted cyan: %q", b.String()) |
| |
121 | } |
| |
122 | if !strings.Contains(b.String(), sgrDim+"#12"+sgrReset) { |
| |
123 | t.Errorf("ref not dim: %q", b.String()) |
| |
124 | } |
| |
125 | } |
| |
126 | |
| |
127 | func TestGlyphAndMetaCells(t *testing.T) { |
| |
128 | var b strings.Builder |
| |
129 | tb := (&Ctx{Term: Term{Cols: 80, Color: true}}).table(&b) |
| |
130 | tb.row(cRef("1779"), cGlyph("failure"), cFlex("test"), cMeta("gitbay", "2h")) |
| |
131 | tb.row(cRef("1780"), cYou(), cFlex("review"), cMeta("", "3h")) |
| |
132 | tb.flush() |
| |
133 | out := b.String() |
| |
134 | for _, want := range []string{sgrRed + "✗" + sgrReset, sgrYellow + "●" + sgrReset, sgrDim + "gitbay · 2h" + sgrReset, sgrDim + "3h" + sgrReset} { |
| |
135 | if !strings.Contains(out, want) { |
| |
136 | t.Errorf("missing %q in %q", want, out) |
| |
137 | } |
| |
138 | } |
| |
139 | } |
| |
140 | ``` |
| |
141 | |
| |
142 | - [ ] **Step 2: Run to confirm failure** |
| |
143 | |
| |
144 | Run: `go test ./internal/control/ -run 'TestGlyph|TestRefsDimNotCyan|TestGlyphAndMetaCells'` |
| |
145 | Expected: build failure, `undefined: glyph`, `undefined: cGlyph`. |
| |
146 | |
| |
147 | - [ ] **Step 3: Implement** |
| |
148 | |
| |
149 | In `term.go`, add `sgrBlue = "\x1b[34m"` to the const block after `sgrYellow`, and after `stateColor`: |
| |
150 | |
| |
151 | ```go |
| |
152 | // glyph is the mark a screen puts first in a row or field for a state |
| |
153 | // word, and its colour: ✓ passed, ✗ failed or blocked, ◐ still going, |
| |
154 | // ○ closed or draft. A state with no mark ("open") returns "". |
| |
155 | func glyph(state string) (g, sgr string) { |
| |
156 | switch state { |
| |
157 | case "merged": |
| |
158 | return "✓", "" |
| |
159 | case "success", "ok", "approved", "verified", "passed": |
| |
160 | return "✓", sgrGreen |
| |
161 | case "failure", "failed", "error", "changes requested", |
| |
162 | "bad_signature", "signed_email_mismatch", "signed_key_expired", "signed_key_revoked": |
| |
163 | return "✗", sgrRed |
| |
164 | case "pending", "running", "queued": |
| |
165 | return "◐", "" |
| |
166 | case "closed", "draft", "canceled", "cancelled", "skipped": |
| |
167 | return "○", "" |
| |
168 | } |
| |
169 | return "", "" |
| |
170 | } |
| |
171 | ``` |
| |
172 | |
| |
173 | In `table.go`, extend the kind list and constructors: |
| |
174 | |
| |
175 | ```go |
| |
176 | const ( |
| |
177 | kindText cellKind = iota |
| |
178 | kindFlex |
| |
179 | kindRef |
| |
180 | kindState |
| |
181 | kindAge |
| |
182 | kindNum |
| |
183 | kindSize |
| |
184 | kindSwatch |
| |
185 | kindGlyph |
| |
186 | kindMeta |
| |
187 | ) |
| |
188 | ``` |
| |
189 | |
| |
190 | ```go |
| |
191 | // cGlyph is a state's mark, coloured for the state. |
| |
192 | func cGlyph(state string) cell { |
| |
193 | g, sgr := glyph(state) |
| |
194 | return cell{kind: kindGlyph, s: g, sgr: sgr} |
| |
195 | } |
| |
196 | |
| |
197 | // cYou is the mark for a row that waits on the viewer. |
| |
198 | func cYou() cell { return cell{kind: kindGlyph, s: "●", sgr: sgrYellow} } |
| |
199 | |
| |
200 | // cMeta is a row's trailing facts, dim and joined by " · ". Empty parts |
| |
201 | // are skipped. |
| |
202 | func cMeta(parts ...string) cell { |
| |
203 | var keep []string |
| |
204 | for _, p := range parts { |
| |
205 | if p != "" { |
| |
206 | keep = append(keep, p) |
| |
207 | } |
| |
208 | } |
| |
209 | return cell{kind: kindMeta, s: strings.Join(keep, " · ")} |
| |
210 | } |
| |
211 | ``` |
| |
212 | |
| |
213 | In `joinRow`, replace the `kindRef` case and add two cases: |
| |
214 | |
| |
215 | ```go |
| |
216 | case kindRef: |
| |
217 | s = t.term.link(r[i].url, t.term.paint(sgrDim, s)) |
| |
218 | case kindGlyph: |
| |
219 | s = t.term.paint(r[i].sgr, s) |
| |
220 | case kindMeta: |
| |
221 | s = t.term.paint(sgrDim, s) |
| |
222 | ``` |
| |
223 | |
| |
224 | In `fit`, let metadata shrink with text: |
| |
225 | |
| |
226 | ```go |
| |
227 | for i := len(kinds) - 1; i >= 0; i-- { |
| |
228 | if kinds[i] == kindText || kinds[i] == kindMeta { |
| |
229 | shrink(i) |
| |
230 | } |
| |
231 | } |
| |
232 | ``` |
| |
233 | |
| |
234 | In `view.go`, lines 99 and 106, replace `sgrCyan` with `sgrDim`. |
| |
235 | |
| |
236 | - [ ] **Step 4: Run tests** |
| |
237 | |
| |
238 | Run: `go test ./internal/control/` |
| |
239 | Expected: the three new tests pass. Any existing test asserting `sgrCyan` on a ref (search: `grep -n sgrCyan internal/control/*_test.go`) is updated to `sgrDim` in this step; diff and tree tests that expect cyan hunk headers and directory names stay as they are. |
| |
240 | |
| |
241 | - [ ] **Step 5: Commit** |
| |
242 | |
| |
243 | ```bash |
| |
244 | git add internal/control/term.go internal/control/table.go internal/control/view.go internal/control/*_test.go |
| |
245 | git commit -m "control: glyph and meta cells, refs dim |
| |
246 | |
| |
247 | Ref #317" |
| |
248 | ``` |
| |
249 | |
| |
250 | ### Task 2: Header rows only where columns need naming |
| |
251 | |
| |
252 | **Files:** |
| |
253 | - Modify: `internal/control/table.go` (`flush`, new `numeric`, new `lines`) |
| |
254 | - Test: `internal/control/table_test.go` |
| |
255 | |
| |
256 | **Interfaces:** |
| |
257 | - Produces: `func (t *table) lines() []string` (terminal rows, padded and painted, header omitted); `flush` keeps its signature. |
| |
258 | |
| |
259 | - [ ] **Step 1: Write failing tests** |
| |
260 | |
| |
261 | ```go |
| |
262 | func TestTableDropsHeaderWithoutNumbers(t *testing.T) { |
| |
263 | var b strings.Builder |
| |
264 | tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "#", "STATE", "TITLE") |
| |
265 | tb.row(cRef("#12"), cState("open"), cFlex("Android app")) |
| |
266 | tb.flush() |
| |
267 | if got, want := b.String(), "#12 open Android app\n"; got != want { |
| |
268 | t.Errorf("got %q, want %q", got, want) |
| |
269 | } |
| |
270 | } |
| |
271 | |
| |
272 | func TestTableKeepsHeaderForNumbers(t *testing.T) { |
| |
273 | var b strings.Builder |
| |
274 | tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "RUNNER", "PENDING") |
| |
275 | tb.row(cText("bay1"), cNum(3)) |
| |
276 | tb.flush() |
| |
277 | if !strings.HasPrefix(b.String(), "RUNNER PENDING\n") { |
| |
278 | t.Errorf("header missing: %q", b.String()) |
| |
279 | } |
| |
280 | } |
| |
281 | |
| |
282 | func TestTableLines(t *testing.T) { |
| |
283 | tb := (&Ctx{Term: Term{Cols: 80}}).table(io.Discard) |
| |
284 | tb.row(cRef("!1"), cFlex("one")) |
| |
285 | tb.row(cRef("!22"), cFlex("two")) |
| |
286 | got := tb.lines() |
| |
287 | want := []string{"!1 one", "!22 two"} |
| |
288 | if !slices.Equal(got, want) { |
| |
289 | t.Errorf("got %q, want %q", got, want) |
| |
290 | } |
| |
291 | } |
| |
292 | ``` |
| |
293 | |
| |
294 | Add `"io"` and `"slices"` to the test file's imports if missing. |
| |
295 | |
| |
296 | - [ ] **Step 2: Run to confirm failure** |
| |
297 | |
| |
298 | Run: `go test ./internal/control/ -run 'TestTableDropsHeader|TestTableKeepsHeader|TestTableLines'` |
| |
299 | Expected: FAIL (header printed; `lines` undefined). |
| |
300 | |
| |
301 | - [ ] **Step 3: Implement** |
| |
302 | |
| |
303 | Replace `flush` with: |
| |
304 | |
| |
305 | ```go |
| |
306 | func (t *table) flush() { |
| |
307 | if t.term.Cols == 0 || len(t.rows) == 0 { |
| |
308 | return |
| |
309 | } |
| |
310 | var b strings.Builder |
| |
311 | for _, l := range t.lines() { |
| |
312 | b.WriteString(l + "\n") |
| |
313 | } |
| |
314 | io.WriteString(t.w, b.String()) |
| |
315 | } |
| |
316 | |
| |
317 | // numeric reports whether a column holds numbers or sizes, which need a |
| |
318 | // header to say what they count. |
| |
319 | func (t *table) numeric() bool { |
| |
320 | for _, r := range t.rows { |
| |
321 | for _, c := range r { |
| |
322 | if c.kind == kindNum || c.kind == kindSize { |
| |
323 | return true |
| |
324 | } |
| |
325 | } |
| |
326 | } |
| |
327 | return false |
| |
328 | } |
| |
329 | |
| |
330 | // lines lays the rows out at the terminal width: a dim header first |
| |
331 | // only when a column is a number, then each row padded and painted. |
| |
332 | func (t *table) lines() []string { |
| |
333 | if !t.numeric() { |
| |
334 | t.header = nil |
| |
335 | } |
| |
336 | t.dropEmpty() |
| |
337 | n := len(t.header) |
| |
338 | for _, r := range t.rows { |
| |
339 | n = max(n, len(r)) |
| |
340 | } |
| |
341 | widths := make([]int, n) |
| |
342 | for i, h := range t.header { |
| |
343 | widths[i] = cells(h) |
| |
344 | } |
| |
345 | for _, r := range t.rows { |
| |
346 | for i := 0; i < len(r); i++ { |
| |
347 | widths[i] = max(widths[i], cells(r[i].s)) |
| |
348 | } |
| |
349 | } |
| |
350 | t.capSparse(widths) |
| |
351 | t.fit(widths) |
| |
352 | |
| |
353 | var out []string |
| |
354 | line := make([]string, n) |
| |
355 | if len(t.header) > 0 { |
| |
356 | for i := range line { |
| |
357 | line[i] = "" |
| |
358 | if i < len(t.header) { |
| |
359 | line[i] = clip(t.header[i], widths[i]) |
| |
360 | } |
| |
361 | } |
| |
362 | out = append(out, t.term.paint(sgrDim, strings.TrimRight(t.join(line, widths), " "))) |
| |
363 | } |
| |
364 | for _, r := range t.rows { |
| |
365 | for i := 0; i < n; i++ { |
| |
366 | s := "" |
| |
367 | if i < len(r) { |
| |
368 | s = clip(r[i].s, widths[i]) |
| |
369 | } |
| |
370 | line[i] = s |
| |
371 | } |
| |
372 | out = append(out, strings.TrimRight(t.joinRow(r, line, widths), " ")) |
| |
373 | } |
| |
374 | return out |
| |
375 | } |
| |
376 | ``` |
| |
377 | |
| |
378 | Update the comment above `type table struct` to say the header is written only when a column is numeric. |
| |
379 | |
| |
380 | - [ ] **Step 4: Run tests** |
| |
381 | |
| |
382 | Run: `go test ./internal/control/` |
| |
383 | Expected: new tests pass. Existing terminal tests that expected a header line on a table without numbers fail; update each expected string to drop its first line (the header). Do not change any plain-output expectation. |
| |
384 | |
| |
385 | - [ ] **Step 5: Commit** |
| |
386 | |
| |
387 | ```bash |
| |
388 | git add internal/control/table.go internal/control/*_test.go |
| |
389 | git commit -m "control: tables name their columns only when they hold numbers |
| |
390 | |
| |
391 | Ref #317" |
| |
392 | ``` |
| |
393 | |
| |
394 | ### Task 3: The screen model and renderer |
| |
395 | |
| |
396 | **Files:** |
| |
397 | - Create: `internal/control/screen.go` |
| |
398 | - Create: `internal/control/screen_test.go` |
| |
399 | |
| |
400 | **Interfaces:** |
| |
401 | - Consumes: `table.lines()`, `cGlyph`, `cMeta`, `cYou`, `sgrBlue` (tasks 1–2). |
| |
402 | - Produces: |
| |
403 | |
| |
404 | ```go |
| |
405 | type screen struct { |
| |
406 | fields []field |
| |
407 | body string |
| |
408 | format string |
| |
409 | sections []section |
| |
410 | actions []action |
| |
411 | } |
| |
412 | type field struct { |
| |
413 | label string |
| |
414 | value []cell |
| |
415 | } |
| |
416 | type section struct { |
| |
417 | title string |
| |
418 | n int |
| |
419 | rows []row |
| |
420 | more []string |
| |
421 | empty bool |
| |
422 | } |
| |
423 | type row struct { |
| |
424 | cells []cell |
| |
425 | body string |
| |
426 | format string |
| |
427 | } |
| |
428 | type action struct { |
| |
429 | group string |
| |
430 | argv []string |
| |
431 | } |
| |
432 | func (c *Ctx) render(w io.Writer, s screen) |
| |
433 | func rowOf(cs ...cell) row // a row with no body |
| |
434 | ``` |
| |
435 | |
| |
436 | - [ ] **Step 1: Write failing tests** |
| |
437 | |
| |
438 | `internal/control/screen_test.go`: |
| |
439 | |
| |
440 | ```go |
| |
441 | package control |
| |
442 | |
| |
443 | import ( |
| |
444 | "strings" |
| |
445 | "testing" |
| |
446 | "time" |
| |
447 | ) |
| |
448 | |
| |
449 | func screenCtx(cols int, color bool) *Ctx { |
| |
450 | return &Ctx{Term: Term{Cols: cols, Color: color}} |
| |
451 | } |
| |
452 | |
| |
453 | func renderString(c *Ctx, s screen) string { |
| |
454 | var b strings.Builder |
| |
455 | c.render(&b, s) |
| |
456 | return b.String() |
| |
457 | } |
| |
458 | |
| |
459 | func sampleScreen() screen { |
| |
460 | return screen{ |
| |
461 | fields: []field{ |
| |
462 | {"Merge", []cell{cRef("!552"), cText("wire $PAGER through long views")}}, |
| |
463 | {"State", []cell{cState("open"), cMeta("cli-pager → main", "cmc")}}, |
| |
464 | {"Checks", []cell{cGlyph("running"), cText("1 running")}}, |
| |
465 | }, |
| |
466 | body: "Pages long views.", |
| |
467 | format: "markdown", |
| |
468 | sections: []section{ |
| |
469 | {title: "Commits", n: 2, rows: []row{ |
| |
470 | rowOf(cRef("8f3a1c2"), cFlex("cli: page long output")), |
| |
471 | rowOf(cRef("2b77e90"), cFlex("control: mark views")), |
| |
472 | }}, |
| |
473 | {title: "Discussion", n: 0, empty: true}, |
| |
474 | {title: "Hidden", n: 0}, |
| |
475 | }, |
| |
476 | } |
| |
477 | } |
| |
478 | |
| |
479 | func TestRenderPlainLayout(t *testing.T) { |
| |
480 | got := renderString(screenCtx(80, false), sampleScreen()) |
| |
481 | want := `Merge: !552 wire $PAGER through long views |
| |
482 | State: open cli-pager → main · cmc |
| |
483 | Checks: ◐ 1 running |
| |
484 | |
| |
485 | Pages long views. |
| |
486 | |
| |
487 | Commits (2) |
| |
488 | 8f3a1c2 cli: page long output |
| |
489 | 2b77e90 control: mark views |
| |
490 | |
| |
491 | Discussion (0) |
| |
492 | ` |
| |
493 | if got != want { |
| |
494 | t.Errorf("got:\n%s\nwant:\n%s", got, want) |
| |
495 | } |
| |
496 | } |
| |
497 | |
| |
498 | func TestRenderColourIsOnlyPaint(t *testing.T) { |
| |
499 | for _, cols := range []int{80, 120} { |
| |
500 | plain := renderString(screenCtx(cols, false), sampleScreen()) |
| |
501 | colour := renderString(screenCtx(cols, true), sampleScreen()) |
| |
502 | if stripSGR(colour) != plain { |
| |
503 | t.Errorf("cols %d: colour render differs beyond SGR:\n%s\n---\n%s", cols, stripSGR(colour), plain) |
| |
504 | } |
| |
505 | if !strings.Contains(colour, sgrBold+sgrBlue+"Commits (2)"+sgrReset) { |
| |
506 | t.Errorf("cols %d: heading not bold blue: %q", cols, colour) |
| |
507 | } |
| |
508 | if !strings.Contains(colour, sgrDim+"Merge:"+sgrReset) { |
| |
509 | t.Errorf("cols %d: label not dim: %q", cols, colour) |
| |
510 | } |
| |
511 | } |
| |
512 | } |
| |
513 | |
| |
514 | func TestRenderMoreLine(t *testing.T) { |
| |
515 | s := screen{sections: []section{{title: "Builds", n: 14, more: []string{"build", "list", "krz/gitbay"}, |
| |
516 | rows: []row{rowOf(cRef("1779"), cFlex("test"))}}}} |
| |
517 | got := renderString(screenCtx(80, false), s) |
| |
518 | if !strings.HasSuffix(got, "1779 test\n+13 more gitbay build list krz/gitbay\n") { |
| |
519 | t.Errorf("got %q", got) |
| |
520 | } |
| |
521 | } |
| |
522 | |
| |
523 | func TestRenderRowBody(t *testing.T) { |
| |
524 | s := screen{sections: []section{{title: "Discussion", n: 1, rows: []row{ |
| |
525 | {cells: []cell{cText("cmc"), cMeta("2h")}, body: "Looks good.", format: "markdown"}, |
| |
526 | }}}} |
| |
527 | got := renderString(screenCtx(80, false), s) |
| |
528 | want := "Discussion (1)\ncmc 2h\n Looks good.\n" |
| |
529 | if got != want { |
| |
530 | t.Errorf("got %q, want %q", got, want) |
| |
531 | } |
| |
532 | } |
| |
533 | |
| |
534 | func TestRenderFieldWraps(t *testing.T) { |
| |
535 | long := strings.Repeat("word ", 20) |
| |
536 | s := screen{fields: []field{{"Merge", []cell{cRef("!1"), cText(strings.TrimSpace(long))}}}} |
| |
537 | for _, l := range strings.Split(strings.TrimRight(renderString(screenCtx(40, false), s), "\n"), "\n") { |
| |
538 | if cells(l) > 40 { |
| |
539 | t.Errorf("line wider than 40: %q", l) |
| |
540 | } |
| |
541 | } |
| |
542 | } |
| |
543 | |
| |
544 | func init() { termNow = func() time.Time { return time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) } } |
| |
545 | ``` |
| |
546 | |
| |
547 | If `termNow` is already pinned by another test file's `init`, drop the `init` here. |
| |
548 | |
| |
549 | - [ ] **Step 2: Run to confirm failure** |
| |
550 | |
| |
551 | Run: `go test ./internal/control/ -run 'TestRender'` |
| |
552 | Expected: build failure, `undefined: screen`. |
| |
553 | |
| |
554 | - [ ] **Step 3: Implement `internal/control/screen.go`** |
| |
555 | |
| |
556 | ```go |
| |
557 | package control |
| |
558 | |
| |
559 | import ( |
| |
560 | "fmt" |
| |
561 | "io" |
| |
562 | "strings" |
| |
563 | |
| |
564 | "gitbay.org/krz/gitbay/internal/termtext" |
| |
565 | ) |
| |
566 | |
| |
567 | // screen is a show or a list as a terminal draws it: a header block of |
| |
568 | // labelled fields, a body, counted sections, and the commands that |
| |
569 | // apply. Commands build one only at a terminal; plain and --json output |
| |
570 | // never reach it. |
| |
571 | type screen struct { |
| |
572 | fields []field |
| |
573 | body string // markup source |
| |
574 | format string // the body's markup format |
| |
575 | sections []section |
| |
576 | actions []action |
| |
577 | } |
| |
578 | |
| |
579 | // field is one header line: a label and the cells after it. |
| |
580 | type field struct { |
| |
581 | label string |
| |
582 | value []cell |
| |
583 | } |
| |
584 | |
| |
585 | // section is a titled run of rows. n is the total, shown as "(n)"; when |
| |
586 | // it is more than the rows shown, more is the command for the rest. An |
| |
587 | // empty section is left out unless empty says to draw "(0)". |
| |
588 | type section struct { |
| |
589 | title string |
| |
590 | n int |
| |
591 | rows []row |
| |
592 | more []string |
| |
593 | empty bool |
| |
594 | } |
| |
595 | |
| |
596 | // row is a section row, and optionally the markup that follows it (a |
| |
597 | // comment's body). |
| |
598 | type row struct { |
| |
599 | cells []cell |
| |
600 | body string |
| |
601 | format string |
| |
602 | } |
| |
603 | |
| |
604 | func rowOf(cs ...cell) row { return row{cells: cs} } |
| |
605 | |
| |
606 | // action is one command in the legend, under its group's name. argv is |
| |
607 | // the command as typed after "gitbay". |
| |
608 | type action struct { |
| |
609 | group string |
| |
610 | argv []string |
| |
611 | } |
| |
612 | |
| |
613 | // render draws s: the parts in order, one blank line between parts and |
| |
614 | // between sections. |
| |
615 | func (c *Ctx) render(w io.Writer, s screen) { |
| |
616 | var blocks []string |
| |
617 | if b := c.renderFields(s.fields); b != "" { |
| |
618 | blocks = append(blocks, b) |
| |
619 | } |
| |
620 | if b := c.renderBody(s.body, s.format, ""); b != "" { |
| |
621 | blocks = append(blocks, b) |
| |
622 | } |
| |
623 | for _, sec := range s.sections { |
| |
624 | if b := c.renderSection(sec); b != "" { |
| |
625 | blocks = append(blocks, b) |
| |
626 | } |
| |
627 | } |
| |
628 | if b := c.renderLegend(s.actions); b != "" { |
| |
629 | blocks = append(blocks, b) |
| |
630 | } |
| |
631 | io.WriteString(w, strings.Join(blocks, "\n")) |
| |
632 | } |
| |
633 | |
| |
634 | // renderFields aligns labels on the widest, dim, with a colon. Every |
| |
635 | // cell but the last is painted as a table paints it; the last wraps to |
| |
636 | // the width, continuation lines under its first column. |
| |
637 | func (c *Ctx) renderFields(fs []field) string { |
| |
638 | t := c.Term |
| |
639 | wide := 0 |
| |
640 | for _, f := range fs { |
| |
641 | if len(f.value) > 0 { |
| |
642 | wide = max(wide, cells(f.label)+1) |
| |
643 | } |
| |
644 | } |
| |
645 | var b strings.Builder |
| |
646 | for _, f := range fs { |
| |
647 | if len(f.value) == 0 { |
| |
648 | continue |
| |
649 | } |
| |
650 | prefix := t.paint(sgrDim, f.label+":") + strings.Repeat(" ", wide-cells(f.label)-1+2) |
| |
651 | col := wide + 2 |
| |
652 | head := "" |
| |
653 | for _, cl := range f.value[:len(f.value)-1] { |
| |
654 | s := c.cellText(cl) |
| |
655 | if s == "" { |
| |
656 | continue |
| |
657 | } |
| |
658 | head += c.paintCell(cl, s) + " " |
| |
659 | col += cells(s) + 2 |
| |
660 | } |
| |
661 | last := f.value[len(f.value)-1] |
| |
662 | lines := termtext.Wrap(c.cellText(last), max(8, t.Cols-col)) |
| |
663 | indent := strings.Repeat(" ", col) |
| |
664 | for i, l := range lines { |
| |
665 | p := indent |
| |
666 | if i == 0 { |
| |
667 | p = prefix + head |
| |
668 | } |
| |
669 | b.WriteString(strings.TrimRight(p+c.paintCell(last, l), " ") + "\n") |
| |
670 | } |
| |
671 | } |
| |
672 | return b.String() |
| |
673 | } |
| |
674 | |
| |
675 | // cellText is a cell's terminal text: made safe, ages relative, sizes |
| |
676 | // humanized, as table.row prepares it. |
| |
677 | func (c *Ctx) cellText(cl cell) string { |
| |
678 | s := termSafe(cl.s) |
| |
679 | switch cl.kind { |
| |
680 | case kindAge: |
| |
681 | return relAge(s, termNow()) |
| |
682 | case kindSize: |
| |
683 | var n int64 |
| |
684 | if _, err := fmt.Sscan(s, &n); err == nil { |
| |
685 | return humanBytes(n) |
| |
686 | } |
| |
687 | } |
| |
688 | return s |
| |
689 | } |
| |
690 | |
| |
691 | // paintCell paints text s as its cell's kind paints at a terminal. |
| |
692 | func (c *Ctx) paintCell(cl cell, s string) string { |
| |
693 | t := c.Term |
| |
694 | switch cl.kind { |
| |
695 | case kindState: |
| |
696 | return t.paintState(s) |
| |
697 | case kindRef: |
| |
698 | return t.link(cl.url, t.paint(sgrDim, s)) |
| |
699 | case kindMeta: |
| |
700 | return t.paint(sgrDim, s) |
| |
701 | case kindSwatch: |
| |
702 | return t.swatch(s) |
| |
703 | } |
| |
704 | return t.paint(cl.sgr, s) |
| |
705 | } |
| |
706 | |
| |
707 | // renderBody renders markup to the width, each line prefixed by indent. |
| |
708 | func (c *Ctx) renderBody(src, format, indent string) string { |
| |
709 | if strings.TrimSpace(src) == "" { |
| |
710 | return "" |
| |
711 | } |
| |
712 | opts := termtext.Options{Width: max(0, c.Term.Cols-len(indent)), Color: c.Term.Color, Base: c.Cfg.Server.SiteURL} |
| |
713 | var b strings.Builder |
| |
714 | for _, l := range strings.Split(strings.TrimRight(termtext.Render(termSafe(src), format, opts), "\n"), "\n") { |
| |
715 | if l == "" { |
| |
716 | b.WriteString("\n") |
| |
717 | continue |
| |
718 | } |
| |
719 | b.WriteString(indent + l + "\n") |
| |
720 | } |
| |
721 | return b.String() |
| |
722 | } |
| |
723 | |
| |
724 | // renderSection is the heading, the rows laid out as one table with no |
| |
725 | // header, each row's body indented beneath it, and the "+n more" line. |
| |
726 | func (c *Ctx) renderSection(s section) string { |
| |
727 | if len(s.rows) == 0 && !s.empty { |
| |
728 | return "" |
| |
729 | } |
| |
730 | var b strings.Builder |
| |
731 | b.WriteString(c.Term.paint(sgrBold+sgrBlue, fmt.Sprintf("%s (%d)", s.title, s.n)) + "\n") |
| |
732 | tb := &table{term: c.Term, w: io.Discard} |
| |
733 | for _, r := range s.rows { |
| |
734 | tb.row(append([]cell(nil), r.cells...)...) |
| |
735 | } |
| |
736 | if len(s.rows) > 0 { |
| |
737 | tb.header = nil |
| |
738 | for i, l := range tb.lines() { |
| |
739 | b.WriteString(l + "\n") |
| |
740 | b.WriteString(c.renderBody(s.rows[i].body, s.rows[i].format, " ")) |
| |
741 | } |
| |
742 | } |
| |
743 | if rest := s.n - len(s.rows); rest > 0 && len(s.more) > 0 { |
| |
744 | b.WriteString(c.Term.paint(sgrDim, fmt.Sprintf("+%d more %s", rest, c.cmdline(s.more))) + "\n") |
| |
745 | } |
| |
746 | return b.String() |
| |
747 | } |
| |
748 | ``` |
| |
749 | |
| |
750 | `renderLegend` and `cmdline` come in task 4; add stubs now so the package builds: |
| |
751 | |
| |
752 | ```go |
| |
753 | func (c *Ctx) renderLegend(as []action) string { return "" } |
| |
754 | |
| |
755 | func (c *Ctx) cmdline(argv []string) string { return "gitbay " + strings.Join(argv, " ") } |
| |
756 | ``` |
| |
757 | |
| |
758 | Check the module path in `go.mod` and use it in the `termtext` import. |
| |
759 | |
| |
760 | `tb.lines()` relies on task 2's rule that a table with no numeric column has no header; a section whose rows carry `cNum` would otherwise print the header slice, which is nil here, so `tb.header = nil` before `lines()` keeps sections header-less in every case. |
| |
761 | |
| |
762 | - [ ] **Step 4: Run tests** |
| |
763 | |
| |
764 | Run: `go test ./internal/control/ -run 'TestRender'` |
| |
765 | Expected: PASS. If `TestRenderPlainLayout` differs only in the field padding, the rule is: label, colon, then spaces to `wide+2` columns (`wide` = longest label + 1). |
| |
766 | |
| |
767 | - [ ] **Step 5: Commit** |
| |
768 | |
| |
769 | ```bash |
| |
770 | git add internal/control/screen.go internal/control/screen_test.go |
| |
771 | git commit -m "control: screen model and renderer |
| |
772 | |
| |
773 | Ref #317" |
| |
774 | ``` |
| |
775 | |
| |
776 | ### Task 4: Legend, `cmdline`, and `here=` |
| |
777 | |
| |
778 | **Files:** |
| |
779 | - Modify: `internal/control/screen.go` (replace the two stubs) |
| |
780 | - Modify: `internal/control/term.go` (`Term.Here`, `ParseTerm`) |
| |
781 | - Test: `internal/control/screen_test.go`, `internal/control/term_test.go` |
| |
782 | |
| |
783 | **Interfaces:** |
| |
784 | - Produces: `Term.Here string`; `func (c *Ctx) cmdline(argv []string) string`; `func (c *Ctx) renderLegend(as []action) string`. |
| |
785 | |
| |
786 | - [ ] **Step 1: Write failing tests** |
| |
787 | |
| |
788 | In `term_test.go`, add cases to `TestParseTerm`'s table (match its existing struct shape): |
| |
789 | |
| |
790 | ```go |
| |
791 | {"120,color,here=krz/gitbay", Term{Cols: 120, Color: true, Here: "krz/gitbay"}}, |
| |
792 | {"120,here=", Term{Cols: 120}}, |
| |
793 | ``` |
| |
794 | |
| |
795 | In `screen_test.go`: |
| |
796 | |
| |
797 | ```go |
| |
798 | func TestCmdlineDropsHere(t *testing.T) { |
| |
799 | c := screenCtx(80, false) |
| |
800 | c.Term.Here = "krz/gitbay" |
| |
801 | if got, want := c.cmdline([]string{"mr", "diff", "krz/gitbay", "552"}), "gitbay mr diff 552"; got != want { |
| |
802 | t.Errorf("got %q, want %q", got, want) |
| |
803 | } |
| |
804 | if got, want := c.cmdline([]string{"issue", "show", "krz/hutch", "3"}), "gitbay issue show krz/hutch 3"; got != want { |
| |
805 | t.Errorf("got %q, want %q", got, want) |
| |
806 | } |
| |
807 | if got, want := c.cmdline([]string{"issue", "list", "--label", "needs review"}), "gitbay issue list --label 'needs review'"; got != want { |
| |
808 | t.Errorf("got %q, want %q", got, want) |
| |
809 | } |
| |
810 | } |
| |
811 | |
| |
812 | func legendSample() []action { |
| |
813 | return []action{ |
| |
814 | {"Unblock", []string{"mr", "rebase", "552"}}, |
| |
815 | {"Review", []string{"mr", "review", "krz/gitbay", "552", "--approve"}}, |
| |
816 | {"Review", []string{"mr", "comment", "krz/gitbay", "552"}}, |
| |
817 | {"Read", []string{"mr", "diff", "krz/gitbay", "552"}}, |
| |
818 | } |
| |
819 | } |
| |
820 | |
| |
821 | func TestLegendColumns(t *testing.T) { |
| |
822 | c := screenCtx(120, false) |
| |
823 | c.Term.Here = "krz/gitbay" |
| |
824 | got := c.renderLegend(legendSample()) |
| |
825 | want := strings.Repeat("─", 120) + "\n" + |
| |
826 | "Unblock Review Read\n" + |
| |
827 | "gitbay mr rebase 552 gitbay mr review 552 --approve gitbay mr diff 552\n" + |
| |
828 | " gitbay mr comment 552\n" |
| |
829 | if got != want { |
| |
830 | t.Errorf("got:\n%s\nwant:\n%s", got, want) |
| |
831 | } |
| |
832 | } |
| |
833 | |
| |
834 | func TestLegendStacksWhenNarrow(t *testing.T) { |
| |
835 | c := screenCtx(60, false) |
| |
836 | c.Term.Here = "krz/gitbay" |
| |
837 | got := c.renderLegend(legendSample()) |
| |
838 | want := strings.Repeat("─", 60) + "\n" + |
| |
839 | "Unblock\ngitbay mr rebase 552\n" + |
| |
840 | "Review\ngitbay mr review 552 --approve\ngitbay mr comment 552\n" + |
| |
841 | "Read\ngitbay mr diff 552\n" |
| |
842 | if got != want { |
| |
843 | t.Errorf("got:\n%s\nwant:\n%s", got, want) |
| |
844 | } |
| |
845 | } |
| |
846 | |
| |
847 | func TestLegendColour(t *testing.T) { |
| |
848 | c := screenCtx(120, true) |
| |
849 | got := c.renderLegend(legendSample()) |
| |
850 | if !strings.Contains(got, sgrBlue+"gitbay mr rebase 552"+sgrReset) || !strings.Contains(got, sgrBold+"Unblock"+sgrReset) { |
| |
851 | t.Errorf("legend paint: %q", got) |
| |
852 | } |
| |
853 | if stripSGR(got) != screenCtx(120, false).renderLegend(legendSample()) { |
| |
854 | t.Error("colour legend differs beyond SGR") |
| |
855 | } |
| |
856 | } |
| |
857 | ``` |
| |
858 | |
| |
859 | - [ ] **Step 2: Run to confirm failure** |
| |
860 | |
| |
861 | Run: `go test ./internal/control/ -run 'TestParseTerm|TestCmdline|TestLegend'` |
| |
862 | Expected: FAIL (`Here` unknown field; stub output). |
| |
863 | |
| |
864 | - [ ] **Step 3: Implement** |
| |
865 | |
| |
866 | In `term.go`, add to `Term`: |
| |
867 | |
| |
868 | ```go |
| |
869 | // Here is the repository the CLI inferred from the clone it runs in, |
| |
870 | // so a suggested command can leave it out. |
| |
871 | Here string |
| |
872 | ``` |
| |
873 | |
| |
874 | and in `ParseTerm`'s loop, before `switch opt`: |
| |
875 | |
| |
876 | ```go |
| |
877 | if v, ok := strings.CutPrefix(opt, "here="); ok { |
| |
878 | t.Here = v |
| |
879 | continue |
| |
880 | } |
| |
881 | ``` |
| |
882 | |
| |
883 | In `screen.go`, replace the stubs: |
| |
884 | |
| |
885 | ```go |
| |
886 | // cmdline is argv as the viewer would type it: "gitbay", the words |
| |
887 | // shell-quoted, and the repository the CLI inferred left out. |
| |
888 | func (c *Ctx) cmdline(argv []string) string { |
| |
889 | words := []string{"gitbay"} |
| |
890 | for _, a := range argv { |
| |
891 | if c.Term.Here != "" && a == c.Term.Here { |
| |
892 | continue |
| |
893 | } |
| |
894 | words = append(words, shellWord(a)) |
| |
895 | } |
| |
896 | return strings.Join(words, " ") |
| |
897 | } |
| |
898 | |
| |
899 | // renderLegend is a rule, then the action groups in the order they first |
| |
900 | // appear, each a bold name over its commands in blue. Groups sit side by |
| |
901 | // side, three to a band, when the terminal is 80 wide or more and the |
| |
902 | // band fits; otherwise they stack. |
| |
903 | func (c *Ctx) renderLegend(as []action) string { |
| |
904 | if len(as) == 0 { |
| |
905 | return "" |
| |
906 | } |
| |
907 | t := c.Term |
| |
908 | type group struct { |
| |
909 | name string |
| |
910 | cmds []string |
| |
911 | } |
| |
912 | var groups []*group |
| |
913 | byName := map[string]*group{} |
| |
914 | for _, a := range as { |
| |
915 | g := byName[a.group] |
| |
916 | if g == nil { |
| |
917 | g = &group{name: a.group} |
| |
918 | byName[a.group] = g |
| |
919 | groups = append(groups, g) |
| |
920 | } |
| |
921 | g.cmds = append(g.cmds, c.cmdline(a.argv)) |
| |
922 | } |
| |
923 | var b strings.Builder |
| |
924 | b.WriteString(t.paint(sgrDim, strings.Repeat("─", t.Cols)) + "\n") |
| |
925 | for start := 0; start < len(groups); start += 3 { |
| |
926 | band := groups[start:min(start+3, len(groups))] |
| |
927 | widths := make([]int, len(band)) |
| |
928 | total, height := 0, 0 |
| |
929 | for i, g := range band { |
| |
930 | widths[i] = cells(g.name) |
| |
931 | for _, cmd := range g.cmds { |
| |
932 | widths[i] = max(widths[i], cells(cmd)) |
| |
933 | } |
| |
934 | total += widths[i] |
| |
935 | height = max(height, len(g.cmds)) |
| |
936 | } |
| |
937 | total += 2 * (len(band) - 1) |
| |
938 | if t.Cols < 80 || total > t.Cols { |
| |
939 | for _, g := range band { |
| |
940 | b.WriteString(t.paint(sgrBold, g.name) + "\n") |
| |
941 | for _, cmd := range g.cmds { |
| |
942 | b.WriteString(t.paint(sgrBlue, cmd) + "\n") |
| |
943 | } |
| |
944 | } |
| |
945 | continue |
| |
946 | } |
| |
947 | for line := -1; line < height; line++ { |
| |
948 | var l strings.Builder |
| |
949 | for i, g := range band { |
| |
950 | s, paint := "", sgrBlue |
| |
951 | if line < 0 { |
| |
952 | s, paint = g.name, sgrBold |
| |
953 | } else if line < len(g.cmds) { |
| |
954 | s = g.cmds[line] |
| |
955 | } |
| |
956 | if i < len(band)-1 { |
| |
957 | l.WriteString(t.paint(paint, s) + strings.Repeat(" ", widths[i]-cells(s)+2)) |
| |
958 | } else { |
| |
959 | l.WriteString(t.paint(paint, s)) |
| |
960 | } |
| |
961 | } |
| |
962 | b.WriteString(strings.TrimRight(l.String(), " ") + "\n") |
| |
963 | } |
| |
964 | } |
| |
965 | return b.String() |
| |
966 | } |
| |
967 | ``` |
| |
968 | |
| |
969 | `shellWord` already exists (used by `emitPage`); if it is in `cmd/gitbay` rather than `internal/control`, find it with `grep -rn 'func shellWord' internal/` and use that one. |
| |
970 | |
| |
971 | - [ ] **Step 4: Run tests** |
| |
972 | |
| |
973 | Run: `go test ./internal/control/` |
| |
974 | Expected: PASS. |
| |
975 | |
| |
976 | - [ ] **Step 5: Commit** |
| |
977 | |
| |
978 | ```bash |
| |
979 | git add internal/control/screen.go internal/control/term.go internal/control/*_test.go |
| |
980 | git commit -m "control: action legend; here= names the inferred repository |
| |
981 | |
| |
982 | Ref #317" |
| |
983 | ``` |
| |
984 | |
| |
985 | ### Task 5: `emitView`, `emitPageView`, the next-page line |
| |
986 | |
| |
987 | **Files:** |
| |
988 | - Modify: `internal/control/control.go` (after `emit`) |
| |
989 | - Modify: `internal/control/cursor.go` (`emitPage`) |
| |
990 | - Test: `internal/control/screen_test.go`, `internal/control/cursor_test.go:84` |
| |
991 | |
| |
992 | **Interfaces:** |
| |
993 | - Consumes: `render`, `cmdline` (tasks 3–4). |
| |
994 | - Produces: |
| |
995 | |
| |
996 | ```go |
| |
997 | func (c *Ctx) emitView(data any, plain func(w io.Writer), build func() screen) int |
| |
998 | func (c *Ctx) emitPageView(p page, items any, next string, plain func(w io.Writer), build func() screen) int |
| |
999 | func (c *Ctx) nextArgv(next string) []string |
| |
1000 | ``` |
| |
1001 | |
| |
1002 | - [ ] **Step 1: Write failing tests** |
| |
1003 | |
| |
1004 | ```go |
| |
1005 | func TestEmitViewRoutes(t *testing.T) { |
| |
1006 | built := false |
| |
1007 | build := func() screen { built = true; return screen{fields: []field{{"Repo", []cell{cText("a/b")}}}} } |
| |
1008 | plain := func(w io.Writer) { io.WriteString(w, "plain\n") } |
| |
1009 | |
| |
1010 | var out bytes.Buffer |
| |
1011 | c := &Ctx{Stdout: &out, Stderr: io.Discard} |
| |
1012 | c.emitView(map[string]string{"k": "v"}, plain, build) |
| |
1013 | if out.String() != "plain\n" || built { |
| |
1014 | t.Errorf("piped: %q built=%v", out.String(), built) |
| |
1015 | } |
| |
1016 | |
| |
1017 | out.Reset() |
| |
1018 | c = &Ctx{Stdout: &out, Stderr: io.Discard, JSON: true, Term: Term{Cols: 80}} |
| |
1019 | c.emitView(map[string]string{"k": "v"}, plain, build) |
| |
1020 | if !strings.Contains(out.String(), `"k":"v"`) || built { |
| |
1021 | t.Errorf("json: %q built=%v", out.String(), built) |
| |
1022 | } |
| |
1023 | |
| |
1024 | out.Reset() |
| |
1025 | c = &Ctx{Stdout: &out, Stderr: io.Discard, Term: Term{Cols: 80}} |
| |
1026 | c.emitView(map[string]string{"k": "v"}, plain, build) |
| |
1027 | if out.String() != "Repo: a/b\n" || !built { |
| |
1028 | t.Errorf("terminal: %q built=%v", out.String(), built) |
| |
1029 | } |
| |
1030 | } |
| |
1031 | ``` |
| |
1032 | |
| |
1033 | Add `bytes` and `io` imports to `screen_test.go`. |
| |
1034 | |
| |
1035 | In `cursor_test.go`, change the expectation at line 84 to the new stderr line: |
| |
1036 | |
| |
1037 | ```go |
| |
1038 | want := "Next page gitbay build list " + repo.Path() + " --limit 2 --cursor " |
| |
1039 | ``` |
| |
1040 | |
| |
1041 | - [ ] **Step 2: Run to confirm failure** |
| |
1042 | |
| |
1043 | Run: `go test ./internal/control/ -run 'TestEmitViewRoutes|TestCursor'` |
| |
1044 | Expected: FAIL (`emitView` undefined; old `more:` text). |
| |
1045 | |
| |
1046 | - [ ] **Step 3: Implement** |
| |
1047 | |
| |
1048 | In `control.go`, after `emit`: |
| |
1049 | |
| |
1050 | ```go |
| |
1051 | // emitView is emit for a command with a terminal screen: --json and |
| |
1052 | // piped output are emit's, and at a terminal build's screen is drawn |
| |
1053 | // in place of plain. |
| |
1054 | func (c *Ctx) emitView(data any, plain func(w io.Writer), build func() screen) int { |
| |
1055 | if c.Term.Cols == 0 || c.JSON { |
| |
1056 | return c.emit(data, plain) |
| |
1057 | } |
| |
1058 | return c.emit(data, func(w io.Writer) { c.render(w, build()) }) |
| |
1059 | } |
| |
1060 | ``` |
| |
1061 | |
| |
1062 | In `cursor.go`, extract the argv the old `more:` line built: |
| |
1063 | |
| |
1064 | ```go |
| |
1065 | // nextArgv is the command that fetches the page after this one: this |
| |
1066 | // command's argv with its cursor replaced. |
| |
1067 | func (c *Ctx) nextArgv(next string) []string { |
| |
1068 | argv := strings.Fields(joinPath(c.Cmd.Path)) |
| |
1069 | for i := 0; i < len(c.Argv); i++ { |
| |
1070 | if c.Argv[i] == "--cursor" { |
| |
1071 | i++ |
| |
1072 | continue |
| |
1073 | } |
| |
1074 | argv = append(argv, c.Argv[i]) |
| |
1075 | } |
| |
1076 | return append(argv, "--cursor", next) |
| |
1077 | } |
| |
1078 | ``` |
| |
1079 | |
| |
1080 | and replace the terminal branch of `emitPage`'s closure (from `var again []string` to the `Fprintf`) with: |
| |
1081 | |
| |
1082 | ```go |
| |
1083 | fmt.Fprintln(c.Stderr, c.Term.paint(sgrBold, "Next page")+" "+c.Term.paint(sgrBlue, c.cmdline(c.nextArgv(next)))) |
| |
1084 | ``` |
| |
1085 | |
| |
1086 | Then add: |
| |
1087 | |
| |
1088 | ```go |
| |
1089 | // emitPageView is emitPage for a list with a terminal screen. |
| |
1090 | func (c *Ctx) emitPageView(p page, items any, next string, plain func(w io.Writer), build func() screen) int { |
| |
1091 | if c.Term.Cols == 0 || c.JSON { |
| |
1092 | return c.emitPage(p, items, next, plain) |
| |
1093 | } |
| |
1094 | return c.emitPage(p, items, next, func(w io.Writer) { c.render(w, build()) }) |
| |
1095 | } |
| |
1096 | ``` |
| |
1097 | |
| |
1098 | `cmdline` shell-quotes each word, so the old `shellWord` loop in `emitPage` is no longer used there; remove it if nothing else in the function uses it. |
| |
1099 | |
| |
1100 | - [ ] **Step 4: Run tests** |
| |
1101 | |
| |
1102 | Run: `go test ./internal/control/` |
| |
1103 | Expected: PASS. |
| |
1104 | |
| |
1105 | - [ ] **Step 5: Commit** |
| |
1106 | |
| |
1107 | ```bash |
| |
1108 | git add internal/control/control.go internal/control/cursor.go internal/control/*_test.go |
| |
1109 | git commit -m "control: emitView and emitPageView; next page as a legend line |
| |
1110 | |
| |
1111 | Ref #317" |
| |
1112 | ``` |
| |
1113 | |
| |
1114 | ### Task 6: The CLI sends `here=` |
| |
1115 | |
| |
1116 | **Files:** |
| |
1117 | - Modify: `cmd/gitbay/ssh.go` (`termValue` signature and `runSSHPaged`) |
| |
1118 | - Test: `cmd/gitbay/term_test.go` |
| |
1119 | |
| |
1120 | **Interfaces:** |
| |
1121 | - Produces: `func termValue(isTerminal bool, cols int, here string, env func(string) string) string`. |
| |
1122 | |
| |
1123 | - [ ] **Step 1: Write failing test** |
| |
1124 | |
| |
1125 | Add to `cmd/gitbay/term_test.go`: |
| |
1126 | |
| |
1127 | ```go |
| |
1128 | func TestTermValueHere(t *testing.T) { |
| |
1129 | env := func(string) string { return "" } |
| |
1130 | if got, want := termValue(true, 100, "krz/gitbay", env), "100,color,here=krz/gitbay"; got != want { |
| |
1131 | t.Errorf("got %q, want %q", got, want) |
| |
1132 | } |
| |
1133 | if got := termValue(true, 100, "", env); strings.Contains(got, "here=") { |
| |
1134 | t.Errorf("here= without a repository: %q", got) |
| |
1135 | } |
| |
1136 | if got := termValue(false, 100, "krz/gitbay", env); got != "" { |
| |
1137 | t.Errorf("piped: %q", got) |
| |
1138 | } |
| |
1139 | } |
| |
1140 | ``` |
| |
1141 | |
| |
1142 | Update the existing `termValue(c.tty, c.cols, env(c.env))` call in `term_test.go:40` to `termValue(c.tty, c.cols, "", env(c.env))`. |
| |
1143 | |
| |
1144 | - [ ] **Step 2: Run to confirm failure** |
| |
1145 | |
| |
1146 | Run: `go test ./cmd/gitbay/ -run TestTermValue` |
| |
1147 | Expected: build failure (too many arguments). |
| |
1148 | |
| |
1149 | - [ ] **Step 3: Implement** |
| |
1150 | |
| |
1151 | Change `termValue` to take `here` and append it last: |
| |
1152 | |
| |
1153 | ```go |
| |
1154 | func termValue(isTerminal bool, cols int, here string, env func(string) string) string { |
| |
1155 | // ... existing body up to the links check unchanged ... |
| |
1156 | if linksWanted(env) { |
| |
1157 | v += ",links" |
| |
1158 | } |
| |
1159 | if here != "" { |
| |
1160 | v += ",here=" + here |
| |
1161 | } |
| |
1162 | return v |
| |
1163 | } |
| |
1164 | ``` |
| |
1165 | |
| |
1166 | Keep the `GITBAY_TERM=basic` early return as it is: an instance that old ignores `here=` anyway, and `basic` promises only width and colour. Add one sentence to the function's comment: "here is the repository inferred from the clone, for suggested commands to leave out." |
| |
1167 | |
| |
1168 | In `runSSHPaged`, pass `t.repo`: |
| |
1169 | |
| |
1170 | ```go |
| |
1171 | if v := termValue(isTTY, cols, t.repo, os.Getenv); v != "" && !slices.Contains(serverArgv, "--json") { |
| |
1172 | ``` |
| |
1173 | |
| |
1174 | - [ ] **Step 4: Run tests** |
| |
1175 | |
| |
1176 | Run: `go test ./cmd/gitbay/` |
| |
1177 | Expected: PASS. |
| |
1178 | |
| |
1179 | - [ ] **Step 5: Commit** |
| |
1180 | |
| |
1181 | ```bash |
| |
1182 | git add cmd/gitbay/ssh.go cmd/gitbay/term_test.go |
| |
1183 | git commit -m "cli: send the inferred repository as here= in --term |
| |
1184 | |
| |
1185 | Ref #317" |
| |
1186 | ``` |
| |
1187 | |
| |
1188 | ### Task 7: Test helpers for migrations — `checkActions` and `pinPlain` |
| |
1189 | |
| |
1190 | **Files:** |
| |
1191 | - Modify: `internal/control/screen_test.go` |
| |
1192 | - Create: `internal/control/plain_test.go` |
| |
1193 | |
| |
1194 | **Interfaces:** |
| |
1195 | - Produces (test-only): `func checkActions(t *testing.T, s screen)`; `func pinPlain(t *testing.T, name, got string)`; flag `-update-plain`. |
| |
1196 | |
| |
1197 | - [ ] **Step 1: Write the helpers and their own tests** |
| |
1198 | |
| |
1199 | Append to `screen_test.go`: |
| |
1200 | |
| |
1201 | ```go |
| |
1202 | // cliLocal are commands cmd/gitbay runs itself; the registry does not |
| |
1203 | // know them, but a legend may suggest them. |
| |
1204 | var cliLocal = map[string]bool{"mr rebase": true, "mr checkout": true, "repo clone": true} |
| |
1205 | |
| |
1206 | // errorer is the part of *testing.T checkActions uses, so its own test |
| |
1207 | // can pass a recorder. |
| |
1208 | type errorer interface { |
| |
1209 | Helper() |
| |
1210 | Errorf(format string, args ...any) |
| |
1211 | } |
| |
1212 | |
| |
1213 | type recorder struct{ failed bool } |
| |
1214 | |
| |
1215 | func (r *recorder) Helper() {} |
| |
1216 | func (r *recorder) Errorf(string, ...any) { r.failed = true } |
| |
1217 | |
| |
1218 | // checkActions fails t for any legend or "more" command that the |
| |
1219 | // registry would not dispatch, or whose flags it would refuse. |
| |
1220 | func checkActions(t errorer, s screen) { |
| |
1221 | t.Helper() |
| |
1222 | var all [][]string |
| |
1223 | for _, a := range s.actions { |
| |
1224 | all = append(all, a.argv) |
| |
1225 | } |
| |
1226 | for _, sec := range s.sections { |
| |
1227 | if len(sec.more) > 0 { |
| |
1228 | all = append(all, sec.more) |
| |
1229 | } |
| |
1230 | } |
| |
1231 | for _, argv := range all { |
| |
1232 | if len(argv) >= 2 && cliLocal[argv[0]+" "+argv[1]] { |
| |
1233 | continue |
| |
1234 | } |
| |
1235 | cmd, rest, ok := Lookup(argv) |
| |
1236 | if !ok { |
| |
1237 | t.Errorf("no command for %q", argv) |
| |
1238 | continue |
| |
1239 | } |
| |
1240 | if err := checkFlags(cmd, rest); err != nil { |
| |
1241 | t.Errorf("%q: %v", argv, err) |
| |
1242 | } |
| |
1243 | } |
| |
1244 | } |
| |
1245 | |
| |
1246 | func TestCheckActionsCatchesTypos(t *testing.T) { |
| |
1247 | r := &recorder{} |
| |
1248 | checkActions(r, screen{actions: []action{{"Read", []string{"mr", "dif", "a/b", "1"}}}}) |
| |
1249 | if !r.failed { |
| |
1250 | t.Error("a misspelled command passed") |
| |
1251 | } |
| |
1252 | r = &recorder{} |
| |
1253 | checkActions(r, screen{actions: []action{{"Read", []string{"mr", "diff", "a/b", "1", "--bogus"}}}}) |
| |
1254 | if !r.failed { |
| |
1255 | t.Error("an unknown flag passed") |
| |
1256 | } |
| |
1257 | r = &recorder{} |
| |
1258 | checkActions(r, screen{actions: []action{{"Unblock", []string{"mr", "rebase", "1"}}}}) |
| |
1259 | if r.failed { |
| |
1260 | t.Error("a CLI-local command failed") |
| |
1261 | } |
| |
1262 | } |
| |
1263 | ``` |
| |
1264 | |
| |
1265 | `checkFlags` reads the command's declared flags (`Command.Flags`, each with |
| |
1266 | `Name` and `Arg`; `Arg` empty for a switch), so no store is involved. Add it |
| |
1267 | to `screen_test.go`: |
| |
1268 | |
| |
1269 | ```go |
| |
1270 | // checkFlags refuses a flag the command does not declare, and a value |
| |
1271 | // flag with nothing after it. Words that are not flags are positionals. |
| |
1272 | func checkFlags(cmd Command, rest []string) error { |
| |
1273 | takes := map[string]bool{} |
| |
1274 | for _, f := range cmd.Flags { |
| |
1275 | takes[f.Name] = f.Arg != "" |
| |
1276 | } |
| |
1277 | for i := 0; i < len(rest); i++ { |
| |
1278 | a := rest[i] |
| |
1279 | if !strings.HasPrefix(a, "--") { |
| |
1280 | continue |
| |
1281 | } |
| |
1282 | name, _, inline := strings.Cut(a, "=") |
| |
1283 | value, ok := takes[name] |
| |
1284 | if !ok { |
| |
1285 | return fmt.Errorf("%s does not take %s", joinPath(cmd.Path), name) |
| |
1286 | } |
| |
1287 | if value && !inline { |
| |
1288 | if i+1 >= len(rest) { |
| |
1289 | return fmt.Errorf("%s needs a value", name) |
| |
1290 | } |
| |
1291 | i++ |
| |
1292 | } |
| |
1293 | } |
| |
1294 | return nil |
| |
1295 | } |
| |
1296 | ``` |
| |
1297 | |
| |
1298 | If a command in a legend declares no `Flags` but accepts some (an older |
| |
1299 | registration), add its `Flags` to the registration rather than loosening |
| |
1300 | the check; `TestCommandsDeclareFlags` or its equivalent may already |
| |
1301 | enforce this (`grep -n 'Flags' internal/control/help_test.go`). |
| |
1302 | |
| |
1303 | Create `internal/control/plain_test.go`: |
| |
1304 | |
| |
1305 | ```go |
| |
1306 | package control |
| |
1307 | |
| |
1308 | import ( |
| |
1309 | "flag" |
| |
1310 | "os" |
| |
1311 | "path/filepath" |
| |
1312 | "regexp" |
| |
1313 | "testing" |
| |
1314 | ) |
| |
1315 | |
| |
1316 | var updatePlain = flag.Bool("update-plain", false, "rewrite testdata/plain from the current output") |
| |
1317 | |
| |
1318 | var plainStamp = regexp.MustCompile(`\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?(Z| UTC)?`) |
| |
1319 | |
| |
1320 | // pinPlain compares a command's piped output with the copy taken before |
| |
1321 | // its terminal screen was rewritten. Timestamps are masked; the fixture |
| |
1322 | // clock is the store's. |
| |
1323 | func pinPlain(t *testing.T, name, got string) { |
| |
1324 | t.Helper() |
| |
1325 | got = plainStamp.ReplaceAllString(got, "<time>") |
| |
1326 | path := filepath.Join("testdata", "plain", name+".txt") |
| |
1327 | if *updatePlain { |
| |
1328 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| |
1329 | t.Fatal(err) |
| |
1330 | } |
| |
1331 | if err := os.WriteFile(path, []byte(got), 0o644); err != nil { |
| |
1332 | t.Fatal(err) |
| |
1333 | } |
| |
1334 | return |
| |
1335 | } |
| |
1336 | want, err := os.ReadFile(path) |
| |
1337 | if err != nil { |
| |
1338 | t.Fatalf("%v: capture it with -update-plain before changing the command", err) |
| |
1339 | } |
| |
1340 | if got != string(want) { |
| |
1341 | t.Errorf("piped output changed:\n--- want\n%s--- got\n%s", want, got) |
| |
1342 | } |
| |
1343 | } |
| |
1344 | ``` |
| |
1345 | |
| |
1346 | - [ ] **Step 2: Run** |
| |
1347 | |
| |
1348 | Run: `go test ./internal/control/ -run 'TestCheckActions'` |
| |
1349 | Expected: PASS. |
| |
1350 | |
| |
1351 | - [ ] **Step 3: Commit** |
| |
1352 | |
| |
1353 | ```bash |
| |
1354 | git add internal/control/screen_test.go internal/control/plain_test.go |
| |
1355 | git commit -m "control: test helpers for screen migrations |
| |
1356 | |
| |
1357 | Ref #317" |
| |
1358 | ``` |
| |
1359 | |
| |
1360 | ### Task 8: Docs, changelog, MR |
| |
1361 | |
| |
1362 | **Files:** |
| |
1363 | - Modify: `.gitbay/wiki/Users.org` ("Output rules" section) |
| |
1364 | - Modify: `CHANGELOG.org` (unreleased section at the top; create one headed `* Unreleased` if the top entry is a released version) |
| |
1365 | |
| |
1366 | - [ ] **Step 1: Rewrite "Output rules"** |
| |
1367 | |
| |
1368 | Replace the section's terminal-output rules with: the four parts of a screen and their order; the label/heading/row rules; the colour table; the glyph table; that a table names its columns only when one holds numbers; the legend and `here=`; that piped and `--json` output are unaffected. Keep the existing piped-output and exit-code rules as they are. |
| |
1369 | |
| |
1370 | - [ ] **Step 2: Changelog entry** |
| |
1371 | |
| |
1372 | ```org |
| |
1373 | - Terminal output is drawn as screens (#317): lists drop |
| |
1374 | their header row unless a column is a number, refs are dim rather than |
| |
1375 | cyan, and state shows as a leading glyph where a screen has one. The |
| |
1376 | next page is a =Next page= line with the command. The CLI sends the |
| |
1377 | repository it inferred as =here== in =--term=, so suggested commands |
| |
1378 | leave it out; an older instance ignores it. Piped and =--json= output |
| |
1379 | are unchanged. |
| |
1380 | ``` |
| |
1381 | |
| |
1382 | - [ ] **Step 3: Verify and push** |
| |
1383 | |
| |
1384 | Run: `go build ./... && go vet ./... && go test ./internal/control/ ./cmd/gitbay/` |
| |
1385 | Expected: all PASS. |
| |
1386 | |
| |
1387 | ```bash |
| |
1388 | git add .gitbay/wiki/Users.org CHANGELOG.org |
| |
1389 | git commit -m "docs: screen output rules |
| |
1390 | |
| |
1391 | Ref #317" |
| |
1392 | git push -u origin cli-views-1 |
| |
1393 | gitbay mr create --source cli-views-1 --target main --title "control: screen renderer (stage 1 of CLI views)" |
| |
1394 | ``` |
| |
1395 | |
| |
1396 | CI runs the e2e suite. `e2e/term_test.go` and any e2e test that matches a terminal header row or cyan may need its expectation updated; fix on the branch, amend, force-push. |
| |
1397 | |
| |
1398 | --- |
| |
1399 | |
| |
1400 | ## Stage 2a: show screens (one MR, Ref #318) |
| |
1401 | |
| |
1402 | Each migration follows the same order: pin the piped output on the |
| |
1403 | unchanged code, write the screen test, write the builder, switch the |
| |
1404 | command to `emitView`, confirm the pin still matches. |
| |
1405 | |
| |
1406 | ### Task 9: `gitutil.DiffNumstat` |
| |
1407 | |
| |
1408 | **Files:** |
| |
1409 | - Create: `internal/gitutil/numstat.go` |
| |
1410 | - Test: `internal/gitutil/numstat_test.go` |
| |
1411 | |
| |
1412 | **Interfaces:** |
| |
1413 | - Produces: |
| |
1414 | |
| |
1415 | ```go |
| |
1416 | type NumStat struct { |
| |
1417 | Path string |
| |
1418 | Added int // -1 for a binary file |
| |
1419 | Deleted int // -1 for a binary file |
| |
1420 | Status string // "A", "M", "D", "R" |
| |
1421 | } |
| |
1422 | func DiffNumstat(dir, base, head string) ([]NumStat, error) |
| |
1423 | ``` |
| |
1424 | |
| |
1425 | - [ ] **Step 1: Write failing test** |
| |
1426 | |
| |
1427 | Use the package's existing test-repository helper (`grep -n 'func.*testRepo\|func initRepo' internal/gitutil/*_test.go`) to make a repo with a base commit, then a commit that modifies one file (+2 −1), adds one, and adds a binary file. Assert: |
| |
1428 | |
| |
1429 | ```go |
| |
1430 | want := []NumStat{ |
| |
1431 | {Path: "a.txt", Added: 2, Deleted: 1, Status: "M"}, |
| |
1432 | {Path: "bin.dat", Added: -1, Deleted: -1, Status: "A"}, |
| |
1433 | {Path: "new.txt", Added: 1, Deleted: 0, Status: "A"}, |
| |
1434 | } |
| |
1435 | ``` |
| |
1436 | |
| |
1437 | - [ ] **Step 2: Run to confirm failure** |
| |
1438 | |
| |
1439 | Run: `go test ./internal/gitutil/ -run TestDiffNumstat` |
| |
1440 | Expected: `undefined: DiffNumstat`. |
| |
1441 | |
| |
1442 | - [ ] **Step 3: Implement** |
| |
1443 | |
| |
1444 | ```go |
| |
1445 | package gitutil |
| |
1446 | |
| |
1447 | import ( |
| |
1448 | "fmt" |
| |
1449 | "os/exec" |
| |
1450 | "strconv" |
| |
1451 | "strings" |
| |
1452 | |
| |
1453 | "gitbay.org/krz/gitbay/internal/toolpath" |
| |
1454 | ) |
| |
1455 | |
| |
1456 | // NumStat is one file's line counts between two commits. A binary file |
| |
1457 | // counts -1 each way. |
| |
1458 | type NumStat struct { |
| |
1459 | Path string |
| |
1460 | Added int |
| |
1461 | Deleted int |
| |
1462 | Status string |
| |
1463 | } |
| |
1464 | |
| |
1465 | // DiffNumstat lists the files changed from base to head, sorted by path, |
| |
1466 | // with line counts and A/M/D/R status. Renames are detected. |
| |
1467 | func DiffNumstat(dir, base, head string) ([]NumStat, error) { |
| |
1468 | run := func(args ...string) (string, error) { |
| |
1469 | cmd := exec.Command(toolpath.Look("git"), append([]string{"-C", dir}, args...)...) |
| |
1470 | out, err := cmd.Output() |
| |
1471 | if err != nil { |
| |
1472 | return "", fmt.Errorf("git %s: %w", args[0], err) |
| |
1473 | } |
| |
1474 | return string(out), nil |
| |
1475 | } |
| |
1476 | num, err := run("diff", "--numstat", "-z", "-M", "--end-of-options", base, head) |
| |
1477 | if err != nil { |
| |
1478 | return nil, err |
| |
1479 | } |
| |
1480 | names, err := run("diff", "--name-status", "-z", "-M", "--end-of-options", base, head) |
| |
1481 | if err != nil { |
| |
1482 | return nil, err |
| |
1483 | } |
| |
1484 | status := map[string]string{} |
| |
1485 | f := strings.Split(strings.TrimSuffix(names, "\x00"), "\x00") |
| |
1486 | for i := 0; i < len(f); { |
| |
1487 | s := f[i] |
| |
1488 | if strings.HasPrefix(s, "R") || strings.HasPrefix(s, "C") { |
| |
1489 | if i+2 < len(f) { |
| |
1490 | status[f[i+2]] = s[:1] |
| |
1491 | } |
| |
1492 | i += 3 |
| |
1493 | continue |
| |
1494 | } |
| |
1495 | if i+1 < len(f) { |
| |
1496 | status[f[i+1]] = s[:1] |
| |
1497 | } |
| |
1498 | i += 2 |
| |
1499 | } |
| |
1500 | var out []NumStat |
| |
1501 | recs := strings.Split(strings.TrimSuffix(num, "\x00"), "\x00") |
| |
1502 | for i := 0; i < len(recs); i++ { |
| |
1503 | parts := strings.SplitN(recs[i], "\t", 3) |
| |
1504 | if len(parts) != 3 { |
| |
1505 | continue |
| |
1506 | } |
| |
1507 | path := parts[2] |
| |
1508 | if path == "" && i+2 < len(recs) { // rename: "a\td\t" then old, new |
| |
1509 | path = recs[i+2] |
| |
1510 | i += 2 |
| |
1511 | } |
| |
1512 | ns := NumStat{Path: path, Added: -1, Deleted: -1, Status: status[path]} |
| |
1513 | if parts[0] != "-" { |
| |
1514 | ns.Added, _ = strconv.Atoi(parts[0]) |
| |
1515 | ns.Deleted, _ = strconv.Atoi(parts[1]) |
| |
1516 | } |
| |
1517 | out = append(out, ns) |
| |
1518 | } |
| |
1519 | return out, nil |
| |
1520 | } |
| |
1521 | ``` |
| |
1522 | |
| |
1523 | Check the `toolpath` import path against `merge.go`'s imports and match it. |
| |
1524 | |
| |
1525 | - [ ] **Step 4: Run tests** |
| |
1526 | |
| |
1527 | Run: `go test ./internal/gitutil/ -run TestDiffNumstat` |
| |
1528 | Expected: PASS. |
| |
1529 | |
| |
1530 | - [ ] **Step 5: Commit** |
| |
1531 | |
| |
1532 | ```bash |
| |
1533 | git add internal/gitutil/numstat.go internal/gitutil/numstat_test.go |
| |
1534 | git commit -m "gitutil: DiffNumstat |
| |
1535 | |
| |
1536 | Ref #318" |
| |
1537 | ``` |
| |
1538 | |
| |
1539 | ### Task 10: `mr show` screen |
| |
1540 | |
| |
1541 | **Files:** |
| |
1542 | - Modify: `internal/control/mr.go` (`runMRShow` at line 739; new `mrShowScreen`) |
| |
1543 | - Test: `internal/control/mrscreen_test.go` (new), `internal/control/testdata/plain/mr-show.txt` (new) |
| |
1544 | |
| |
1545 | **Interfaces:** |
| |
1546 | - Consumes: `screen` types, `emitView`, `cGlyph`, `cYou`, `cMeta`, `checksMark` (`marks.go:15`), `gitutil.DiffNumstat`. |
| |
1547 | - Produces: `func mrShowScreen(c *Ctx, repo store.Repo, d MRShow, files []gitutil.NumStat) screen`. |
| |
1548 | |
| |
1549 | - [ ] **Step 1: Pin the piped output on unchanged code** |
| |
1550 | |
| |
1551 | `internal/control/mrscreen_test.go`: |
| |
1552 | |
| |
1553 | ```go |
| |
1554 | package control |
| |
1555 | |
| |
1556 | import ( |
| |
1557 | "testing" |
| |
1558 | |
| |
1559 | "gitbay.org/krz/gitbay/internal/gitutil" |
| |
1560 | "gitbay.org/krz/gitbay/internal/protocol" |
| |
1561 | ) |
| |
1562 | |
| |
1563 | func TestMRShowPlainPinned(t *testing.T) { |
| |
1564 | st, repo, owner := twoMRTestRepo(t) |
| |
1565 | c, out, errOut := mrTestCtx(st, owner) |
| |
1566 | if code := Dispatch(c, []string{"mr", "show", repo.Path(), "1"}); code != protocol.ExitOK { |
| |
1567 | t.Fatalf("exit %d: %s", code, errOut) |
| |
1568 | } |
| |
1569 | pinPlain(t, "mr-show", out.String()) |
| |
1570 | } |
| |
1571 | ``` |
| |
1572 | |
| |
1573 | Run: `go test ./internal/control/ -run TestMRShowPlainPinned -update-plain` |
| |
1574 | then `go test ./internal/control/ -run TestMRShowPlainPinned` |
| |
1575 | Expected: PASS both times; `testdata/plain/mr-show.txt` exists. |
| |
1576 | |
| |
1577 | - [ ] **Step 2: Write the failing screen tests** |
| |
1578 | |
| |
1579 | ```go |
| |
1580 | func mrShowFixture() MRShow { |
| |
1581 | d := MRShow{mrOut: mrOut{Number: 552, Title: "wire $PAGER through long views", State: "open", |
| |
1582 | Author: "cmc", Source: "cli-pager", TargetRef: "main", Body: "Pages long views.", BodyFormat: "markdown", |
| |
1583 | CreatedAt: "2026-10-01T11:40:00Z"}} |
| |
1584 | d.Commits = []CommitOut{{"8f3a1c2aaaaaaaa", "cli: page long output"}, {"2b77e90bbbbbbbb", "control: mark views"}} |
| |
1585 | d.Checks = []CheckOut{{Context: "test", State: "pending"}, {Context: "build", State: "success"}} |
| |
1586 | d.Gates = &GatesOut{ApprovalsRequired: 1, FastForward: false, Unmet: []string{"needs 1 approval", "behind main"}} |
| |
1587 | return d |
| |
1588 | } |
| |
1589 | |
| |
1590 | func actionArgvs(s screen, group string) [][]string { |
| |
1591 | var out [][]string |
| |
1592 | for _, a := range s.actions { |
| |
1593 | if a.group == group { |
| |
1594 | out = append(out, a.argv) |
| |
1595 | } |
| |
1596 | } |
| |
1597 | return out |
| |
1598 | } |
| |
1599 | |
| |
1600 | func TestMRShowScreenBehind(t *testing.T) { |
| |
1601 | c := screenCtx(100, false) |
| |
1602 | repo := store.Repo{OwnerName: "krz", Name: "gitbay", DefaultBranch: "main"} |
| |
1603 | s := mrShowScreen(c, repo, mrShowFixture(), []gitutil.NumStat{{Path: "cmd/gitbay/ssh.go", Added: 41, Deleted: 6, Status: "M"}}) |
| |
1604 | if got := actionArgvs(s, "Unblock"); len(got) != 1 || got[0][1] != "rebase" { |
| |
1605 | t.Errorf("Unblock = %q, want mr rebase", got) |
| |
1606 | } |
| |
1607 | if got := actionArgvs(s, "Merge"); len(got) != 0 { |
| |
1608 | t.Errorf("Merge offered with unmet gates: %q", got) |
| |
1609 | } |
| |
1610 | titles := map[string]int{} |
| |
1611 | for _, sec := range s.sections { |
| |
1612 | titles[sec.title] = sec.n |
| |
1613 | } |
| |
1614 | if titles["Commits"] != 2 || titles["Files"] != 1 { |
| |
1615 | t.Errorf("sections = %v", titles) |
| |
1616 | } |
| |
1617 | checkActions(t, s) |
| |
1618 | } |
| |
1619 | |
| |
1620 | func TestMRShowScreenReady(t *testing.T) { |
| |
1621 | c := screenCtx(100, false) |
| |
1622 | d := mrShowFixture() |
| |
1623 | d.Gates = &GatesOut{FastForward: true} |
| |
1624 | repo := store.Repo{OwnerName: "krz", Name: "gitbay", DefaultBranch: "main"} |
| |
1625 | s := mrShowScreen(c, repo, d, nil) |
| |
1626 | if len(actionArgvs(s, "Unblock")) != 0 { |
| |
1627 | t.Error("Unblock offered when nothing blocks") |
| |
1628 | } |
| |
1629 | if got := actionArgvs(s, "Merge"); len(got) != 1 || got[0][1] != "merge" { |
| |
1630 | t.Errorf("Merge = %q", got) |
| |
1631 | } |
| |
1632 | checkActions(t, s) |
| |
1633 | } |
| |
1634 | |
| |
1635 | func TestMRShowScreenClosedOffersNoWrites(t *testing.T) { |
| |
1636 | c := screenCtx(100, false) |
| |
1637 | d := mrShowFixture() |
| |
1638 | d.State, d.Gates = "merged", nil |
| |
1639 | s := mrShowScreen(c, store.Repo{OwnerName: "krz", Name: "gitbay"}, d, nil) |
| |
1640 | for _, a := range s.actions { |
| |
1641 | if a.group != "Read" { |
| |
1642 | t.Errorf("merged MR offers %q", a.argv) |
| |
1643 | } |
| |
1644 | } |
| |
1645 | } |
| |
1646 | ``` |
| |
1647 | |
| |
1648 | Add `store` to the imports. |
| |
1649 | |
| |
1650 | Run: `go test ./internal/control/ -run TestMRShowScreen` |
| |
1651 | Expected: `undefined: mrShowScreen`. |
| |
1652 | |
| |
1653 | - [ ] **Step 3: Implement `mrShowScreen`** |
| |
1654 | |
| |
1655 | Add to `mr.go` after `runMRShow`: |
| |
1656 | |
| |
1657 | ```go |
| |
1658 | // mrShowScreen is mr show at a terminal: where the merge request stands, |
| |
1659 | // its description, its commits, files and discussion, and what can be |
| |
1660 | // done about it now. |
| |
1661 | func mrShowScreen(c *Ctx, repo store.Repo, d MRShow, files []gitutil.NumStat) screen { |
| |
1662 | n := strconv.FormatInt(d.Number, 10) |
| |
1663 | path := repo.Path() |
| |
1664 | state := d.State |
| |
1665 | if d.Draft { |
| |
1666 | state = "draft" |
| |
1667 | } |
| |
1668 | s := screen{body: d.Body, format: d.BodyFormat} |
| |
1669 | |
| |
1670 | s.fields = append(s.fields, |
| |
1671 | field{"Merge", []cell{cLink("!"+n, c.siteURL(path, "mrs", n)), cText(d.Title)}}, |
| |
1672 | field{"State", []cell{cState(state), cMeta(d.Source+" → "+d.TargetRef, d.Author, relAge(d.CreatedAt, termNow()))}}, |
| |
1673 | ) |
| |
1674 | if len(d.Checks) > 0 { |
| |
1675 | var sts []store.CommitStatus |
| |
1676 | for _, x := range d.Checks { |
| |
1677 | sts = append(sts, store.CommitStatus{Context: x.Context, State: x.State}) |
| |
1678 | } |
| |
1679 | m := checksMark(sts) |
| |
1680 | s.fields = append(s.fields, field{"Checks", []cell{m}}) |
| |
1681 | } |
| |
1682 | open := d.State == "open" || d.State == "source_gone" |
| |
1683 | behind := false |
| |
1684 | if g := d.Gates; g != nil && open { |
| |
1685 | var parts []string |
| |
1686 | for _, u := range g.Unmet { |
| |
1687 | parts = append(parts, "✗ "+u) |
| |
1688 | } |
| |
1689 | behind = !g.FastForward |
| |
1690 | if len(parts) == 0 { |
| |
1691 | s.fields = append(s.fields, field{"Gates", []cell{cMark("✓ ready to merge", sgrGreen)}}) |
| |
1692 | } else { |
| |
1693 | s.fields = append(s.fields, field{"Gates", []cell{cMark(strings.Join(parts, " · "), sgrRed)}}) |
| |
1694 | } |
| |
1695 | } |
| |
1696 | if len(d.ReviewRequests) > 0 { |
| |
1697 | v := cText("requested of " + strings.Join(d.ReviewRequests, ", ")) |
| |
1698 | if slices.Contains(d.ReviewRequests, c.User.Username) { |
| |
1699 | v.sgr = sgrYellow |
| |
1700 | } |
| |
1701 | s.fields = append(s.fields, field{"Review", []cell{v}}) |
| |
1702 | } |
| |
1703 | if d.Milestone != "" { |
| |
1704 | s.fields = append(s.fields, field{"Milestone", []cell{cText(d.Milestone)}}) |
| |
1705 | } |
| |
1706 | if len(d.Labels) > 0 { |
| |
1707 | s.fields = append(s.fields, field{"Labels", []cell{cText(strings.Join(d.Labels, ", "))}}) |
| |
1708 | } |
| |
1709 | if !c.Term.Links { |
| |
1710 | s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "mrs", n))}}) |
| |
1711 | } |
| |
1712 | |
| |
1713 | commits := section{title: "Commits", n: len(d.Commits)} |
| |
1714 | for _, cm := range d.Commits { |
| |
1715 | commits.rows = append(commits.rows, rowOf(cRef(fmt.Sprintf("%.7s", cm.SHA)), cFlex(cm.Subject))) |
| |
1716 | } |
| |
1717 | add, del := 0, 0 |
| |
1718 | filesSec := section{n: len(files)} |
| |
1719 | for _, f := range files { |
| |
1720 | counts := "binary" |
| |
1721 | if f.Added >= 0 { |
| |
1722 | add, del = add+f.Added, del+f.Deleted |
| |
1723 | counts = fmt.Sprintf("+%d −%d", f.Added, f.Deleted) |
| |
1724 | } |
| |
1725 | filesSec.rows = append(filesSec.rows, rowOf(cRef(f.Status), cFlex(f.Path), cMeta(counts))) |
| |
1726 | } |
| |
1727 | filesSec.title = fmt.Sprintf("Files +%d −%d", add, del) |
| |
1728 | if len(files) == 0 { |
| |
1729 | filesSec.title = "Files" |
| |
1730 | } |
| |
1731 | disc := section{title: "Discussion", empty: true} |
| |
1732 | for _, cm := range d.Comments { |
| |
1733 | if cm.Kind == "system" { |
| |
1734 | continue |
| |
1735 | } |
| |
1736 | disc.n++ |
| |
1737 | disc.rows = append(disc.rows, row{cells: []cell{cText(cm.Author), cMeta(relAge(cm.CreatedAt, termNow()))}, body: cm.Body, format: cm.BodyFormat}) |
| |
1738 | } |
| |
1739 | s.sections = []section{commits, filesSec, disc} |
| |
1740 | |
| |
1741 | if open { |
| |
1742 | if behind { |
| |
1743 | s.actions = append(s.actions, action{"Unblock", []string{"mr", "rebase", n}}) |
| |
1744 | } |
| |
1745 | s.actions = append(s.actions, |
| |
1746 | action{"Review", []string{"mr", "review", path, n, "--approve"}}, |
| |
1747 | action{"Review", []string{"mr", "comment", path, n}}, |
| |
1748 | ) |
| |
1749 | if d.Gates != nil && len(d.Gates.Unmet) == 0 { |
| |
1750 | s.actions = append(s.actions, action{"Merge", []string{"mr", "merge", path, n}}) |
| |
1751 | } |
| |
1752 | } |
| |
1753 | s.actions = append(s.actions, action{"Read", []string{"mr", "diff", path, n}}) |
| |
1754 | return s |
| |
1755 | } |
| |
1756 | ``` |
| |
1757 | |
| |
1758 | Notes for the implementer: |
| |
1759 | - `mr rebase` is CLI-local and infers the repository from the clone, so it takes only the number. |
| |
1760 | - The "Files" title carries the totals as the spec's `Files (n) +a −d`; `renderSection` prints `title (n)`, so title `Files +86 −12` with `n: 4` renders as `Files +86 −12 (4)`. If that reads badly in the golden, add an optional `note string` to `section` drawn dim after the count, and use it here instead. Decide when you see the render; keep the test on `n`. |
| |
1761 | |
| |
1762 | In `runMRShow`, compute files only at a terminal and switch to `emitView`: |
| |
1763 | |
| |
1764 | ```go |
| |
1765 | var files []gitutil.NumStat |
| |
1766 | if c.Term.Cols > 0 && !c.JSON && base != "" { |
| |
1767 | files, _ = gitutil.DiffNumstat(dir, base, mrHeadRef(mr.Number)) |
| |
1768 | } |
| |
1769 | return c.emitView(d, func(w io.Writer) { |
| |
1770 | // existing plain closure, unchanged |
| |
1771 | }, func() screen { return mrShowScreen(c, repo, d, files) }) |
| |
1772 | ``` |
| |
1773 | |
| |
1774 | - [ ] **Step 4: Run tests** |
| |
1775 | |
| |
1776 | Run: `go test ./internal/control/ -run 'TestMRShow'` |
| |
1777 | Expected: all PASS, including `TestMRShowPlainPinned` against the pin taken in step 1. |
| |
1778 | |
| |
1779 | - [ ] **Step 5: Look at it** |
| |
1780 | |
| |
1781 | Build and run against a scratch instance or production read-only: |
| |
1782 | |
| |
1783 | ```bash |
| |
1784 | go build -o /tmp/gitbay ./cmd/gitbay && /tmp/gitbay mr show krz/gitbay 551 |
| |
1785 | ``` |
| |
1786 | |
| |
1787 | The server renders, so this shows the new screen only after deploy; until then, render from a test: add a temporary `t.Log(renderString(c, s))` in `TestMRShowScreenBehind`, run with `-v`, read it, remove the log. |
| |
1788 | |
| |
1789 | - [ ] **Step 6: Commit** |
| |
1790 | |
| |
1791 | ```bash |
| |
1792 | git add internal/control/mr.go internal/control/mrscreen_test.go internal/control/testdata/plain/mr-show.txt |
| |
1793 | git commit -m "control: mr show as a screen |
| |
1794 | |
| |
1795 | Ref #318" |
| |
1796 | ``` |
| |
1797 | |
| |
1798 | ### Task 11: `issue show` screen |
| |
1799 | |
| |
1800 | **Files:** |
| |
1801 | - Modify: `internal/control/issue.go` (`runIssueShow` at line 381; new `issueShowScreen`) |
| |
1802 | - Test: `internal/control/issuescreen_test.go` (new), `testdata/plain/issue-show.txt` |
| |
1803 | |
| |
1804 | **Interfaces:** |
| |
1805 | - Produces: `func issueShowScreen(c *Ctx, repo store.Repo, d IssueShow, canWrite bool) screen`. |
| |
1806 | |
| |
1807 | - [ ] **Step 1: Pin piped output** |
| |
1808 | |
| |
1809 | ```go |
| |
1810 | func TestIssueShowPlainPinned(t *testing.T) { |
| |
1811 | st, repo, uid := newQueueTestRepo(t) |
| |
1812 | owner := store.User{ID: uid, Username: "alice"} |
| |
1813 | id, err := st.CreateIssue(repo.ID, uid, "Android app", "An app.", "markdown") |
| |
1814 | if err != nil { |
| |
1815 | t.Fatal(err) |
| |
1816 | } |
| |
1817 | if err := st.AddIssueComment(id, uid, "Started.", "markdown"); err != nil { |
| |
1818 | t.Fatal(err) |
| |
1819 | } |
| |
1820 | c, out, errOut := mrTestCtx(st, owner) |
| |
1821 | if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK { |
| |
1822 | t.Fatalf("exit %d: %s", code, errOut) |
| |
1823 | } |
| |
1824 | pinPlain(t, "issue-show", out.String()) |
| |
1825 | } |
| |
1826 | ``` |
| |
1827 | |
| |
1828 | Run with `-update-plain`, then without. Expected: PASS. |
| |
1829 | |
| |
1830 | - [ ] **Step 2: Failing screen tests** |
| |
1831 | |
| |
1832 | ```go |
| |
1833 | func issueFixture() IssueShow { |
| |
1834 | return IssueShow{issueOut: issueOut{Number: 12, Title: "Android app", State: "open", Author: "cmc", |
| |
1835 | Labels: []string{"mobile"}, Assignees: []string{"alice"}, Body: "An app.", BodyFormat: "markdown", |
| |
1836 | CreatedAt: "2026-08-21T10:00:00Z"}, |
| |
1837 | Comments: []commentOut{{ID: 1, Author: "cmc", Body: "Started.", BodyFormat: "markdown", CreatedAt: "2026-09-01T10:00:00Z", Kind: "comment"}, |
| |
1838 | {ID: 2, Author: "cmc", Body: "labelled mobile", CreatedAt: "2026-09-01T10:00:00Z", Kind: "system"}}} |
| |
1839 | } |
| |
1840 | |
| |
1841 | func TestIssueShowScreen(t *testing.T) { |
| |
1842 | c := screenCtx(100, false) |
| |
1843 | c.User = store.User{Username: "alice"} |
| |
1844 | repo := store.Repo{OwnerName: "krz", Name: "gitbay"} |
| |
1845 | s := issueShowScreen(c, repo, issueFixture(), true) |
| |
1846 | if len(s.sections) != 1 || s.sections[0].title != "Discussion" || s.sections[0].n != 1 { |
| |
1847 | t.Errorf("sections = %+v", s.sections) |
| |
1848 | } |
| |
1849 | var verbs []string |
| |
1850 | for _, a := range s.actions { |
| |
1851 | verbs = append(verbs, a.argv[1]) |
| |
1852 | } |
| |
1853 | if !slices.Equal(verbs, []string{"comment", "assign", "label", "close"}) { |
| |
1854 | t.Errorf("actions = %q", verbs) |
| |
1855 | } |
| |
1856 | checkActions(t, s) |
| |
1857 | } |
| |
1858 | |
| |
1859 | func TestIssueShowScreenReaderOnlyComments(t *testing.T) { |
| |
1860 | c := screenCtx(100, false) |
| |
1861 | c.User = store.User{Username: "bob"} |
| |
1862 | s := issueShowScreen(c, store.Repo{OwnerName: "krz", Name: "gitbay"}, issueFixture(), false) |
| |
1863 | if len(s.actions) != 1 || s.actions[0].argv[1] != "comment" { |
| |
1864 | t.Errorf("reader actions = %+v", s.actions) |
| |
1865 | } |
| |
1866 | } |
| |
1867 | ``` |
| |
1868 | |
| |
1869 | Expected on run: `undefined: issueShowScreen`. |
| |
1870 | |
| |
1871 | - [ ] **Step 3: Implement** |
| |
1872 | |
| |
1873 | ```go |
| |
1874 | // issueShowScreen is issue show at a terminal. canWrite is write access |
| |
1875 | // or authorship: what lets the viewer close, assign and label. |
| |
1876 | func issueShowScreen(c *Ctx, repo store.Repo, d IssueShow, canWrite bool) screen { |
| |
1877 | n := strconv.FormatInt(d.Number, 10) |
| |
1878 | path := repo.Path() |
| |
1879 | s := screen{body: d.Body, format: d.BodyFormat} |
| |
1880 | s.fields = append(s.fields, |
| |
1881 | field{"Issue", []cell{cLink("#"+n, c.siteURL(path, "issues", n)), cText(d.Title)}}, |
| |
1882 | field{"State", []cell{cState(d.State), cMeta(d.Author, relAge(d.CreatedAt, termNow()))}}, |
| |
1883 | ) |
| |
1884 | if len(d.Labels) > 0 { |
| |
1885 | s.fields = append(s.fields, field{"Labels", []cell{cText(strings.Join(d.Labels, ", "))}}) |
| |
1886 | } |
| |
1887 | if len(d.Assignees) > 0 { |
| |
1888 | v := cText(strings.Join(d.Assignees, ", ")) |
| |
1889 | if slices.Contains(d.Assignees, c.User.Username) { |
| |
1890 | v.sgr = sgrYellow |
| |
1891 | } |
| |
1892 | s.fields = append(s.fields, field{"Assignee", []cell{v}}) |
| |
1893 | } |
| |
1894 | if d.Milestone != "" { |
| |
1895 | s.fields = append(s.fields, field{"Milestone", []cell{cText(d.Milestone)}}) |
| |
1896 | } |
| |
1897 | if !c.Term.Links { |
| |
1898 | s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "issues", n))}}) |
| |
1899 | } |
| |
1900 | disc := section{title: "Discussion", empty: true} |
| |
1901 | for _, cm := range d.Comments { |
| |
1902 | if cm.Kind == "system" { |
| |
1903 | continue |
| |
1904 | } |
| |
1905 | disc.n++ |
| |
1906 | disc.rows = append(disc.rows, row{cells: []cell{cText(cm.Author), cMeta(relAge(cm.CreatedAt, termNow()))}, body: cm.Body, format: cm.BodyFormat}) |
| |
1907 | } |
| |
1908 | s.sections = []section{disc} |
| |
1909 | |
| |
1910 | s.actions = append(s.actions, action{"Discuss", []string{"issue", "comment", path, n}}) |
| |
1911 | if canWrite { |
| |
1912 | s.actions = append(s.actions, |
| |
1913 | action{"Triage", []string{"issue", "assign", path, n, "--add", c.User.Username}}, |
| |
1914 | action{"Triage", []string{"issue", "label", path, n, "--add", "<label>"}}, |
| |
1915 | ) |
| |
1916 | verb := "close" |
| |
1917 | if d.State == "closed" { |
| |
1918 | verb = "reopen" |
| |
1919 | } |
| |
1920 | s.actions = append(s.actions, action{"State", []string{"issue", verb, path, n}}) |
| |
1921 | } |
| |
1922 | return s |
| |
1923 | } |
| |
1924 | ``` |
| |
1925 | |
| |
1926 | Check `issue assign` and `issue label` flags with `ssh git@gitbay.org help issue assign` and `help issue label`; adjust the argv to the real flags. A literal `<label>` placeholder in a suggested command is acceptable in the legend (it is what the user fills in) but `checkActions` will parse it as a value; that is fine. |
| |
1927 | |
| |
1928 | In `runIssueShow`, compute `canWrite` with the same rule `setIssueState` uses (author, or `policy.CanWrite` on the access role) only at a terminal, and switch to `emitView(d, plain, func() screen { return issueShowScreen(c, repo, d, canWrite) })`. |
| |
1929 | |
| |
1930 | - [ ] **Step 4: Run tests** |
| |
1931 | |
| |
1932 | Run: `go test ./internal/control/ -run 'TestIssueShow'` |
| |
1933 | Expected: PASS. |
| |
1934 | |
| |
1935 | - [ ] **Step 5: Commit** |
| |
1936 | |
| |
1937 | ```bash |
| |
1938 | git add internal/control/issue.go internal/control/issuescreen_test.go internal/control/testdata/plain/issue-show.txt |
| |
1939 | git commit -m "control: issue show as a screen |
| |
1940 | |
| |
1941 | Ref #318" |
| |
1942 | ``` |
| |
1943 | |
| |
1944 | ### Task 12: `repo show` screen |
| |
1945 | |
| |
1946 | **Files:** |
| |
1947 | - Modify: `internal/control/repo.go` (`runRepoShow` at line 376, `repoGlance`, new `repoShowScreen`) |
| |
1948 | - Test: `internal/control/reposcreen_test.go` (new), `testdata/plain/repo-show.txt` |
| |
1949 | |
| |
1950 | **Interfaces:** |
| |
1951 | - Produces: `func repoShowScreen(c *Ctx, d repoShowOut, g repoGlance, mrs []store.MR, issues []store.Issue, commits []CommitOut) screen`. Hoist the local `type out struct` in `runRepoShow` to package level as `repoShowOut` (and `mirrorOut` as `repoMirrorOut`) so the builder can take it; the JSON tags do not change. |
| |
1952 | |
| |
1953 | - [ ] **Step 1: Pin piped output** |
| |
1954 | |
| |
1955 | ```go |
| |
1956 | func TestRepoShowPlainPinned(t *testing.T) { |
| |
1957 | st, repo, uid := newQueueTestRepo(t) |
| |
1958 | c, out, errOut := mrTestCtx(st, store.User{ID: uid, Username: "alice"}) |
| |
1959 | if code := Dispatch(c, []string{"repo", "show", repo.Path()}); code != protocol.ExitOK { |
| |
1960 | t.Fatalf("exit %d: %s", code, errOut) |
| |
1961 | } |
| |
1962 | pinPlain(t, "repo-show", out.String()) |
| |
1963 | } |
| |
1964 | ``` |
| |
1965 | |
| |
1966 | Run with `-update-plain`, then without. Expected: PASS. Then hoist the types; run again; still PASS. |
| |
1967 | |
| |
1968 | - [ ] **Step 2: Failing screen test** |
| |
1969 | |
| |
1970 | ```go |
| |
1971 | func TestRepoShowScreen(t *testing.T) { |
| |
1972 | c := screenCtx(100, false) |
| |
1973 | d := repoShowOut{Path: "krz/gitbay", Description: "A CLI-first git forge.", Visibility: "public", DefaultBranch: "main", |
| |
1974 | Topics: []string{"cli", "forge"}} |
| |
1975 | g := repoGlance{clone: "ssh://git@gitbay.org/krz/gitbay.git", release: "v1.41.0, 2h ago", checks: "✓ 2/2 on main"} |
| |
1976 | mrs := []store.MR{{Number: 552, Title: "wire $PAGER", UpdatedAt: "2026-10-01T11:40:00Z"}} |
| |
1977 | s := repoShowScreen(c, d, g, mrs, nil, []CommitOut{{"a136534ff9", "changelog: v1.41.0"}}) |
| |
1978 | got := map[string]int{} |
| |
1979 | for _, sec := range s.sections { |
| |
1980 | got[sec.title] = sec.n |
| |
1981 | } |
| |
1982 | if got["Open merge requests"] != 1 || got["Recent commits"] != 1 { |
| |
1983 | t.Errorf("sections = %v", got) |
| |
1984 | } |
| |
1985 | if _, ok := got["Open issues"]; ok && got["Open issues"] == 0 && len(s.sections) == 3 { |
| |
1986 | // an empty section with no rows is not drawn; it may be present in the model |
| |
1987 | } |
| |
1988 | checkActions(t, s) |
| |
1989 | } |
| |
1990 | ``` |
| |
1991 | |
| |
1992 | Expected on run: `undefined: repoShowScreen`. |
| |
1993 | |
| |
1994 | - [ ] **Step 3: Implement** |
| |
1995 | |
| |
1996 | `repoAtAGlance` keeps its fields; add the counts as ints (`issues, mrs int`) beside the strings so sections can carry totals, and fill them from `c.Store.OpenCounts`. |
| |
1997 | |
| |
1998 | ```go |
| |
1999 | // repoShowScreen is repo show at a terminal: how to clone it, where the |
| |
2000 | // default branch stands, what is open, and the latest commits. |
| |
2001 | func repoShowScreen(c *Ctx, d repoShowOut, g repoGlance, mrs []store.MR, issues []store.Issue, commits []CommitOut) screen { |
| |
2002 | s := screen{body: d.Description, format: "text"} |
| |
2003 | name := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility)} |
| |
2004 | if d.Archived { |
| |
2005 | name = append(name, cState("archived")) |
| |
2006 | } |
| |
2007 | s.fields = append(s.fields, field{"Repo", name}, field{"Clone", []cell{cText(g.clone)}}) |
| |
2008 | head := []cell{cText(d.DefaultBranch)} |
| |
2009 | if g.checks != "" { |
| |
2010 | head = append(head, cText(strings.TrimSuffix(g.checks, " on "+d.DefaultBranch))) |
| |
2011 | } |
| |
2012 | s.fields = append(s.fields, field{"Head", head}) |
| |
2013 | if g.release != "" { |
| |
2014 | s.fields = append(s.fields, field{"Release", []cell{cText(g.release)}}) |
| |
2015 | } |
| |
2016 | if len(d.Topics) > 0 { |
| |
2017 | s.fields = append(s.fields, field{"Topics", []cell{cText(strings.Join(d.Topics, ", "))}}) |
| |
2018 | } |
| |
2019 | if d.ForkOf != "" { |
| |
2020 | s.fields = append(s.fields, field{"Fork of", []cell{cRef(d.ForkOf)}}) |
| |
2021 | } |
| |
2022 | for _, m := range d.Mirrors { |
| |
2023 | if m.LastError != "" { |
| |
2024 | s.fields = append(s.fields, field{"Mirror", []cell{cGlyph("failed"), cText(m.Direction + " " + m.URL + ": " + m.LastError)}}) |
| |
2025 | } |
| |
2026 | } |
| |
2027 | if !c.Term.Links { |
| |
2028 | s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(d.Path))}}) |
| |
2029 | } |
| |
2030 | |
| |
2031 | ms := section{title: "Open merge requests", n: g.mrsN, more: []string{"mr", "list", d.Path}} |
| |
2032 | for _, m := range mrs { |
| |
2033 | ms.rows = append(ms.rows, rowOf(cRef(fmt.Sprintf("!%d", m.Number)), cFlex(m.Title), cAge(m.UpdatedAt))) |
| |
2034 | } |
| |
2035 | is := section{title: "Open issues", n: g.issuesN, more: []string{"issue", "list", d.Path}} |
| |
2036 | for _, i := range issues { |
| |
2037 | is.rows = append(is.rows, rowOf(cRef(fmt.Sprintf("#%d", i.Number)), cFlex(i.Title), cAge(i.UpdatedAt))) |
| |
2038 | } |
| |
2039 | cs := section{title: "Recent commits", n: len(commits), more: []string{"repo", "log", d.Path}} |
| |
2040 | for _, cm := range commits { |
| |
2041 | cs.rows = append(cs.rows, rowOf(cRef(fmt.Sprintf("%.7s", cm.SHA)), cFlex(cm.Subject))) |
| |
2042 | } |
| |
2043 | s.sections = []section{ms, is, cs} |
| |
2044 | s.actions = []action{ |
| |
2045 | {"Contribute", []string{"mr", "create", d.Path}}, |
| |
2046 | {"Contribute", []string{"issue", "create", d.Path}}, |
| |
2047 | {"Read", []string{"repo", "log", d.Path}}, |
| |
2048 | } |
| |
2049 | return s |
| |
2050 | } |
| |
2051 | ``` |
| |
2052 | |
| |
2053 | `Recent commits` sets `more`, but `n == len(rows)` so no "+n more" line prints; that is intended. In `runRepoShow`, at a terminal only, read up to 5 open MRs (`c.Store.QueryMRs` or the function `runMRList` uses, with `State: "open", Limit: 5`), up to 5 open issues (`c.Store.QueryIssues(repo.ID, store.IssueFilter{State: "open", Limit: 5})`), and 5 commits (the helper `repo log` uses; `grep -n 'func runRepoLog' internal/control/*.go` and reuse what it calls). Then `emitView(d, plain, func() screen { return repoShowScreen(c, d, glance, mrs, issues, commits) })`. The plain closure keeps only its `else` branch: delete the `c.Term.Cols > 0` branch inside it, since a terminal no longer reaches it. |
| |
2054 | |
| |
2055 | - [ ] **Step 4: Run tests** |
| |
2056 | |
| |
2057 | Run: `go test ./internal/control/ -run 'TestRepoShow'` |
| |
2058 | Expected: PASS, pin unchanged. |
| |
2059 | |
| |
2060 | - [ ] **Step 5: Commit** |
| |
2061 | |
| |
2062 | ```bash |
| |
2063 | git add internal/control/repo.go internal/control/reposcreen_test.go internal/control/testdata/plain/repo-show.txt |
| |
2064 | git commit -m "control: repo show as a screen |
| |
2065 | |
| |
2066 | Ref #318" |
| |
2067 | ``` |
| |
2068 | |
| |
2069 | ### Task 13: `build show` screen |
| |
2070 | |
| |
2071 | **Files:** |
| |
2072 | - Modify: `internal/control/build.go` (`runBuildShow` at line 240, new `buildShowScreen`) |
| |
2073 | - Test: `internal/control/buildscreen_test.go` (new), `testdata/plain/build-show.txt` |
| |
2074 | |
| |
2075 | **Interfaces:** |
| |
2076 | - Produces: `func buildShowScreen(c *Ctx, repo store.Repo, d BuildOut, mr *store.MR) screen`. |
| |
2077 | |
| |
2078 | - [ ] **Step 1: Pin piped output** |
| |
2079 | |
| |
2080 | ```go |
| |
2081 | func TestBuildShowPlainPinned(t *testing.T) { |
| |
2082 | st, repo, uid := newQueueTestRepo(t) |
| |
2083 | id, err := st.CreateBuild(repo.ID, "test", "a136534ff9a136534ff9a136534ff9a136534ff9", "main", `["go vet ./...","go test ./..."]`, "", "", true) |
| |
2084 | if err != nil { |
| |
2085 | t.Fatal(err) |
| |
2086 | } |
| |
2087 | if err := st.FinishBuild(id, "success"); err != nil { |
| |
2088 | t.Fatal(err) |
| |
2089 | } |
| |
2090 | c, out, errOut := mrTestCtx(st, store.User{ID: uid, Username: "alice"}) |
| |
2091 | if code := Dispatch(c, []string{"build", "show", repo.Path(), "1"}); code != protocol.ExitOK { |
| |
2092 | t.Fatalf("exit %d: %s", code, errOut) |
| |
2093 | } |
| |
2094 | pinPlain(t, "build-show", out.String()) |
| |
2095 | } |
| |
2096 | ``` |
| |
2097 | |
| |
2098 | If the build number is not 1, read it back with the store's build-by-id accessor and pass that number. Run with `-update-plain`, then without. |
| |
2099 | |
| |
2100 | - [ ] **Step 2: Failing screen test** |
| |
2101 | |
| |
2102 | ```go |
| |
2103 | func TestBuildShowScreenFailed(t *testing.T) { |
| |
2104 | c := screenCtx(100, false) |
| |
2105 | d := BuildOut{Number: 83, Job: "daily-pull", Status: "failure", SHA: "355b04066b", Ref: "main", |
| |
2106 | Steps: []string{"setup", "pull", "publish"}, FailedStep: 2, FailedReason: "exit 1", DurationS: 74} |
| |
2107 | s := buildShowScreen(c, store.Repo{OwnerName: "krz", Name: "omaha-metro-blotter"}, d, nil) |
| |
2108 | steps := s.sections[0] |
| |
2109 | if steps.title != "Steps" || steps.n != 3 { |
| |
2110 | t.Fatalf("steps = %+v", steps) |
| |
2111 | } |
| |
2112 | if g := steps.rows[1].cells[0]; g.s != "✗" { |
| |
2113 | t.Errorf("failed step glyph = %q", g.s) |
| |
2114 | } |
| |
2115 | if g := steps.rows[2].cells[0]; g.s != "" { |
| |
2116 | t.Errorf("step after the failure = %q, want no glyph", g.s) |
| |
2117 | } |
| |
2118 | if len(s.actions) != 1 || s.actions[0].argv[1] != "log" { |
| |
2119 | t.Errorf("actions = %+v", s.actions) |
| |
2120 | } |
| |
2121 | checkActions(t, s) |
| |
2122 | } |
| |
2123 | ``` |
| |
2124 | |
| |
2125 | Expected on run: `undefined: buildShowScreen`. |
| |
2126 | |
| |
2127 | - [ ] **Step 3: Implement** |
| |
2128 | |
| |
2129 | ```go |
| |
2130 | // buildShowScreen is build show at a terminal: the build's outcome, what |
| |
2131 | // it ran on, each step's outcome, and the command for its log. |
| |
2132 | func buildShowScreen(c *Ctx, repo store.Repo, d BuildOut, mr *store.MR) screen { |
| |
2133 | n := strconv.FormatInt(d.Number, 10) |
| |
2134 | path := repo.Path() |
| |
2135 | var s screen |
| |
2136 | s.fields = append(s.fields, |
| |
2137 | field{"Build", []cell{cLink(n, c.siteURL(path, "builds", n)), cText(d.Job)}}, |
| |
2138 | field{"State", []cell{cGlyph(d.Status), cState(d.Status)}}, |
| |
2139 | field{"Commit", []cell{cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Subject)}}, |
| |
2140 | field{"Ref", []cell{cText(d.Ref)}}, |
| |
2141 | ) |
| |
2142 | if mr != nil { |
| |
2143 | s.fields = append(s.fields, field{"MR", []cell{cRef(fmt.Sprintf("!%d", mr.Number)), cText(mr.Title)}}) |
| |
2144 | } |
| |
2145 | if d.DurationS > 0 { |
| |
2146 | s.fields = append(s.fields, field{"Duration", []cell{cText(c.Term.dur(d.DurationS))}}) |
| |
2147 | } |
| |
2148 | if !c.Term.Links { |
| |
2149 | s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "builds", n))}}) |
| |
2150 | } |
| |
2151 | steps := section{title: "Steps", n: len(d.Steps)} |
| |
2152 | for i, step := range d.Steps { |
| |
2153 | line, _, _ := strings.Cut(step, "\n") |
| |
2154 | state := stepState(d.Status, d.FailedStep, i+1) |
| |
2155 | meta := "" |
| |
2156 | if i+1 == d.FailedStep { |
| |
2157 | meta = d.FailedReason |
| |
2158 | } |
| |
2159 | steps.rows = append(steps.rows, rowOf(cGlyph(state), cFlex(line), cMeta(meta))) |
| |
2160 | } |
| |
2161 | s.sections = []section{steps} |
| |
2162 | s.actions = []action{{"Read", []string{"build", "log", path, n}}} |
| |
2163 | return s |
| |
2164 | } |
| |
2165 | ``` |
| |
2166 | |
| |
2167 | Check what `stepState` returns for steps after a failure (the comment says they "never ran"); if it returns a word `glyph` maps to `○` ("skipped"), change the test's third assertion to `○`. In `runBuildShow`, move the `OpenMRBySource` lookup out of the plain closure into a terminal-only block before `emitView`, and drop the plain closure's `c.Term.Cols > 0` code (the `mr`/`logCmd` fields and the steps table), which only a terminal reached. |
| |
2168 | |
| |
2169 | - [ ] **Step 4: Run tests** |
| |
2170 | |
| |
2171 | Run: `go test ./internal/control/ -run 'TestBuildShow'` |
| |
2172 | Expected: PASS, pin unchanged. |
| |
2173 | |
| |
2174 | - [ ] **Step 5: Commit, verify, MR** |
| |
2175 | |
| |
2176 | ```bash |
| |
2177 | git add internal/control/build.go internal/control/buildscreen_test.go internal/control/testdata/plain/build-show.txt |
| |
2178 | git commit -m "control: build show as a screen |
| |
2179 | |
| |
2180 | Ref #318" |
| |
2181 | go build ./... && go vet ./... && go test ./internal/control/ ./internal/gitutil/ |
| |
2182 | git push -u origin cli-views-2a |
| |
2183 | gitbay mr create --source cli-views-2a --target main --title "control: show screens (stage 2a of CLI views)" |
| |
2184 | ``` |
| |
2185 | |
| |
2186 | Add a CHANGELOG line under the unreleased section naming the four screens before pushing. |
| |
2187 | |
| |
2188 | --- |
| |
2189 | |
| |
2190 | ## Stage 2b: lists and dashboard (one MR, Ref #318) |
| |
2191 | |
| |
2192 | ### Task 14: `mr list`, `issue list`, `build list` screens |
| |
2193 | |
| |
2194 | **Files:** |
| |
2195 | - Modify: `internal/control/mr.go` (`mrListTerm` at line 711 and its caller), `issue.go` (`runIssueList` terminal branch), `build.go` (`runBuildList`) |
| |
2196 | - Test: `internal/control/listscreen_test.go` (new), `testdata/plain/{mr,issue,build}-list.txt` |
| |
2197 | |
| |
2198 | **Interfaces:** |
| |
2199 | - Produces: |
| |
2200 | |
| |
2201 | ```go |
| |
2202 | func mrListScreen(c *Ctx, repo store.Repo, state string, mrs []store.MR, ds []mrOut, checks, review map[int64]cell) screen |
| |
2203 | func issueListScreen(c *Ctx, repo store.Repo, state string, issues []store.Issue, ds []issueOut, comments map[int64]int, labels, assignees map[int64][]string) screen |
| |
2204 | func buildListScreen(c *Ctx, repo store.Repo, ds []BuildOut) screen |
| |
2205 | ``` |
| |
2206 | |
| |
2207 | - [ ] **Step 1: Pin piped output for all three** |
| |
2208 | |
| |
2209 | ```go |
| |
2210 | func TestListsPlainPinned(t *testing.T) { |
| |
2211 | st, repo, owner := twoMRTestRepo(t) |
| |
2212 | if _, err := st.CreateIssue(repo.ID, owner.ID, "Android app", "", "markdown"); err != nil { |
| |
2213 | t.Fatal(err) |
| |
2214 | } |
| |
2215 | if _, err := st.CreateBuild(repo.ID, "test", "abc111", "main", `["go test ./..."]`, "", "", true); err != nil { |
| |
2216 | t.Fatal(err) |
| |
2217 | } |
| |
2218 | for name, argv := range map[string][]string{ |
| |
2219 | "mr-list": {"mr", "list", repo.Path()}, |
| |
2220 | "issue-list": {"issue", "list", repo.Path()}, |
| |
2221 | "build-list": {"build", "list", repo.Path()}, |
| |
2222 | } { |
| |
2223 | c, out, errOut := mrTestCtx(st, owner) |
| |
2224 | if code := Dispatch(c, argv); code != protocol.ExitOK { |
| |
2225 | t.Fatalf("%s: exit %d: %s", name, code, errOut) |
| |
2226 | } |
| |
2227 | pinPlain(t, name, out.String()) |
| |
2228 | } |
| |
2229 | } |
| |
2230 | ``` |
| |
2231 | |
| |
2232 | Run with `-update-plain`, then without. |
| |
2233 | |
| |
2234 | - [ ] **Step 2: Failing screen tests** |
| |
2235 | |
| |
2236 | ```go |
| |
2237 | func TestMRListScreen(t *testing.T) { |
| |
2238 | c := screenCtx(100, false) |
| |
2239 | c.User = store.User{Username: "alice"} |
| |
2240 | repo := store.Repo{OwnerName: "krz", Name: "gitbay", DefaultBranch: "main"} |
| |
2241 | mrs := []store.MR{{ID: 1, Number: 552, UpdatedAt: "2026-10-01T11:40:00Z"}} |
| |
2242 | ds := []mrOut{{Number: 552, Title: "wire $PAGER", State: "open", Source: "cli-pager", TargetRef: "main"}} |
| |
2243 | checks := map[int64]cell{1: cMark("✓ 2/2", sgrGreen)} |
| |
2244 | review := map[int64]cell{1: cMark("review requested", sgrYellow)} |
| |
2245 | s := mrListScreen(c, repo, "open", mrs, ds, checks, review) |
| |
2246 | if len(s.sections) != 1 || s.sections[0].title != "Open merge requests" || s.sections[0].n != 1 { |
| |
2247 | t.Fatalf("sections = %+v", s.sections) |
| |
2248 | } |
| |
2249 | if g := s.sections[0].rows[0].cells[1]; g.s != "●" { |
| |
2250 | t.Errorf("review requested of the viewer should lead with ●, got %q", g.s) |
| |
2251 | } |
| |
2252 | var verbs []string |
| |
2253 | for _, a := range s.actions { |
| |
2254 | verbs = append(verbs, strings.Join(a.argv[:2], " ")) |
| |
2255 | } |
| |
2256 | if !slices.Equal(verbs, []string{"mr create", "mr list"}) { |
| |
2257 | t.Errorf("actions = %q", verbs) |
| |
2258 | } |
| |
2259 | checkActions(t, s) |
| |
2260 | } |
| |
2261 | ``` |
| |
2262 | |
| |
2263 | Write the equivalent `TestIssueListScreen` (assigned to the viewer leads with `●`; title `Open issues`; actions `issue create`, `issue list --state closed`) and `TestBuildListScreen` (glyph from status; title `Builds`; no state filter action). |
| |
2264 | |
| |
2265 | Expected on run: undefined builders. |
| |
2266 | |
| |
2267 | - [ ] **Step 3: Implement** |
| |
2268 | |
| |
2269 | ```go |
| |
2270 | // mrListScreen is mr list at a terminal: one section of merge requests, |
| |
2271 | // each led by ● when it waits on the viewer or its checks' mark, then |
| |
2272 | // title, branch, review and age. |
| |
2273 | func mrListScreen(c *Ctx, repo store.Repo, state string, mrs []store.MR, ds []mrOut, checks, review map[int64]cell) screen { |
| |
2274 | title := map[string]string{"open": "Open merge requests", "closed": "Closed merge requests", "merged": "Merged merge requests", "all": "Merge requests"}[state] |
| |
2275 | sec := section{title: title, n: len(ds)} |
| |
2276 | for i, d := range ds { |
| |
2277 | m := mrs[i] |
| |
2278 | lead := checks[m.ID] |
| |
2279 | lead.kind = kindGlyph |
| |
2280 | lead.s, _, _ = strings.Cut(lead.s, " ") |
| |
2281 | if r := review[m.ID]; r.sgr == sgrYellow { |
| |
2282 | lead = cYou() |
| |
2283 | } |
| |
2284 | branch := d.Source |
| |
2285 | if d.TargetRef != repo.DefaultBranch { |
| |
2286 | branch += " → " + d.TargetRef |
| |
2287 | } |
| |
2288 | ref := cLink(fmt.Sprintf("!%d", d.Number), c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10))) |
| |
2289 | sec.rows = append(sec.rows, rowOf(ref, lead, cFlex(d.Title), cMeta(branch, review[m.ID].s, relAge(m.UpdatedAt, termNow())))) |
| |
2290 | } |
| |
2291 | s := screen{sections: []section{sec}} |
| |
2292 | s.actions = append(s.actions, action{"New", []string{"mr", "create", repo.Path()}}) |
| |
2293 | other := "all" |
| |
2294 | if state == "all" { |
| |
2295 | other = "open" |
| |
2296 | } |
| |
2297 | s.actions = append(s.actions, action{"Filter", []string{"mr", "list", repo.Path(), "--state", other}}) |
| |
2298 | return s |
| |
2299 | } |
| |
2300 | ``` |
| |
2301 | |
| |
2302 | `issueListScreen` follows the same shape: lead `cYou()` when `assigneesMark` would colour the viewer yellow, otherwise `cGlyph(d.State)`; text the title; meta labels, assignees, comment count (`"3 comments"`), age. `buildListScreen`: lead `cGlyph(d.Status)`, ref the number, text `job ref`, meta subject and age; one action, `Read: build show <path> <latest number>` when the list is non-empty. |
| |
2303 | |
| |
2304 | Callers: replace each terminal branch with `emitPageView(p, ds, next, plain, func() screen { ... })`, keeping the plain closures byte-for-byte. `mrListTerm` is deleted once nothing calls it. An empty list at a terminal still prints "nothing to list" to stderr (that is `emit`'s), so the builders never see zero rows from a real call. |
| |
2305 | |
| |
2306 | - [ ] **Step 4: Run tests** |
| |
2307 | |
| |
2308 | Run: `go test ./internal/control/ -run 'TestListsPlainPinned|TestMRListScreen|TestIssueListScreen|TestBuildListScreen'` |
| |
2309 | Expected: PASS. |
| |
2310 | |
| |
2311 | - [ ] **Step 5: Commit** |
| |
2312 | |
| |
2313 | ```bash |
| |
2314 | git add internal/control/mr.go internal/control/issue.go internal/control/build.go internal/control/listscreen_test.go internal/control/testdata/plain/ |
| |
2315 | git commit -m "control: mr, issue and build lists as screens |
| |
2316 | |
| |
2317 | Ref #318" |
| |
2318 | ``` |
| |
2319 | |
| |
2320 | ### Task 15: `dashboard` screen |
| |
2321 | |
| |
2322 | **Files:** |
| |
2323 | - Modify: `internal/control/dashboard.go` (`runDashboard` from line 140; delete `needsYou` once unused) |
| |
2324 | - Test: `internal/control/dashscreen_test.go` (new), `testdata/plain/dashboard.txt` |
| |
2325 | |
| |
2326 | **Interfaces:** |
| |
2327 | - Produces: `func dashboardScreen(c *Ctx, d DashboardOut, lines []FeedLine) screen`. Check the element type `FeedLines` returns (`grep -n 'func FeedLines' internal/control/feedline.go`) and use it. |
| |
2328 | |
| |
2329 | - [ ] **Step 1: Pin piped output** |
| |
2330 | |
| |
2331 | ```go |
| |
2332 | func TestDashboardPlainPinned(t *testing.T) { |
| |
2333 | st, _, owner := twoMRTestRepo(t) |
| |
2334 | c, out, errOut := mrTestCtx(st, owner) |
| |
2335 | if code := Dispatch(c, []string{"dashboard"}); code != protocol.ExitOK { |
| |
2336 | t.Fatalf("exit %d: %s", code, errOut) |
| |
2337 | } |
| |
2338 | pinPlain(t, "dashboard", out.String()) |
| |
2339 | } |
| |
2340 | ``` |
| |
2341 | |
| |
2342 | Run with `-update-plain`, then without. |
| |
2343 | |
| |
2344 | - [ ] **Step 2: Failing screen tests** |
| |
2345 | |
| |
2346 | ```go |
| |
2347 | func dashFixture() DashboardOut { |
| |
2348 | return DashboardOut{ |
| |
2349 | Assigned: []DashboardItem{{Repo: "krz/skunky-art", Number: 33, Title: "LibRedirect listing", Author: "cmc"}}, |
| |
2350 | MRs: []DashboardItem{{Repo: "krz/gitbay", Number: 552, Title: "wire $PAGER", Author: "cmc"}}, |
| |
2351 | Builds: []DashboardBuild{ |
| |
2352 | {Repo: "krz/omaha-metro-blotter", Number: 83, Job: "daily-pull", Status: "failure", Ref: "main", CreatedAt: "2026-09-30T12:00:00Z"}, |
| |
2353 | {Repo: "krz/gitbay", Number: 1779, Job: "test", Status: "success", Ref: "main", CreatedAt: "2026-10-01T10:00:00Z"}, |
| |
2354 | {Repo: "krz/gitbay", Number: 1778, Job: "build", Status: "success", Ref: "main", CreatedAt: "2026-10-01T10:00:00Z"}, |
| |
2355 | }, |
| |
2356 | Server: &ServerOut{Commit: "135f300"}, |
| |
2357 | } |
| |
2358 | } |
| |
2359 | |
| |
2360 | func TestDashboardScreen(t *testing.T) { |
| |
2361 | c := screenCtx(110, false) |
| |
2362 | c.User = store.User{Username: "cmc", IsAdmin: true} |
| |
2363 | s := dashboardScreen(c, dashFixture(), nil) |
| |
2364 | var titles []string |
| |
2365 | for _, sec := range s.sections { |
| |
2366 | if len(sec.rows) > 0 { |
| |
2367 | titles = append(titles, sec.title) |
| |
2368 | } |
| |
2369 | } |
| |
2370 | want := []string{"Assigned issues", "Open merge requests", "Failed builds"} |
| |
2371 | if !slices.Equal(titles, want) { |
| |
2372 | t.Errorf("sections = %q, want %q", titles, want) |
| |
2373 | } |
| |
2374 | for _, f := range s.fields { |
| |
2375 | if f.label == "Server" || f.label == "Queues" { |
| |
2376 | t.Errorf("admin block %q on the dashboard screen", f.label) |
| |
2377 | } |
| |
2378 | } |
| |
2379 | var first []string |
| |
2380 | for _, a := range s.actions { |
| |
2381 | first = append(first, strings.Join(a.argv, " ")) |
| |
2382 | } |
| |
2383 | if !slices.Contains(first, "issue show krz/skunky-art 33") || !slices.Contains(first, "build log krz/omaha-metro-blotter 83") { |
| |
2384 | t.Errorf("actions = %q", first) |
| |
2385 | } |
| |
2386 | if !slices.Contains(first, "admin stats") { |
| |
2387 | t.Errorf("admin missing admin stats: %q", first) |
| |
2388 | } |
| |
2389 | checkActions(t, s) |
| |
2390 | } |
| |
2391 | |
| |
2392 | func TestDashboardScreenPassingBuildsCollapse(t *testing.T) { |
| |
2393 | c := screenCtx(110, false) |
| |
2394 | d := dashFixture() |
| |
2395 | d.Builds = d.Builds[1:] |
| |
2396 | s := dashboardScreen(c, d, nil) |
| |
2397 | for _, sec := range s.sections { |
| |
2398 | if sec.title == "Failed builds" && len(sec.rows) > 0 { |
| |
2399 | t.Error("failed builds section with no failures") |
| |
2400 | } |
| |
2401 | } |
| |
2402 | if !strings.Contains(renderString(c, s), "2 builds passed") { |
| |
2403 | t.Errorf("no passing-build summary:\n%s", renderString(c, s)) |
| |
2404 | } |
| |
2405 | } |
| |
2406 | ``` |
| |
2407 | |
| |
2408 | Expected on run: `undefined: dashboardScreen`. |
| |
2409 | |
| |
2410 | - [ ] **Step 3: Implement** |
| |
2411 | |
| |
2412 | ```go |
| |
2413 | // dashboardScreen is dashboard at a terminal: what waits on the viewer |
| |
2414 | // first, then their open merge requests, failed builds, a few lines of |
| |
2415 | // activity and the pinned repositories. The operator's queues are |
| |
2416 | // admin stats'; a mirror or queue failure shows as one header line. |
| |
2417 | func dashboardScreen(c *Ctx, d DashboardOut, lines []FeedLine) screen { |
| |
2418 | var s screen |
| |
2419 | s.fields = append(s.fields, field{"User", []cell{cText(c.User.Username)}}, field{"Instance", []cell{cText(c.Cfg.SiteHost())}}) |
| |
2420 | if q := d.Queues; q != nil { |
| |
2421 | if bad := q.Webhooks.Failed + q.Mail.Failed + q.Push.Failed + q.Mirrors.Errors + q.Deps.Errors; bad > 0 { |
| |
2422 | s.fields = append(s.fields, field{"Problems", []cell{cGlyph("failed"), cText(fmt.Sprintf("%d failing in the background", bad))}}) |
| |
2423 | } |
| |
2424 | } |
| |
2425 | if d.Unread > 0 { |
| |
2426 | s.fields = append(s.fields, field{"Inbox", []cell{cYou(), cText(fmt.Sprintf("%d unread", d.Unread))}}) |
| |
2427 | } |
| |
2428 | |
| |
2429 | item := func(it DashboardItem, marker, page string, you bool) row { |
| |
2430 | lead := cell{kind: kindGlyph} |
| |
2431 | if you { |
| |
2432 | lead = cYou() |
| |
2433 | } |
| |
2434 | ref := cLink(fmt.Sprintf("%s%s%d", it.Repo, marker, it.Number), c.siteURL(it.Repo, page, strconv.FormatInt(it.Number, 10))) |
| |
2435 | return rowOf(ref, lead, cFlex(it.Title), cMeta(it.Author)) |
| |
2436 | } |
| |
2437 | reviews := section{title: "Review requested", n: len(d.Reviews)} |
| |
2438 | for _, it := range d.Reviews { |
| |
2439 | reviews.rows = append(reviews.rows, item(it, "!", "mrs", true)) |
| |
2440 | } |
| |
2441 | assigned := section{title: "Assigned issues", n: len(d.Assigned)} |
| |
2442 | for _, it := range d.Assigned { |
| |
2443 | assigned.rows = append(assigned.rows, item(it, "#", "issues", true)) |
| |
2444 | } |
| |
2445 | mrs := section{title: "Open merge requests", n: len(d.MRs)} |
| |
2446 | for _, it := range d.MRs { |
| |
2447 | mrs.rows = append(mrs.rows, item(it, "!", "mrs", false)) |
| |
2448 | } |
| |
2449 | |
| |
2450 | failed := section{title: "Failed builds"} |
| |
2451 | passed := 0 |
| |
2452 | seen := map[string]bool{} |
| |
2453 | var firstFailed *DashboardBuild |
| |
2454 | for i, b := range d.Builds { |
| |
2455 | key := b.Repo + "\x00" + b.Job + "\x00" + b.Ref |
| |
2456 | if seen[key] { |
| |
2457 | continue |
| |
2458 | } |
| |
2459 | seen[key] = true |
| |
2460 | switch b.Status { |
| |
2461 | case "success": |
| |
2462 | passed++ |
| |
2463 | case "failure", "failed", "error": |
| |
2464 | failed.n++ |
| |
2465 | failed.rows = append(failed.rows, rowOf(cLink(strconv.FormatInt(b.Number, 10), c.siteURL(b.Repo, "builds", strconv.FormatInt(b.Number, 10))), |
| |
2466 | cGlyph(b.Status), cFlex(b.Job+" "+b.Ref), cMeta(b.Repo, relAge(b.CreatedAt, termNow())))) |
| |
2467 | if firstFailed == nil { |
| |
2468 | firstFailed = &d.Builds[i] |
| |
2469 | } |
| |
2470 | } |
| |
2471 | } |
| |
2472 | |
| |
2473 | activity := section{title: "Recent activity", n: len(lines), more: []string{"feed"}} |
| |
2474 | for _, l := range lines[:min(len(lines), 5)] { |
| |
2475 | activity.rows = append(activity.rows, rowOf(l.termCells(c)...)) |
| |
2476 | } |
| |
2477 | if passed > 0 { |
| |
2478 | activity.rows = append(activity.rows, rowOf(cMeta(fmt.Sprintf("%d builds passed", passed)))) |
| |
2479 | } |
| |
2480 | |
| |
2481 | pinned := section{title: "Pinned", n: len(d.Pinned)} |
| |
2482 | if len(d.Pinned) > 0 { |
| |
2483 | var paths []string |
| |
2484 | for _, p := range d.Pinned { |
| |
2485 | paths = append(paths, p.Path) |
| |
2486 | } |
| |
2487 | pinned.rows = []row{rowOf(cMeta(strings.Join(paths, " ")))} |
| |
2488 | } |
| |
2489 | s.sections = []section{reviews, assigned, mrs, failed, activity, pinned} |
| |
2490 | |
| |
2491 | if len(d.Reviews) > 0 { |
| |
2492 | it := d.Reviews[0] |
| |
2493 | s.actions = append(s.actions, action{"Next", []string{"mr", "show", it.Repo, strconv.FormatInt(it.Number, 10)}}) |
| |
2494 | } |
| |
2495 | if len(d.Assigned) > 0 { |
| |
2496 | it := d.Assigned[0] |
| |
2497 | s.actions = append(s.actions, action{"Next", []string{"issue", "show", it.Repo, strconv.FormatInt(it.Number, 10)}}) |
| |
2498 | } |
| |
2499 | if firstFailed != nil { |
| |
2500 | s.actions = append(s.actions, action{"Next", []string{"build", "log", firstFailed.Repo, strconv.FormatInt(firstFailed.Number, 10)}}) |
| |
2501 | } |
| |
2502 | s.actions = append(s.actions, action{"More", []string{"feed"}}) |
| |
2503 | if c.User.IsAdmin { |
| |
2504 | s.actions = append(s.actions, action{"Instance", []string{"admin", "stats"}}) |
| |
2505 | } |
| |
2506 | return s |
| |
2507 | } |
| |
2508 | ``` |
| |
2509 | |
| |
2510 | Notes: |
| |
2511 | - `l.termCells(c)` is the feed's existing terminal row; check its column order and, if it leads with an age, keep it — activity rows are the one place time leads. |
| |
2512 | - With no feed lines but passing builds, the section has `n: 0` and one row, so it renders as `Recent activity (0)` above the summary. If that reads wrong, title the summary row's section `Builds` instead. Decide on the rendered output. |
| |
2513 | - Saved query sections (`d.Queries`) become one section each, titled with the query name and `n: q.Count`, rows as `item` without a lead, `more: []string{"query", "show", q.Name}` (check the real subcommand with `ssh git@gitbay.org help query`). Insert them after `mrs`. |
| |
2514 | - `Cfg.SiteHost()` exists (`repo.go` uses it). |
| |
2515 | |
| |
2516 | In `runDashboard`, compute `lines := FeedLines(events)` before emitting (it is computed inside the closure today), and switch to `c.emitView(d, plain, func() screen { return dashboardScreen(c, d, lines) })`. In the plain closure, delete every `c.Term.Cols > 0` branch (the `needsYou` line, the terminal activity rows, the empty-section skip) so the closure is the plain path only; the pin from step 1 proves it unchanged. Delete `needsYou` if nothing else calls it, and its test. |
| |
2517 | |
| |
2518 | - [ ] **Step 4: Run tests** |
| |
2519 | |
| |
2520 | Run: `go test ./internal/control/ -run 'TestDashboard'` |
| |
2521 | Expected: PASS, pin unchanged. |
| |
2522 | |
| |
2523 | - [ ] **Step 5: Verify, commit, MR** |
| |
2524 | |
| |
2525 | ```bash |
| |
2526 | go build ./... && go vet ./... && go test ./internal/control/ ./internal/gitutil/ ./cmd/gitbay/ |
| |
2527 | git add internal/control/dashboard.go internal/control/dashscreen_test.go internal/control/testdata/plain/dashboard.txt CHANGELOG.org |
| |
2528 | git commit -m "control: dashboard as a screen |
| |
2529 | |
| |
2530 | Ref #318" |
| |
2531 | git push -u origin cli-views-2b |
| |
2532 | gitbay mr create --source cli-views-2b --target main --title "control: list and dashboard screens (stage 2b of CLI views)" |
| |
2533 | ``` |
| |
2534 | |
| |
2535 | --- |
| |
2536 | |
| |
2537 | ## Stage 3 |
| |
2538 | |
| |
2539 | Written as its own plan after stage 2b merges, from the `screen` API as it |
| |
2540 | stands then. Its scope: every remaining `c.table` and `c.view` caller |
| |
2541 | (start with `profile`, `release`, `milestone`, `org`, `label`), the header |
| |
2542 | lines of `mr diff`, `repo diff`, `repo commit` and `build log`, and removing |
| |
2543 | `view`'s terminal branches. |
| |
2544 | |
| |
2545 | ## Self-review notes |
| |
2546 | |
| |
2547 | - Spec coverage: visual rules → tasks 1–4; model and `emitView` → 3, 5; |
| |
2548 | `here=` → 4, 6; paging → 5; resolve test → 7; plain pinning → 7 and the |
| |
2549 | first step of 10–15; screens → 10–15; wiki → 8; stage 3 deferred to its |
| |
2550 | own plan, as the spec's stages allow. |
| |
2551 | - Two places in tasks 10 and 15 are marked "decide on the rendered output" |
| |
2552 | (the Files count, the activity count). They are presentation choices with |
| |
2553 | both options spelled out, not missing work. |