Commit a14e94fa35
Verified · cmc
Layout: unified · split
docs/plans/2026-10-01-cli-views.md added +2553
| @@ -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. | |
docs/specs/2026-10-01-cli-views-design.md +26 −15
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | # CLI views |
| 2 | 2 | |
| 3 | Status: proposed, 2026-10-01. Follows `2026-10-01-cli-terminal-output-design.md` | |
| 3 | Status: accepted, 2026-10-01. Follows `2026-10-01-cli-terminal-output-design.md` | |
| 4 | 4 | (v1.41.0, #312–#315). |
| 5 | 5 | |
| 6 | 6 | ## Problem |
| @@ -90,8 +90,12 @@ Nothing else is coloured. Refs and paths lose their cyan. | ||
| 90 | 90 | |
| 91 | 91 | **Legend.** A rule line, then up to three columns of action groups. Each |
| 92 | 92 | group is a bold name over its commands, blue. Below 80 columns the groups |
| 93 | stack in one column. Commands omit `<owner/name>` when the screen is for | |
| 94 | the repository the CLI would infer, so each line is what the user types. | |
| 93 | stack in one column. Commands omit `<owner/name>` when it equals the repository the CLI | |
| 94 | inferred from its clone, which the CLI sends as `here=<owner/name>` in | |
| 95 | `--term` (an older server ignores the option). Commands may name | |
| 96 | CLI-local commands (`mr rebase`, `mr checkout`). There is no `browse`: | |
| 97 | the header's first field links to the page where the terminal shows | |
| 98 | links, and a `URL:` field carries it otherwise. | |
| 95 | 99 | |
| 96 | 100 | ## Model |
| 97 | 101 | |
| @@ -117,14 +121,19 @@ type field struct { | ||
| 117 | 121 | type section struct { |
| 118 | 122 | title string |
| 119 | 123 | n int // total, shown as "(n)" |
| 120 | rows [][]cell | |
| 121 | more string // command for the rest, when n > len(rows) | |
| 124 | rows []row // cells, plus a markup body drawn beneath (a comment) | |
| 125 | more []string // command for the rest, when n > len(rows) | |
| 122 | 126 | empty bool // draw "(0)" rather than omit |
| 123 | 127 | } |
| 124 | 128 | |
| 129 | type row struct { | |
| 130 | cells []cell | |
| 131 | body string | |
| 132 | format string | |
| 133 | } | |
| 134 | ||
| 125 | 135 | type action struct { |
| 126 | 136 | group string |
| 127 | label string | |
| 128 | 137 | argv []string |
| 129 | 138 | } |
| 130 | 139 | ``` |
| @@ -145,7 +154,9 @@ command, replacing the `more:` line. | ||
| 145 | 154 | |
| 146 | 155 | The width logic in `table.flush` (`fit`, `capSparse`, `dropEmpty`) moves |
| 147 | 156 | into the section renderer. At a terminal, `table` renders as one untitled |
| 148 | section, so lists that have not migrated take the new row style in stage 1. | |
| 157 | section, so lists that have not migrated take the new row style in stage 1. Such a | |
| 158 | table keeps a dim header row only when a column is a number or size | |
| 159 | (`admin runners`, `admin stats`), which is unreadable without one. | |
| 149 | 160 | Piped `table` output is unchanged. `view`'s terminal branches are removed |
| 150 | 161 | once no command reaches them. |
| 151 | 162 | |
| @@ -176,19 +187,19 @@ command; a field without data is omitted. | ||
| 176 | 187 | - Header: `Merge`, `State`, `Checks`, `Review`, `Gates` (each |
| 177 | 188 | `MergeGates` gate as a glyph). |
| 178 | 189 | - Body: description. |
| 179 | - Sections: `Commits` (SHA, signature glyph, subject), `Files` with | |
| 180 | `+a −d`, `Discussion` (threads as who · age and the first line; | |
| 181 | suggestions marked; shown when empty). | |
| 182 | - Legend: Unblock (`mr rebase`; `mr checkout` on a conflict), Review (approve, comment, | |
| 183 | `apply-suggestion` when there are suggestions), Merge (when the gates | |
| 184 | pass), Read (`mr diff`, `browse`). | |
| 190 | - Sections: `Commits` (SHA, subject), `Files` with `+a −d`, | |
| 191 | `Discussion` (each comment as who · age with its body beneath; shown | |
| 192 | when empty). | |
| 193 | - Legend: Unblock (`mr rebase` when behind), Review (`mr review | |
| 194 | --approve`, `mr comment`), Merge (when the gates pass), Read | |
| 195 | (`mr diff`). | |
| 185 | 196 | |
| 186 | 197 | **`issue show`** |
| 187 | 198 | |
| 188 | 199 | - Header: `Issue`, `State`, `Labels` (swatches), `Assignee`, |
| 189 | 200 | `Milestone`, `Linked` (merge requests that close it). |
| 190 | 201 | - Body: description. |
| 191 | - Sections: `Discussion`. | |
| 202 | - Sections: `Discussion` as on `mr show`. | |
| 192 | 203 | - Legend: comment, assign, label, close or reopen, filtered by permission. |
| 193 | 204 | |
| 194 | 205 | **`repo show`** |
| @@ -197,7 +208,7 @@ command; a field without data is omitted. | ||
| 197 | 208 | (only on error). |
| 198 | 209 | - Body: description and topics. |
| 199 | 210 | - Sections: `Open merge requests`, `Open issues`, `Recent commits` (5). |
| 200 | - Legend: `mr create`, `issue create`, `repo log`, `browse`. | |
| 211 | - Legend: `mr create`, `issue create`, `repo log`. | |
| 201 | 212 | |
| 202 | 213 | **`build show`** |
| 203 | 214 | |