CLI output refresh: spec, plan, audit fixes !467

merged merged by cmc on 2026-09-24 15:08 UTC · krz/gitbay:cli-output-refresh into main

10 files changed, +3850 −9

Layout: unified · split

cmd/gitbay/main.go +1 −1
@@ -561,7 +561,7 @@ func releaseCmd() *cobra.Command {
561561 passOpts{server: []string{"release", "create"}, needsRepo: true, stdinOK: true, editor: "release"}),
562562 pass("edit", "update title and notes: <tag> [--title <t>] [--notes|--file -]",
563563 passOpts{server: []string{"release", "edit"}, needsRepo: true, stdinOK: true}),
564 pass("list", "list releases", passOpts{server: []string{"release", "list"}, needsRepo: true}),
564 pass("list", "releases: <owner/name> [--limit <n>] [--cursor <c>]", passOpts{server: []string{"release", "list"}, needsRepo: true}),
565565 pass("show", "show a release with assets: <tag>", passOpts{server: []string{"release", "show"}, needsRepo: true}),
566566 pass("delete", "delete a release and its assets: <tag> --yes", passOpts{server: []string{"release", "delete"}, needsRepo: true}),
567567 group("asset", "binary assets on a release",
docs/plans/2026-09-23-cli-output-refresh.md added +3371
@@ -0,0 +1,3371 @@
1# CLI output refresh implementation plan
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 rendering for tables, `show` views and help, selected
6by the client, with piped output unchanged in shape, plus the audit
7fixes from 2026-09-23.
8
9**Architecture:** The server renders. The CLI sends
10`GITBAY_TERM=<cols>[,color]` on the SSH session; sshd parses it into
11`Ctx.Term`; a `table` helper, a `view` helper and an
12`internal/termtext` renderer pick terminal or plain output from it.
13The CLI adds a pager and a grouped root help. Help text (flag
14descriptions, examples) moves into the command registry.
15
16**Tech stack:** Go, `golang.org/x/crypto/ssh`, goldmark, go-org,
17chroma v2, `golang.org/x/text/width`, `golang.org/x/term`, cobra.
18
19**Spec:** `docs/specs/2026-09-23-cli-output-refresh-design.md`
20
21## Global constraints
22
23- Five MRs, in order, each on its own branch off `main`:
24 `cli-output-refresh` (Part 1, already holds the spec and this
25 plan), `cli-output-tables` (Part 2), `cli-output-views` (Part 3),
26 `cli-output-help` (Part 4), `cli-output-docs` (Part 5).
27- Commits are signed (the repository refuses unsigned ones), end with
28 `Ref #254`; the last commit of Part 5 says `Closes #254`. No
29 attribution to any assistant or model anywhere.
30- MR: `gitbay mr create --source <branch> --target main --title "..."`;
31 merge with `gitbay mr merge <n> --strategy ff` after CI is green,
32 then delete the branch locally and on the remote. If the merge says
33 the branch is behind, rebase onto `main`, force-push, merge again.
34- Locally run `go build ./...`, `go vet ./...` (catches test callers
35 after a signature change), and the unit tests of touched packages.
36 Run at most the one e2e test being written:
37 `go test ./e2e -run TestName -count=1`. CI on bay1 runs the rest.
38- `--json` output never changes shape except where a task says so
39 (Part 1 `release list` paging, Part 4 `help --json` gaining two
40 fields).
41- Piped (plain) output keeps its row shape: tab-separated, no header.
42 Timestamps in plain output become RFC3339 to the second in UTC
43 (`2026-09-23T23:26:00Z`).
44- No ANSI byte (`\x1b`) ever reaches plain output.
45- Terminal timestamps: `2006-01-02 15:04 UTC` in views, relative ages
46 in tables (`just now`, `5m ago`, `2h ago`, `3d ago` under 14 days,
47 `3w ago` under 8 weeks, then `2006-01-02`).
48- Colours are ANSI 16-colour: green (`--ok`), magenta (`--done`), red
49 (`--bad`), dim (`--neutral`).
50- Comments and docs: plain, terse, no before/after commentary.
51
52## File map
53
54| File | Part | Responsibility |
55|---|---|---|
56| `internal/store/releases.go` | 1 | `ListReleasesPage` |
57| `internal/control/release.go` | 1 | `release list` paging, empty title |
58| `internal/control/notifications.go` | 1 | device add message |
59| `internal/control/dashboard.go` | 1, 2 | `none` under empty sections; tables |
60| `internal/control/term.go` | 2 | `Term`, `ParseTerm`, ANSI, cell width, clip, timestamps |
61| `internal/control/table.go` | 2 | `table`, typed cells |
62| `internal/control/control.go` | 2, 4 | `Ctx.Term`, `Ctx.Argv`, `Command.Flags/Examples`, help |
63| `internal/control/cursor.go` | 2 | `more:` hint in terminal mode |
64| `internal/sshd/sshd.go` | 2 | `env` request, `Exec` takes a `Term` |
65| `cmd/gitbayd/system.go` | 2 | forced command reads `GITBAY_TERM` |
66| `cmd/gitbay/ssh.go` | 2, 3 | `SetEnv`, `--no-color`, pager; `alignColumns` removed |
67| `internal/control/*.go` list sites | 2 | every list command on `table` |
68| `internal/termtext/` | 3 | markdown and org to terminal text |
69| `internal/control/view.go` | 3 | layout of every `show` |
70| `internal/control/help.go` | 4 | help rendering, noun summaries |
71| `cmd/gitbay/main.go` | 4 | summaries from the generated table, grouped root help |
72| `cmd/gitbay/summaries_gen.go` | 4 | generated from the registry |
73| `e2e/readonly_test.go`, `e2e/ssh_test.go`, `e2e/term_test.go` | 2, 3 | terminal and plain checks over real ssh |
74| `.gitbay/wiki/Users.org`, `Admin.org`, `CHANGELOG.org` | 5 | rules, operator note, release note |
75
76---
77
78# Part 1: audit fixes (branch `cli-output-refresh`)
79
80### Task 1.1: `release list` pages and drops a title equal to its tag
81
82**Files:**
83- Modify: `internal/store/releases.go` (`ListReleases`, around line 92)
84- Modify: `internal/control/release.go:29-31` (registration), `:199-220` (`runReleaseList`)
85- Modify: `cmd/gitbay/main.go` (the `release list` `pass()` short text)
86- Test: `internal/control/release_test.go` (create)
87
88**Interfaces:**
89- Produces: `func (s *Store) ListReleasesPage(repoID int64, limit int, afterID int64) ([]Release, error)` — newest first by `(created_at, id)`; `limit` 0 means all; `afterID` 0 means from the start.
90
91- [ ] **Step 1: Write the failing test**
92
93```go
94package control
95
96import (
97 "bytes"
98 "strings"
99 "testing"
100
101 "gitbay.org/gitbay/internal/protocol"
102 "gitbay.org/gitbay/internal/store"
103)
104
105func TestReleaseListPagesAndHidesTagTitle(t *testing.T) {
106 st, repo, uid := newQueueTestRepo(t)
107 for _, tag := range []string{"v1", "v2", "v3"} {
108 title := tag
109 if tag == "v2" {
110 title = "Second"
111 }
112 if _, err := st.CreateRelease(repo.ID, tag, title, "", uid, "md"); err != nil {
113 t.Fatal(err)
114 }
115 }
116 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
117 if code := Dispatch(c, []string{"release", "list", repo.Path(), "--limit", "2"}); code != protocol.ExitOK {
118 t.Fatalf("exit %d: %s", code, errOut)
119 }
120 lines := strings.Split(strings.TrimSpace(c.Stdout.(*bytes.Buffer).String()), "\n")
121 if len(lines) != 3 || lines[0] != "v3\t\t0 asset(s)" || lines[1] != "v2\tSecond\t0 asset(s)" || !strings.HasPrefix(lines[2], "next\t") {
122 t.Fatalf("page 1:\n%s", strings.Join(lines, "\n"))
123 }
124 cursor := strings.TrimPrefix(lines[2], "next\t")
125
126 c, errOut = pruneCtx(st, t.TempDir(), store.User{ID: uid})
127 if code := Dispatch(c, []string{"release", "list", repo.Path(), "--cursor", cursor}); code != protocol.ExitOK {
128 t.Fatalf("exit %d: %s", code, errOut)
129 }
130 if got := c.Stdout.(*bytes.Buffer).String(); got != "v1\t\t0 asset(s)\n" {
131 t.Fatalf("page 2: %q", got)
132 }
133}
134```
135
136The three releases can share a `created_at` second; the `(created_at, id)` order is what keeps the pages stable.
137
138- [ ] **Step 2: Run it and see it fail**
139
140Run: `go test ./internal/control -run TestReleaseListPagesAndHidesTagTitle -count=1`
141Expected: FAIL, exit 2 (`--limit` is not accepted).
142
143- [ ] **Step 3: Store**
144
145Replace `ListReleases` in `internal/store/releases.go` with:
146
147```go
148func (s *Store) ListReleases(repoID int64) ([]Release, error) {
149 return s.ListReleasesPage(repoID, 0, 0)
150}
151
152// ListReleasesPage lists newest first. limit 0 is every row; afterID is
153// the last release of the previous page, 0 for the first.
154func (s *Store) ListReleasesPage(repoID int64, limit int, afterID int64) ([]Release, error) {
155 q := releaseSelect + " WHERE r.repo_id = ?"
156 args := []any{repoID}
157 if afterID > 0 {
158 q += " AND (r.created_at, r.id) < (SELECT created_at, id FROM releases WHERE id = ?)"
159 args = append(args, afterID)
160 }
161 q += " ORDER BY r.created_at DESC, r.id DESC"
162 if limit > 0 {
163 q += " LIMIT ?"
164 args = append(args, limit)
165 }
166 rows, err := s.DB.Query(q, args...)
167 if err != nil {
168 return nil, err
169 }
170 defer rows.Close()
171 var out []Release
172 for rows.Next() {
173 var r Release
174 if err := rows.Scan(&r.ID, &r.RepoID, &r.Tag, &r.Title, &r.Notes, &r.NotesFormat, &r.Author, &r.CreatedAt); err != nil {
175 return nil, err
176 }
177 out = append(out, r)
178 }
179 if err := rows.Err(); err != nil {
180 return nil, err
181 }
182 for i := range out {
183 if err := s.releaseAssets(&out[i]); err != nil {
184 return nil, err
185 }
186 }
187 return out, nil
188}
189```
190
191- [ ] **Step 4: Command**
192
193Registration usage becomes `release list <owner/name> [--limit <n>] [--cursor <c>]`. `runReleaseList`:
194
195```go
196func runReleaseList(c *Ctx, args []string) int {
197 rest, p, code := parsePageFlags(c, args, "release", true)
198 if code >= 0 {
199 return code
200 }
201 if len(rest) != 1 {
202 return c.usage()
203 }
204 repo, code := resolveRepo(c, rest[0], policy.CanRead)
205 if code >= 0 {
206 return code
207 }
208 rels, err := c.Store.ListReleasesPage(repo.ID, p.queryLimit(), p.keyInt())
209 if err != nil {
210 return c.fail(protocol.ExitFailure, "%v", err)
211 }
212 rels, next := trimPage(p, rels, "release", func(r store.Release) string { return strconv.FormatInt(r.ID, 10) })
213 var ds []releaseOut
214 for _, r := range rels {
215 ds = append(ds, releaseToOut(r, false))
216 }
217 return c.emitPage(p, ds, next, func(w io.Writer) {
218 for _, d := range ds {
219 title := d.Title
220 if title == d.Tag {
221 title = ""
222 }
223 fmt.Fprintf(w, "%s\t%s\t%d asset(s)\n", d.Tag, title, len(d.Assets))
224 }
225 })
226}
227```
228
229Add `strconv` to the imports if missing. In `cmd/gitbay/main.go` the `release list` short text becomes `"releases: <owner/name> [--limit <n>] [--cursor <c>]"` (Part 4 removes these strings).
230
231- [ ] **Step 5: Run the test and the package**
232
233Run: `go test ./internal/control ./internal/store -count=1`
234Expected: PASS.
235
236- [ ] **Step 6: Commit**
237
238```bash
239git add internal/store/releases.go internal/control/release.go internal/control/release_test.go cmd/gitbay/main.go
240git commit -m "release list: --limit and --cursor; no title column when it repeats the tag" -m "Ref #254"
241```
242
243### Task 1.2: `notifications device add` says `registered device <n>`
244
245**Files:**
246- Modify: `internal/control/notifications.go:282`
247- Test: `internal/control/notifications_test.go`
248
249- [ ] **Step 1: Find the existing device add test**
250
251Run: `grep -n 'registered' internal/control/notifications_test.go e2e/*.go`
252Every assertion on `device %d registered` changes with the message. If none asserts on the plain text, add to the existing device add test in `notifications_test.go`, after its successful `Dispatch` (the test runs with `c.JSON` false or add a second plain run):
253
254```go
255if got := c.Stdout.(*bytes.Buffer).String(); !strings.HasPrefix(got, "registered device ") {
256 t.Errorf("device add printed %q", got)
257}
258```
259
260- [ ] **Step 2: Run it and see it fail**
261
262Run: `go test ./internal/control -run Device -count=1`
263Expected: FAIL on the message.
264
265- [ ] **Step 3: Change the message**
266
267```go
268fmt.Fprintf(w, "registered device %d\n", id)
269```
270
271Update any e2e assertion found in Step 1 the same way.
272
273- [ ] **Step 4: Run**
274
275Run: `go test ./internal/control -count=1`
276Expected: PASS.
277
278- [ ] **Step 5: Commit**
279
280```bash
281git add internal/control/notifications.go internal/control/notifications_test.go
282git commit -m "notifications device add: verb first" -m "Ref #254"
283```
284
285### Task 1.3: `dashboard` prints `none` under an empty section
286
287**Files:**
288- Modify: `internal/control/dashboard.go:165-210` and `printDashboardItems`
289- Test: `internal/control/dashboard_test.go`
290
291- [ ] **Step 1: Write the failing test**
292
293Add to `dashboard_test.go`, using the store/ctx setup the file's existing tests use (read the top of the file for its helper):
294
295```go
296func TestDashboardEmptySectionsSayNone(t *testing.T) {
297 st, _, uid := newQueueTestRepo(t)
298 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
299 if code := Dispatch(c, []string{"dashboard"}); code != protocol.ExitOK {
300 t.Fatalf("exit %d: %s", code, errOut)
301 }
302 out := c.Stdout.(*bytes.Buffer).String()
303 for _, h := range []string{"waiting on your review:", "assigned to you:", "open merge requests:", "open issues:"} {
304 if !strings.Contains(out, h+"\n none\n") {
305 t.Errorf("%q not followed by none:\n%s", h, out)
306 }
307 }
308}
309```
310
311- [ ] **Step 2: Run it and see it fail**
312
313Run: `go test ./internal/control -run TestDashboardEmptySectionsSayNone -count=1`
314Expected: FAIL.
315
316- [ ] **Step 3: Implement**
317
318In `printDashboardItems`, before its loop:
319
320```go
321if len(items) == 0 {
322 fmt.Fprintln(w, " none")
323 return
324}
325```
326
327(Use the function's own parameter name.) Apply the same guard to the `pinned`, `recent activity` and `builds` loops in the plain formatter:
328
329```go
330fmt.Fprintln(w, "pinned:")
331if len(d.Pinned) == 0 {
332 fmt.Fprintln(w, " none")
333}
334for _, p := range d.Pinned {
335```
336
337and likewise for `d.Activity` and `d.Builds`.
338
339- [ ] **Step 4: Run**
340
341Run: `go test ./internal/control -count=1`
342Expected: PASS. Fix any existing dashboard assertion that expected a header followed directly by the next header.
343
344- [ ] **Step 5: Commit and open MR 1**
345
346```bash
347git add internal/control/dashboard.go internal/control/dashboard_test.go
348git commit -m "dashboard: none under an empty section" -m "Ref #254"
349git push -u origin cli-output-refresh
350gitbay mr create --source cli-output-refresh --target main --title "CLI output refresh: spec, plan, audit fixes"
351```
352
353Wait for CI, merge (`--strategy ff`), delete the branch both places.
354
355---
356
357# Part 2: transport and tables (branch `cli-output-tables`)
358
359Start: `git switch main && git pull --ff-only && git switch -c cli-output-tables`.
360
361### Task 2.1: `Term`, cell width, clipping, timestamps
362
363**Files:**
364- Create: `internal/control/term.go`
365- Test: `internal/control/term_test.go`
366- Modify: `go.mod` (`golang.org/x/text` moves from indirect to direct: `go mod tidy`)
367
368**Interfaces:**
369- Produces:
370 - `type Term struct { Cols int; Color bool }` — zero value is plain.
371 - `func ParseTerm(v string) Term`
372 - `func (t Term) paint(sgr, s string) string`
373 - `func stateColor(s string) string` — an SGR prefix or `""`.
374 - `func cells(s string) int` — display width, SGR sequences skipped.
375 - `func runeCells(r rune) int`
376 - `func clip(s string, w int) string`
377 - `func pad(s string, w int) string`
378 - `func parseStamp(s string) (time.Time, bool)`
379 - `func stamp(s string) string` — plain timestamp.
380 - `func relAge(s string, now time.Time) string`
381 - `var termNow = time.Now`
382 - constants `sgrReset sgrBold sgrDim sgrUnderline sgrRed sgrGreen sgrMagenta`
383
384- [ ] **Step 1: Write the failing tests**
385
386```go
387package control
388
389import (
390 "testing"
391 "time"
392)
393
394func TestParseTerm(t *testing.T) {
395 cases := map[string]Term{
396 "120": {Cols: 120},
397 "120,color": {Cols: 120, Color: true},
398 "40": {Cols: 40},
399 "39": {},
400 "": {},
401 "abc": {},
402 "80,blink": {},
403 "80,": {},
404 "5000": {},
405 }
406 for in, want := range cases {
407 if got := ParseTerm(in); got != want {
408 t.Errorf("ParseTerm(%q) = %+v, want %+v", in, got, want)
409 }
410 }
411}
412
413func TestCells(t *testing.T) {
414 cases := map[string]int{
415 "abc": 3,
416 "日本": 4,
417 "é": 1,
418 "é": 1,
419 "\x1b[32mopen\x1b[0m": 4,
420 "": 0,
421 }
422 for in, want := range cases {
423 if got := cells(in); got != want {
424 t.Errorf("cells(%q) = %d, want %d", in, got, want)
425 }
426 }
427}
428
429func TestClip(t *testing.T) {
430 if got := clip("Dependency updates available", 14); got != "Dependency up…" {
431 t.Errorf("clip = %q", got)
432 }
433 if got := clip("short", 14); got != "short" {
434 t.Errorf("clip = %q", got)
435 }
436 if got := clip("日本語のタイトル", 7); got != "日本語…" {
437 t.Errorf("clip wide = %q", got)
438 }
439}
440
441func TestStampAndRelAge(t *testing.T) {
442 if got := stamp("2026-09-23T23:26:00.570Z"); got != "2026-09-23T23:26:00Z" {
443 t.Errorf("stamp = %q", got)
444 }
445 if got := stamp("2026-09-23 23:26:00"); got != "2026-09-23T23:26:00Z" {
446 t.Errorf("stamp sqlite = %q", got)
447 }
448 if got := stamp("garbage"); got != "garbage" {
449 t.Errorf("stamp garbage = %q", got)
450 }
451 now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
452 cases := map[string]string{
453 "2026-09-23T11:59:30Z": "just now",
454 "2026-09-23T11:55:00Z": "5m ago",
455 "2026-09-23T10:00:00Z": "2h ago",
456 "2026-09-20T12:00:00Z": "3d ago",
457 "2026-09-02T12:00:00Z": "3w ago",
458 "2026-06-01T12:00:00Z": "2026-06-01",
459 "2026-09-24T12:00:00Z": "just now",
460 "not a time": "not a time",
461 }
462 for in, want := range cases {
463 if got := relAge(in, now); got != want {
464 t.Errorf("relAge(%q) = %q, want %q", in, got, want)
465 }
466 }
467}
468```
469
470- [ ] **Step 2: Run and see them fail**
471
472Run: `go test ./internal/control -run 'TestParseTerm|TestCells|TestClip|TestStampAndRelAge' -count=1`
473Expected: FAIL to compile (`undefined: ParseTerm`).
474
475- [ ] **Step 3: Implement `internal/control/term.go`**
476
477```go
478package control
479
480import (
481 "fmt"
482 "strconv"
483 "strings"
484 "time"
485 "unicode"
486 "unicode/utf8"
487
488 "golang.org/x/text/width"
489)
490
491// Term is what the client said about its terminal (GITBAY_TERM). The
492// zero value is plain output: tab-separated rows, no header, no colour,
493// which is what stock ssh, the API and the web get.
494type Term struct {
495 Cols int
496 Color bool
497}
498
499// ParseTerm reads "<cols>[,color]". Anything else, or a width outside
500// 40 to 1000, is plain output.
501func ParseTerm(v string) Term {
502 cols, opt, hasOpt := strings.Cut(v, ",")
503 n, err := strconv.Atoi(cols)
504 if err != nil || n < 40 || n > 1000 {
505 return Term{}
506 }
507 switch {
508 case !hasOpt:
509 return Term{Cols: n}
510 case opt == "color":
511 return Term{Cols: n, Color: true}
512 }
513 return Term{}
514}
515
516const (
517 sgrReset = "\x1b[0m"
518 sgrBold = "\x1b[1m"
519 sgrDim = "\x1b[2m"
520 sgrUnderline = "\x1b[4m"
521 sgrRed = "\x1b[31m"
522 sgrGreen = "\x1b[32m"
523 sgrMagenta = "\x1b[35m"
524)
525
526// paint wraps s in an SGR sequence when colour is on.
527func (t Term) paint(sgr, s string) string {
528 if !t.Color || sgr == "" || s == "" {
529 return s
530 }
531 return sgr + s + sgrReset
532}
533
534// stateColor maps a state word to the web's state tokens: --ok green,
535// --done magenta, --bad red, --neutral dim.
536func stateColor(s string) string {
537 switch s {
538 case "open", "success", "approved", "active":
539 return sgrGreen
540 case "merged":
541 return sgrMagenta
542 case "failed", "failure", "error", "changes requested":
543 return sgrRed
544 case "closed", "draft", "pending", "canceled", "cancelled", "archived", "disabled":
545 return sgrDim
546 }
547 return ""
548}
549
550// cells is the width of s in terminal cells: SGR sequences and
551// combining marks take none, East Asian wide and fullwidth runes two.
552func cells(s string) int {
553 n := 0
554 for i := 0; i < len(s); {
555 if s[i] == 0x1b {
556 j := strings.IndexByte(s[i:], 'm')
557 if j < 0 {
558 break
559 }
560 i += j + 1
561 continue
562 }
563 r, size := utf8.DecodeRuneInString(s[i:])
564 i += size
565 n += runeCells(r)
566 }
567 return n
568}
569
570func runeCells(r rune) int {
571 if unicode.In(r, unicode.Mn, unicode.Me) || r == '‍' {
572 return 0
573 }
574 switch width.LookupRune(r).Kind() {
575 case width.EastAsianWide, width.EastAsianFullwidth:
576 return 2
577 }
578 return 1
579}
580
581// clip cuts s to at most w cells, ending in "…" when anything was cut.
582// s must carry no SGR sequences: colour goes on after clipping.
583func clip(s string, w int) string {
584 if cells(s) <= w {
585 return s
586 }
587 var b strings.Builder
588 used := 0
589 for _, r := range s {
590 rc := runeCells(r)
591 if used+rc > w-1 {
592 break
593 }
594 b.WriteRune(r)
595 used += rc
596 }
597 return b.String() + "…"
598}
599
600// pad right-pads s with spaces to w cells.
601func pad(s string, w int) string {
602 return s + strings.Repeat(" ", max(0, w-cells(s)))
603}
604
605// termNow is the clock ages are measured against; tests pin it.
606var termNow = time.Now
607
608// parseStamp reads a stored timestamp: RFC3339 as the store writes it,
609// or SQLite's datetime() form.
610func parseStamp(s string) (time.Time, bool) {
611 for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05"} {
612 if t, err := time.Parse(layout, s); err == nil {
613 return t.UTC(), true
614 }
615 }
616 return time.Time{}, false
617}
618
619// stamp is a stored timestamp in plain output: RFC3339 to the second.
620func stamp(s string) string {
621 t, ok := parseStamp(s)
622 if !ok {
623 return s
624 }
625 return t.Format("2006-01-02T15:04:05Z")
626}
627
628// relAge is a stored timestamp as a table shows it at a terminal.
629func relAge(s string, now time.Time) string {
630 t, ok := parseStamp(s)
631 if !ok {
632 return s
633 }
634 d := max(now.Sub(t), 0)
635 switch {
636 case d < time.Minute:
637 return "just now"
638 case d < time.Hour:
639 return fmt.Sprintf("%dm ago", int(d/time.Minute))
640 case d < 24*time.Hour:
641 return fmt.Sprintf("%dh ago", int(d/time.Hour))
642 case d < 14*24*time.Hour:
643 return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
644 case d < 56*24*time.Hour:
645 return fmt.Sprintf("%dw ago", int(d/(7*24*time.Hour)))
646 }
647 return t.Format("2006-01-02")
648}
649```
650
651Then `go mod tidy`.
652
653- [ ] **Step 4: Run**
654
655Run: `go test ./internal/control -run 'TestParseTerm|TestCells|TestClip|TestStampAndRelAge' -count=1`
656Expected: PASS. If `日本語…` fails, check `clip`'s budget: `w-1` cells for runes plus one for `…`.
657
658- [ ] **Step 5: Commit**
659
660```bash
661git add internal/control/term.go internal/control/term_test.go go.mod go.sum
662git commit -m "control: Term, cell width, clipping and timestamp formats" -m "Ref #254"
663```
664
665### Task 2.2: `table`
666
667**Files:**
668- Create: `internal/control/table.go`
669- Modify: `internal/control/control.go` (`Ctx` gains `Term Term`)
670- Test: `internal/control/table_test.go`
671
672**Interfaces:**
673- Consumes: everything in Task 2.1.
674- Produces:
675 - `type cell struct { kind cellKind; s string }`
676 - `func cRef(s string) cell`, `cState(s string) cell`, `cText(s string) cell`, `cFlex(s string) cell`, `cAge(ts string) cell`, `cNum(n int64) cell`
677 - `func (c *Ctx) table(w io.Writer, header ...string) *table`
678 - `func (t *table) row(cells ...cell)`
679 - `func (t *table) flush()`
680 - `func stripSGR(s string) string` (test helper exported to the package; used by e2e via its own copy)
681 - `Ctx.Term Term`
682
683`cFlex` marks the column that shrinks first (a title or description). `cText` columns shrink only after it, rightmost first. `cRef`, `cState`, `cAge`, `cNum` never shrink.
684
685- [ ] **Step 1: Write the failing tests**
686
687```go
688package control
689
690import (
691 "bytes"
692 "strings"
693 "testing"
694 "time"
695)
696
697func fixtureTable(c *Ctx, w *bytes.Buffer) {
698 tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR")
699 tb.row(cRef("#252"), cState("open"), cFlex("Dependency updates available for every module"), cText("gitbay-bot"))
700 tb.row(cRef("#12"), cState("closed"), cFlex("Android app"), cText("cmc"))
701 tb.flush()
702}
703
704func TestTablePlainIsTabs(t *testing.T) {
705 var b bytes.Buffer
706 fixtureTable(&Ctx{}, &b)
707 want := "#252\topen\tDependency updates available for every module\tgitbay-bot\n" +
708 "#12\tclosed\tAndroid app\tcmc\n"
709 if b.String() != want {
710 t.Errorf("plain:\n%q\nwant\n%q", b.String(), want)
711 }
712}
713
714func TestTableTerminalFits(t *testing.T) {
715 var b bytes.Buffer
716 fixtureTable(&Ctx{Term: Term{Cols: 40}}, &b)
717 want := "# STATE TITLE AUTHOR\n" +
718 "#252 open Dependency up… gitbay-bot\n" +
719 "#12 closed Android app cmc\n"
720 if b.String() != want {
721 t.Errorf("terminal:\n%s\nwant\n%s", b.String(), want)
722 }
723}
724
725func TestTableColourOnlyAddsSGR(t *testing.T) {
726 var mono, colour bytes.Buffer
727 fixtureTable(&Ctx{Term: Term{Cols: 40}}, &mono)
728 fixtureTable(&Ctx{Term: Term{Cols: 40, Color: true}}, &colour)
729 if !strings.Contains(colour.String(), sgrGreen+"open"+sgrReset) {
730 t.Errorf("open not green: %q", colour.String())
731 }
732 if !strings.HasPrefix(colour.String(), sgrDim) {
733 t.Errorf("header not dim: %q", colour.String())
734 }
735 if stripSGR(colour.String()) != mono.String() {
736 t.Errorf("colour changed the layout:\n%s\nvs\n%s", stripSGR(colour.String()), mono.String())
737 }
738}
739
740func TestTableAgesAndPlainStamps(t *testing.T) {
741 termNow = func() time.Time { return time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) }
742 t.Cleanup(func() { termNow = time.Now })
743 var plain, term bytes.Buffer
744 for _, c := range []struct {
745 ctx *Ctx
746 w *bytes.Buffer
747 }{{&Ctx{}, &plain}, {&Ctx{Term: Term{Cols: 80}}, &term}} {
748 tb := c.ctx.table(c.w, "#", "UPDATED")
749 tb.row(cRef("#1"), cAge("2026-09-23T10:00:00.123Z"))
750 tb.flush()
751 }
752 if plain.String() != "#1\t2026-09-23T10:00:00Z\n" {
753 t.Errorf("plain = %q", plain.String())
754 }
755 if term.String() != "# UPDATED\n#1 2h ago\n" {
756 t.Errorf("term = %q", term.String())
757 }
758}
759
760func TestTableEmptyPrintsNothing(t *testing.T) {
761 var b bytes.Buffer
762 (&Ctx{Term: Term{Cols: 80}}).table(&b, "#").flush()
763 if b.Len() != 0 {
764 t.Errorf("empty table printed %q", b.String())
765 }
766}
767```
768
769The empty case prints nothing because `emit` already said `nothing to list`
770before the formatter runs.
771
772- [ ] **Step 2: Run and see them fail**
773
774Run: `go test ./internal/control -run TestTable -count=1`
775Expected: FAIL to compile.
776
777- [ ] **Step 3: Add `Term` to `Ctx`**
778
779In `internal/control/control.go`, inside `type Ctx struct`, after `JSON bool`:
780
781```go
782 // Term is the client's terminal, from GITBAY_TERM. The zero value
783 // is plain output.
784 Term Term
785```
786
787- [ ] **Step 4: Implement `internal/control/table.go`**
788
789```go
790package control
791
792import (
793 "io"
794 "strconv"
795 "strings"
796)
797
798type cellKind int
799
800const (
801 kindText cellKind = iota
802 kindFlex
803 kindRef
804 kindState
805 kindAge
806 kindNum
807)
808
809// cell is one column of a table row. The kind decides colour, time
810// format, and whether the column may be clipped to fit the terminal.
811type cell struct {
812 kind cellKind
813 s string
814}
815
816func cRef(s string) cell { return cell{kindRef, s} }
817func cState(s string) cell { return cell{kindState, s} }
818func cText(s string) cell { return cell{kindText, s} }
819func cFlex(s string) cell { return cell{kindFlex, s} }
820func cAge(ts string) cell { return cell{kindAge, ts} }
821func cNum(n int64) cell { return cell{kindNum, strconv.FormatInt(n, 10)} }
822
823// table is a list command's rows. Plain, each row is written as it
824// comes, tab-separated with no header. At a terminal rows are held
825// until flush, then written under a header, padded, and fitted to the
826// width.
827type table struct {
828 term Term
829 w io.Writer
830 header []string
831 rows [][]cell
832}
833
834func (c *Ctx) table(w io.Writer, header ...string) *table {
835 return &table{term: c.Term, w: w, header: header}
836}
837
838func (t *table) row(cs ...cell) {
839 if t.term.Cols == 0 {
840 parts := make([]string, len(cs))
841 for i, c := range cs {
842 if c.kind == kindAge {
843 parts[i] = stamp(c.s)
844 } else {
845 parts[i] = c.s
846 }
847 }
848 io.WriteString(t.w, strings.Join(parts, "\t")+"\n")
849 return
850 }
851 now := termNow()
852 for i := range cs {
853 if cs[i].kind == kindAge {
854 cs[i].s = relAge(cs[i].s, now)
855 }
856 }
857 t.rows = append(t.rows, cs)
858}
859
860func (t *table) flush() {
861 if t.term.Cols == 0 || len(t.rows) == 0 {
862 return
863 }
864 n := len(t.header)
865 widths := make([]int, n)
866 for i, h := range t.header {
867 widths[i] = cells(h)
868 }
869 for _, r := range t.rows {
870 for i := 0; i < n && i < len(r); i++ {
871 widths[i] = max(widths[i], cells(r[i].s))
872 }
873 }
874 t.fit(widths)
875
876 var b strings.Builder
877 line := make([]string, n)
878 for i, h := range t.header {
879 line[i] = h
880 }
881 b.WriteString(t.term.paint(sgrDim, t.join(line, widths)) + "\n")
882 for _, r := range t.rows {
883 for i := 0; i < n; i++ {
884 s := ""
885 if i < len(r) {
886 s = clip(r[i].s, widths[i])
887 }
888 line[i] = s
889 }
890 b.WriteString(t.joinRow(r, line, widths) + "\n")
891 }
892 io.WriteString(t.w, b.String())
893}
894
895// fit shrinks columns until a row fits the terminal: the flexible
896// column first, down to 8 cells, then the other text columns from the
897// right, down to 8 each.
898func (t *table) fit(widths []int) {
899 total := func() int {
900 s := 2 * (len(widths) - 1)
901 for _, w := range widths {
902 s += w
903 }
904 return s
905 }
906 kinds := make([]cellKind, len(widths))
907 if len(t.rows) > 0 {
908 for i := range widths {
909 if i < len(t.rows[0]) {
910 kinds[i] = t.rows[0][i].kind
911 }
912 }
913 }
914 shrink := func(i int) {
915 if over := total() - t.term.Cols; over > 0 && widths[i] > 8 {
916 widths[i] = max(8, widths[i]-over)
917 }
918 }
919 for i, k := range kinds {
920 if k == kindFlex {
921 shrink(i)
922 }
923 }
924 for i := len(kinds) - 1; i >= 0; i-- {
925 if kinds[i] == kindText {
926 shrink(i)
927 }
928 }
929}
930
931// join pads every column but the last and separates them by two spaces.
932func (t *table) join(line []string, widths []int) string {
933 var b strings.Builder
934 for i, s := range line {
935 if i > 0 {
936 b.WriteString(" ")
937 }
938 if i == len(line)-1 {
939 b.WriteString(s)
940 } else {
941 b.WriteString(pad(s, widths[i]))
942 }
943 }
944 return b.String()
945}
946
947// joinRow is join with state cells coloured after padding, so the
948// SGR bytes never count against the width.
949func (t *table) joinRow(r []cell, line []string, widths []int) string {
950 var b strings.Builder
951 for i, s := range line {
952 if i > 0 {
953 b.WriteString(" ")
954 }
955 padding := ""
956 if i < len(line)-1 {
957 padding = strings.Repeat(" ", max(0, widths[i]-cells(s)))
958 }
959 if i < len(r) && r[i].kind == kindState {
960 s = t.term.paint(stateColor(s), s)
961 }
962 b.WriteString(s + padding)
963 }
964 return b.String()
965}
966
967// stripSGR removes SGR sequences, for tests and width checks.
968func stripSGR(s string) string {
969 var b strings.Builder
970 for i := 0; i < len(s); i++ {
971 if s[i] == 0x1b {
972 if j := strings.IndexByte(s[i:], 'm'); j >= 0 {
973 i += j
974 continue
975 }
976 }
977 b.WriteByte(s[i])
978 }
979 return b.String()
980}
981```
982
983The header line is painted dim as a whole, so `TestTableColourOnlyAddsSGR`'s prefix check holds.
984
985- [ ] **Step 5: Run**
986
987Run: `go test ./internal/control -run TestTable -count=1`
988Expected: PASS. If `TestTableTerminalFits` fails, print widths: `#` 4, `STATE` 6, `TITLE` 45 shrinks by 31 to 14, `AUTHOR` 10; 4+6+14+10+6 = 40.
989
990- [ ] **Step 6: Commit**
991
992```bash
993git add internal/control/table.go internal/control/table_test.go internal/control/control.go
994git commit -m "control: table, plain rows or a fitted terminal table" -m "Ref #254"
995```
996
997### Task 2.3: `GITBAY_TERM` over SSH, and the multiplexing check
998
999**Files:**
1000- Modify: `internal/sshd/sshd.go` (`handleSession`, `runExec`, `Exec`)
1001- Modify: `cmd/gitbayd/system.go:96`
1002- Modify: `internal/control/repo.go` (`repo list` plain formatter onto `table`, the first site, so the e2e test has a header to look for)
1003- Modify: `e2e/ssh_test.go` (add `sshTerm`)
1004- Create: `e2e/term_test.go`
1005
1006**Interfaces:**
1007- Consumes: `control.ParseTerm`, `control.Term`, `Ctx.table`.
1008- Produces:
1009 - `func Exec(cfg config.Config, st *store.Store, user store.User, scope, source string, term control.Term, cmdline string, stdin io.Reader, stdout, stderr io.Writer, done, stopping <-chan struct{}) int`
1010 - e2e: `func (i *instance) sshTerm(t *testing.T, key, term string, args ...string) (string, string, int)` — `term` "" sends no `SetEnv`.
1011
1012- [ ] **Step 1: Move `repo list` onto `table`**
1013
1014Read `runRepoList` in `internal/control/repo.go`. Replace the `fmt.Fprintf` row loop in its plain formatter with a table. Columns: `PATH` (`cRef`), `VISIBILITY` (`cState`), `DESCRIPTION` (`cFlex`), keeping any trailing word column as `cText` in the same position it has today. Example shape (adapt the field names to the struct in the file):
1015
1016```go
1017tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
1018for _, r := range rows {
1019 tb.row(cRef(r.Path), cState(r.Visibility), cFlex(r.Description))
1020}
1021tb.flush()
1022```
1023
1024Run `go test ./internal/control -count=1`: plain bytes are unchanged, so existing tests pass.
1025
1026- [ ] **Step 2: Write the failing e2e test**
1027
1028`e2e/ssh_test.go`, next to `sshCmd`:
1029
1030```go
1031// sshTerm is ssh with GITBAY_TERM set on the session, as the CLI sends
1032// it at a terminal. An empty term sends nothing.
1033func (i *instance) sshTerm(t *testing.T, key, term string, args ...string) (string, string, int) {
1034 t.Helper()
1035 cmd := i.sshCmd(key, args...)
1036 if term != "" {
1037 for j, a := range cmd.Args {
1038 if a == "git@127.0.0.1" {
1039 opt := []string{"-o", "SetEnv=GITBAY_TERM=" + term}
1040 cmd.Args = append(cmd.Args[:j:j], append(opt, cmd.Args[j:]...)...)
1041 break
1042 }
1043 }
1044 }
1045 var out, errOut strings.Builder
1046 cmd.Stdout, cmd.Stderr = &out, &errOut
1047 err := cmd.Run()
1048 code := 0
1049 if ee, ok := err.(*exec.ExitError); ok {
1050 code = ee.ExitCode()
1051 } else if err != nil {
1052 t.Fatalf("ssh: %v", err)
1053 }
1054 return out.String(), errOut.String(), code
1055}
1056```
1057
1058`e2e/term_test.go`:
1059
1060```go
1061package e2e
1062
1063import (
1064 "os"
1065 "os/exec"
1066 "path/filepath"
1067 "strings"
1068 "testing"
1069)
1070
1071// GITBAY_TERM selects terminal output per session. Stock ssh without it
1072// gets the plain rows scripts read.
1073func TestTermEnvSelectsTerminalOutput(t *testing.T) {
1074 t.Parallel()
1075 inst := startInstance(t)
1076 key := inst.newKey(t, "alice")
1077 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub",
1078 "--email", "alice@example.test", "--verified")
1079 if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/app"); code != 0 {
1080 t.Fatalf("repo create: %d %s", code, errOut)
1081 }
1082
1083 plain, _, _ := inst.sshTerm(t, key, "", "repo", "list")
1084 if strings.Contains(plain, "PATH") || !strings.Contains(plain, "alice/app\t") {
1085 t.Errorf("plain repo list: %q", plain)
1086 }
1087 term, _, _ := inst.sshTerm(t, key, "80,color", "repo", "list")
1088 if !strings.HasPrefix(term, "\x1b[2mPATH") {
1089 t.Errorf("terminal repo list: %q", term)
1090 }
1091}
1092
1093// The CLI shares one connection per instance. Each session's
1094// GITBAY_TERM must reach the server, not the one the master was opened
1095// with.
1096func TestTermEnvOverMultiplexedSession(t *testing.T) {
1097 t.Parallel()
1098 inst := startInstance(t)
1099 key := inst.newKey(t, "alice")
1100 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub",
1101 "--email", "alice@example.test", "--verified")
1102 inst.ssh(t, key, "", "repo", "create", "alice/app")
1103
1104 dir, err := os.MkdirTemp("", "gbmux")
1105 if err != nil {
1106 t.Fatal(err)
1107 }
1108 t.Cleanup(func() { os.RemoveAll(dir) })
1109 sock := filepath.Join(dir, "cm")
1110 mux := func(term string) string {
1111 t.Helper()
1112 cmd := inst.sshCmd(key, "repo", "list")
1113 opts := []string{"-o", "ControlMaster=auto", "-o", "ControlPath=" + sock, "-o", "ControlPersist=30"}
1114 if term != "" {
1115 opts = append(opts, "-o", "SetEnv=GITBAY_TERM="+term)
1116 }
1117 for j, a := range cmd.Args {
1118 if a == "git@127.0.0.1" {
1119 cmd.Args = append(cmd.Args[:j:j], append(opts, cmd.Args[j:]...)...)
1120 break
1121 }
1122 }
1123 out, err := cmd.Output()
1124 if err != nil {
1125 t.Fatalf("ssh %s: %v", term, err)
1126 }
1127 return string(out)
1128 }
1129 t.Cleanup(func() {
1130 exec.Command("ssh", "-o", "ControlPath="+sock, "-O", "exit", "git@127.0.0.1").Run()
1131 })
1132
1133 if out := mux("80"); !strings.HasPrefix(out, "PATH") {
1134 t.Fatalf("master session: %q", out)
1135 }
1136 if out := mux("80,color"); !strings.HasPrefix(out, "\x1b[2mPATH") {
1137 t.Errorf("second session kept the master's GITBAY_TERM: %q", out)
1138 }
1139 if out := mux(""); strings.Contains(out, "PATH") {
1140 t.Errorf("session without GITBAY_TERM got terminal output: %q", out)
1141 }
1142}
1143```
1144
1145- [ ] **Step 3: Run and see them fail**
1146
1147Run: `go test ./e2e -run 'TestTermEnv' -count=1`
1148Expected: FAIL (the server ignores `env`).
1149
1150- [ ] **Step 4: sshd**
1151
1152In `handleSession`, declare `var term control.Term` before `for req := range reqs`, pass it to `runExec`, and split the `pty-req`/`env` case:
1153
1154```go
1155 case "env":
1156 var kv struct{ Name, Value string }
1157 if ssh.Unmarshal(req.Payload, &kv) == nil && kv.Name == "GITBAY_TERM" {
1158 term = control.ParseTerm(kv.Value)
1159 }
1160 req.Reply(true, nil)
1161 case "pty-req":
1162 // Harmless; accept and ignore.
1163 req.Reply(true, nil)
1164```
1165
1166`runExec(sconn, ch, term, payload.Command, done)`; `runExec` passes `term` to `Exec`; `Exec` gains `term control.Term` after `source` and sets `Term: term` in the `control.Ctx` literal (around line 364). In `cmd/gitbayd/system.go:96`:
1167
1168```go
1169code := sshd.Exec(cfg, st, user, key.Scope, key.Fingerprint, control.ParseTerm(os.Getenv("GITBAY_TERM")), cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil)
1170```
1171
1172(import `gitbay.org/gitbay/internal/control` there if it is not already.)
1173
1174- [ ] **Step 5: Run**
1175
1176Run: `go build ./... && go vet ./... && go test ./e2e -run 'TestTermEnv' -count=1`
1177Expected: `TestTermEnvSelectsTerminalOutput` PASS.
1178
1179`TestTermEnvOverMultiplexedSession` decides the transport:
1180- PASS: keep `SetEnv`. Go to Step 7.
1181- FAIL on the second session: OpenSSH's mux client does not forward the new session's `SetEnv`. Do Step 6.
1182
1183- [ ] **Step 6 (only if Step 5's mux test failed): `--term` argument**
1184
1185In `Dispatch` (`internal/control/control.go`), in the loop that strips `--json`, also strip `--term=<v>`:
1186
1187```go
1188 for _, a := range rest {
1189 if a == "--json" {
1190 c.JSON = true
1191 continue
1192 }
1193 if v, ok := strings.CutPrefix(a, "--term="); ok {
1194 c.Term = ParseTerm(v)
1195 continue
1196 }
1197 args = append(args, a)
1198 }
1199```
1200
1201(match the loop's real shape). `Lookup` runs before the loop, so a leading `--term=` would break it: strip `--term=` from `argv` before `Lookup` too. Change both e2e tests to pass the term as a leading `--term=<v>` argument instead of `-o SetEnv=…`, keep the `env` handling in sshd (stock ssh users may still use it), and in Task 2.5 the CLI prepends `--term=<v>` to the server argv instead of adding `SetEnv`. Record the outcome in the spec's Transport section.
1202
1203- [ ] **Step 7: Commit**
1204
1205```bash
1206git add internal/sshd/sshd.go cmd/gitbayd/system.go internal/control/repo.go internal/control/control.go e2e/ssh_test.go e2e/term_test.go
1207git commit -m "sshd: GITBAY_TERM selects terminal output per session" -m "Ref #254"
1208```
1209
1210### Task 2.4: `more:` hint, `Ctx.Argv`
1211
1212**Files:**
1213- Modify: `internal/control/control.go` (`Ctx.Argv`, set in `Dispatch`)
1214- Modify: `internal/control/cursor.go` (`emitPage`)
1215- Test: `internal/control/cursor_test.go`
1216
1217**Interfaces:**
1218- Produces: `Ctx.Argv []string` — the command's arguments after the path, with `--json` (and `--term=`) removed.
1219
1220- [ ] **Step 1: Write the failing test**
1221
1222```go
1223func TestEmitPageHintsTheNextPageAtATerminal(t *testing.T) {
1224 st, repo, uid := newQueueTestRepo(t)
1225 for i := 0; i < 3; i++ {
1226 if _, err := st.CreateBuild(repo.ID, "unit", "aaa", "main", `["true"]`, "", "", true); err != nil {
1227 t.Fatal(err)
1228 }
1229 }
1230 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
1231 c.Term = Term{Cols: 100}
1232 if code := Dispatch(c, []string{"build", "list", repo.Path(), "--limit", "2"}); code != protocol.ExitOK {
1233 t.Fatalf("exit %d: %s", code, errOut)
1234 }
1235 if strings.Contains(c.Stdout.(*bytes.Buffer).String(), "next\t") {
1236 t.Errorf("cursor row on stdout at a terminal")
1237 }
1238 want := "more: gitbay build list " + repo.Path() + " --limit 2 --cursor "
1239 if !strings.Contains(errOut.String(), want) {
1240 t.Errorf("stderr = %q, want %q…", errOut.String(), want)
1241 }
1242}
1243```
1244
1245Add the imports the file needs (`bytes`, `strings`, `protocol`, `store`).
1246
1247- [ ] **Step 2: Run and see it fail**
1248
1249Run: `go test ./internal/control -run TestEmitPageHints -count=1`
1250Expected: FAIL.
1251
1252- [ ] **Step 3: Implement**
1253
1254`Ctx` gains, after `Cmd Command`:
1255
1256```go
1257 // Argv is the command's arguments after its path, global flags
1258 // removed, so output can print a command to run next.
1259 Argv []string
1260```
1261
1262In `Dispatch`, after the `--json` stripping loop builds `args`: `c.Argv = args`.
1263
1264`emitPage`'s plain closure:
1265
1266```go
1267 return c.emit(out{items, next}, func(w io.Writer) {
1268 plain(w)
1269 if next == "" {
1270 return
1271 }
1272 if c.Term.Cols == 0 {
1273 fmt.Fprintf(w, "next\t%s\n", next)
1274 return
1275 }
1276 var again []string
1277 for i := 0; i < len(c.Argv); i++ {
1278 if c.Argv[i] == "--cursor" {
1279 i++
1280 continue
1281 }
1282 again = append(again, c.Argv[i])
1283 }
1284 fmt.Fprintf(c.Stderr, "more: gitbay %s %s --cursor %s\n", joinPath(c.Cmd.Path), strings.Join(again, " "), next)
1285 })
1286```
1287
1288- [ ] **Step 4: Run**
1289
1290Run: `go test ./internal/control -count=1`
1291Expected: PASS.
1292
1293- [ ] **Step 5: Commit**
1294
1295```bash
1296git add internal/control/control.go internal/control/cursor.go internal/control/cursor_test.go
1297git commit -m "control: the next page as a command on stderr at a terminal" -m "Ref #254"
1298```
1299
1300### Task 2.5: the CLI sends `GITBAY_TERM`; `--no-color`; tabwriter removed
1301
1302**Files:**
1303- Modify: `cmd/gitbay/ssh.go` (`runSSH`, `sshCapture` unchanged; remove `listVerbs`, `alignColumns`, the `tabwriter`)
1304- Modify: `cmd/gitbay/main.go` (`main` strips `--no-color`)
1305- Test: `cmd/gitbay/term_test.go` (create)
1306
1307**Interfaces:**
1308- Produces:
1309 - `var noColor bool` (package `main`)
1310 - `func termValue(isTerminal bool, cols int, env func(string) string) string`
1311 - `func stripNoColor(args []string) ([]string, bool)`
1312
1313- [ ] **Step 1: Write the failing test**
1314
1315```go
1316package main
1317
1318import "testing"
1319
1320func TestTermValue(t *testing.T) {
1321 env := func(m map[string]string) func(string) string {
1322 return func(k string) string { return m[k] }
1323 }
1324 cases := []struct {
1325 tty bool
1326 cols int
1327 env map[string]string
1328 noColor bool
1329 want string
1330 }{
1331 {true, 120, nil, false, "120,color"},
1332 {false, 120, nil, false, ""},
1333 {true, 30, nil, false, ""},
1334 {true, 120, map[string]string{"NO_COLOR": "1"}, false, "120"},
1335 {true, 120, map[string]string{"TERM": "dumb"}, false, "120"},
1336 {true, 120, nil, true, "120"},
1337 }
1338 for _, c := range cases {
1339 noColor = c.noColor
1340 if got := termValue(c.tty, c.cols, env(c.env)); got != c.want {
1341 t.Errorf("%+v: got %q", c, got)
1342 }
1343 }
1344 noColor = false
1345}
1346
1347func TestStripNoColor(t *testing.T) {
1348 args, ok := stripNoColor([]string{"gitbay", "issue", "list", "--no-color", "--state", "all"})
1349 if !ok || len(args) != 5 || args[3] != "--state" {
1350 t.Errorf("got %v %v", args, ok)
1351 }
1352}
1353```
1354
1355- [ ] **Step 2: Run and see it fail**
1356
1357Run: `go test ./cmd/gitbay -run 'TestTermValue|TestStripNoColor' -count=1`
1358Expected: FAIL to compile.
1359
1360- [ ] **Step 3: Implement**
1361
1362In `cmd/gitbay/ssh.go`, delete `listVerbs` and `alignColumns`, and add:
1363
1364```go
1365// noColor is --no-color, stripped from argv in main.
1366var noColor bool
1367
1368// termValue is GITBAY_TERM for this invocation: the terminal's width,
1369// and whether colour is wanted. Empty when stdout is not a terminal,
1370// so piped output stays the rows stock ssh prints.
1371func termValue(isTerminal bool, cols int, env func(string) string) string {
1372 if !isTerminal || cols < 40 {
1373 return ""
1374 }
1375 v := strconv.Itoa(cols)
1376 if !noColor && env("NO_COLOR") == "" && env("TERM") != "dumb" {
1377 v += ",color"
1378 }
1379 return v
1380}
1381
1382// stripNoColor removes --no-color wherever it appears.
1383func stripNoColor(args []string) ([]string, bool) {
1384 out := args[:0:0]
1385 found := false
1386 for _, a := range args {
1387 if a == "--no-color" {
1388 found = true
1389 continue
1390 }
1391 out = append(out, a)
1392 }
1393 return out, found
1394}
1395```
1396
1397In `runSSH`, replace the tabwriter block. Before building `args`'s destination:
1398
1399```go
1400 fd := int(os.Stdout.Fd())
1401 cols := 0
1402 isTTY := term.IsTerminal(fd)
1403 if isTTY {
1404 cols, _, _ = term.GetSize(fd)
1405 }
1406 if v := termValue(isTTY, cols, os.Getenv); v != "" && !slices.Contains(serverArgv, "--json") {
1407 args = append(args, "-o", "SetEnv=GITBAY_TERM="+v)
1408 }
1409```
1410
1411placed after `args := sshArgs(t.inst)` and before the destination is appended (if Task 2.3 took Step 6, prepend `"--term="+v` to `serverArgv` instead). Remove `cmd.Stdout = tw` handling and the `text/tabwriter` import. In `main()`:
1412
1413```go
1414func main() {
1415 os.Args, noColor = stripNoColor(os.Args)
1416 if err := newRoot().Execute(); err != nil {
1417```
1418
1419- [ ] **Step 4: Run**
1420
1421Run: `go build ./... && go vet ./cmd/gitbay && go test ./cmd/gitbay -count=1`
1422Expected: PASS.
1423
1424- [ ] **Step 5: Commit**
1425
1426```bash
1427git add cmd/gitbay
1428git commit -m "gitbay: send GITBAY_TERM at a terminal; --no-color; drop client-side column padding" -m "Ref #254"
1429```
1430
1431### Task 2.6: every list command on `table` (issues, MRs, builds, releases, labels, milestones, search, explore)
1432
1433**Files:**
1434- Modify: `internal/control/issue.go`, `mr.go`, `build.go`, `release.go`, `label.go`, `orglabel.go`, `milestone.go`, `search.go`, `explore.go`
1435- Test: existing tests in those files' `_test.go`
1436
1437**Recipe (applies to Tasks 2.6, 2.7, 2.8):**
1438
1439A list site is an `emit`/`emitPage` whose plain formatter prints one row per item. For each:
1440
14411. Keep the column order exactly as the `fmt.Fprintf` prints it today, so plain bytes are unchanged apart from timestamps.
14422. Choose a cell per column:
1443 - identifier (`#n`, `!n`, a path, a tag, a sha, a fingerprint, a name that is the row's key) → `cRef`
1444 - state, status, visibility, role → `cState`
1445 - stored timestamp → `cAge`
1446 - count → `cNum` (or `cText` if the value is already a string such as `3 asset(s)`)
1447 - the title or description → `cFlex` (one per table)
1448 - anything else → `cText`
14493. Header: one short capitalised word per column (`#`, `STATE`, `TITLE`, `AUTHOR`, `UPDATED`, `TAG`, `JOB`, `STATUS`, `SHA`, `REF`, `NAME`, `DESCRIPTION`, `ASSETS`, `DUE`, `COUNT`, `KIND`, `PATH`, `WHEN`).
14504. Replace the loop:
1451
1452```go
1453return c.emit(ds, func(w io.Writer) {
1454 tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR")
1455 for _, d := range ds {
1456 tb.row(cRef(fmt.Sprintf("#%d", d.Number)), cState(d.State), cFlex(d.Title), cText(d.Author))
1457 }
1458 tb.flush()
1459})
1460```
1461
14625. A column printed with a literal prefix or suffix (`due 2027-01-01`, `via team`) keeps it inside the cell string.
14636. A row printed conditionally (optional trailing column) prints `""` in that cell so every row has the same number of cells.
1464
1465- [ ] **Step 1: Convert** every list site in the files above, one file at a time. `release list` columns: `TAG` `cRef`, `TITLE` `cFlex`, `ASSETS` `cText`.
1466
1467- [ ] **Step 2: Run**
1468
1469Run: `go test ./internal/control -count=1`
1470Expected: PASS. A failure on a timestamp means the test asserted a stored value; change its expectation to `stamp(value)`'s form (`2026-09-23T23:26:00Z`). A failure anywhere else means a column changed order or content: fix the conversion, not the test.
1471
1472- [ ] **Step 3: Commit**
1473
1474```bash
1475git add internal/control
1476git commit -m "control: issue, mr, build, release, label, milestone, search and explore lists as tables" -m "Ref #254"
1477```
1478
1479### Task 2.7: list commands in repo, read, sig, status, wiki, mirror, pages, runners, webhooks, diff threads
1480
1481**Files:**
1482- Modify: `internal/control/repo.go` (the six sites left after `repo list`), `read.go`, `sig.go`, `status.go`, `wiki.go`, `mirrorcmd.go`, `pagescmd.go`, `runnerrepo.go`, `webhook.go`, `diffcomment.go`
1483
1484- [ ] **Step 1: Convert** with the recipe in Task 2.6. `read.go`'s `log`-like listings: `SHA` `cRef`, `DATE` `cAge`, `AUTHOR` `cText`, `SUBJECT` `cFlex`. Sites that print file content (a blob, a diff, a log's body) are not lists; leave them.
1485
1486- [ ] **Step 2: Run**
1487
1488Run: `go test ./internal/control -count=1`
1489Expected: PASS, with the same rule for failures as Task 2.6.
1490
1491- [ ] **Step 3: Commit**
1492
1493```bash
1494git add internal/control
1495git commit -m "control: repository, history, status, wiki, mirror, runner and webhook lists as tables" -m "Ref #254"
1496```
1497
1498### Task 2.8: list commands in accounts, orgs, admin, notifications, snippets, feed, dashboard
1499
1500**Files:**
1501- Modify: `internal/control/admin.go`, `audit.go`, `identity.go`, `deploykey.go`, `token.go`, `web.go`, `register.go`, `notifications.go`, `org.go`, `teams.go`, `snippet.go`, `dashboard.go`
1502- Not `control.go`'s `help` listing: Part 4 rewrites it.
1503
1504- [ ] **Step 1: Convert** with the recipe in Task 2.6. `admin.go:287` and `token.go:102` format `time.RFC3339` themselves: pass the stored string to `cAge` (or, for a `used`/`expires` word column, keep the word and format the time with `stamp` when plain, `relAge(…, termNow())` at a terminal).
1505
1506`dashboard`: each section keeps its title line; the rows under it become a table per section. Plain output keeps today's two-space indent and tabs, so for the dashboard only, write the plain branch as it is and use `c.table` only when `c.Term.Cols > 0`:
1507
1508```go
1509section := func(title string, header []string, rows [][]cell) {
1510 fmt.Fprintln(w, title)
1511 if len(rows) == 0 {
1512 fmt.Fprintln(w, " none")
1513 return
1514 }
1515 if c.Term.Cols == 0 {
1516 for _, r := range rows {
1517 parts := make([]string, len(r))
1518 for i, cl := range r {
1519 parts[i] = cl.s
1520 if cl.kind == kindAge {
1521 parts[i] = stamp(cl.s)
1522 }
1523 }
1524 fmt.Fprintf(w, " %s\n", strings.Join(parts, "\t"))
1525 }
1526 return
1527 }
1528 tb := c.table(w, header...)
1529 for _, r := range rows {
1530 tb.row(r...)
1531 }
1532 tb.flush()
1533}
1534```
1535
1536In terminal mode the section title is bold: `fmt.Fprintln(w, c.Term.paint(sgrBold, title))` in place of the plain `Fprintln` when `c.Term.Cols > 0`.
1537
1538- [ ] **Step 2: Run**
1539
1540Run: `go test ./internal/control -count=1`
1541Expected: PASS.
1542
1543- [ ] **Step 3: Commit**
1544
1545```bash
1546git add internal/control
1547git commit -m "control: account, org, admin, notification, snippet, feed and dashboard lists as tables" -m "Ref #254"
1548```
1549
1550### Task 2.9: every read command, plain and at 60 columns, over ssh
1551
1552**Files:**
1553- Modify: `e2e/readonly_test.go` (the loop at line ~174)
1554
1555- [ ] **Step 1: Extend the loop**
1556
1557After the existing `inst.ssh(...)` call and its exit check, add:
1558
1559```go
1560 argv := append(append([]string{}, cmd.Path...), args...)
1561 plainOut, _, _ := inst.sshTerm(t, aliceKey, "", argv...)
1562 if strings.Contains(plainOut, "\x1b") {
1563 t.Errorf("%s: SGR bytes in plain output", path)
1564 }
1565 termOut, _, _ := inst.sshTerm(t, aliceKey, "60,color", argv...)
1566 if !rawOutput[path] {
1567 for _, line := range strings.Split(termOut, "\n") {
1568 if w := displayCells(stripSGRe2e(line)); w > 60 {
1569 t.Errorf("%s: line of %d cells at 60 columns: %q", path, w, line)
1570 break
1571 }
1572 }
1573 }
1574```
1575
1576and before the loop:
1577
1578```go
1579 // rawOutput prints content verbatim (a file, a log, a diff) and is
1580 // not fitted to the terminal.
1581 rawOutput := map[string]bool{}
1582```
1583
1584with helpers at the bottom of the file (e2e does not import unexported control code):
1585
1586```go
1587func stripSGRe2e(s string) string {
1588 return regexp.MustCompile("\x1b\\[[0-9;]*m").ReplaceAllString(s, "")
1589}
1590
1591func displayCells(s string) int {
1592 n := 0
1593 for _, r := range s {
1594 switch {
1595 case unicode.In(r, unicode.Mn, unicode.Me):
1596 case width.LookupRune(r).Kind() == width.EastAsianWide || width.LookupRune(r).Kind() == width.EastAsianFullwidth:
1597 n += 2
1598 default:
1599 n++
1600 }
1601 }
1602 return n
1603}
1604```
1605
1606(imports `unicode`, `golang.org/x/text/width`.)
1607
1608- [ ] **Step 2: Run**
1609
1610Run: `go test ./e2e -run TestReadOnlyCommandsWriteNothing -count=1`
1611
1612For each width failure: if the command's stdout is verbatim content (file, log, diff, raw asset), add its path to `rawOutput` with nothing else; otherwise the list site was missed or a cell kind is wrong, fix it in `internal/control`. Show commands will fail here until Part 3: add every `* show` path that fails to `rawOutput` with the comment `// until the view layout (Part 3)`, and Part 3 removes them.
1613
1614Expected in the end: PASS.
1615
1616- [ ] **Step 3: Commit and open MR 2**
1617
1618```bash
1619git add e2e/readonly_test.go
1620git commit -m "e2e: read commands carry no SGR when plain and fit 60 columns at a terminal" -m "Ref #254"
1621git push -u origin cli-output-tables
1622gitbay mr create --source cli-output-tables --target main --title "CLI output: GITBAY_TERM and terminal tables"
1623```
1624
1625Before merging, check at a real terminal against a local instance or after deploy: `gitbay issue list`, `gitbay build list --limit 5`, `gitbay issue list | cat`.
1626
1627---
1628
1629# Part 3: show views and the pager (branch `cli-output-views`)
1630
1631### Task 3.1: `internal/termtext`, markdown
1632
1633**Files:**
1634- Create: `internal/termtext/termtext.go`, `internal/termtext/markdown.go`
1635- Test: `internal/termtext/markdown_test.go`, `internal/termtext/testdata/*.md`, `*.golden`
1636
1637**Interfaces:**
1638- Produces:
1639 - `type Options struct { Width int; Color bool; Base string }` — `Width` 0 is plain: no wrapping, no SGR. `Base` is the site URL for relative links.
1640 - `func Render(src, format string, o Options) string` — `format` `"org"` renders org, anything else markdown.
1641 - `func Markdown(src string, o Options) string`
1642 - `func Inline(src, format string) string` — one line, links as their text only, no SGR (for event lines).
1643
1644- [ ] **Step 1: Write the golden test**
1645
1646`internal/termtext/markdown_test.go`:
1647
1648```go
1649package termtext
1650
1651import (
1652 "flag"
1653 "os"
1654 "path/filepath"
1655 "strings"
1656 "testing"
1657)
1658
1659var update = flag.Bool("update", false, "rewrite golden files")
1660
1661func golden(t *testing.T, name, got string) {
1662 t.Helper()
1663 path := filepath.Join("testdata", name)
1664 if *update {
1665 os.WriteFile(path, []byte(got), 0o644)
1666 }
1667 want, err := os.ReadFile(path)
1668 if err != nil {
1669 t.Fatal(err)
1670 }
1671 if got != string(want) {
1672 t.Errorf("%s differs:\n--- got\n%s\n--- want\n%s", name, got, want)
1673 }
1674}
1675
1676func TestMarkdownGolden(t *testing.T) {
1677 src, err := os.ReadFile("testdata/doc.md")
1678 if err != nil {
1679 t.Fatal(err)
1680 }
1681 for _, o := range []struct {
1682 name string
1683 opt Options
1684 }{
1685 {"doc.md.plain.golden", Options{Base: "https://forge.test"}},
1686 {"doc.md.60.golden", Options{Width: 60, Base: "https://forge.test"}},
1687 {"doc.md.60color.golden", Options{Width: 60, Color: true, Base: "https://forge.test"}},
1688 } {
1689 golden(t, o.name, Markdown(string(src), o.opt))
1690 }
1691}
1692
1693func TestMarkdownWidth(t *testing.T) {
1694 src, _ := os.ReadFile("testdata/doc.md")
1695 out := Markdown(string(src), Options{Width: 60, Color: true})
1696 inCode := false
1697 for _, line := range strings.Split(out, "\n") {
1698 plain := stripSGR(line)
1699 if strings.HasPrefix(plain, " ") {
1700 inCode = true
1701 } else if plain != "" {
1702 inCode = false
1703 }
1704 if !inCode && cells(plain) > 60 {
1705 t.Errorf("line of %d cells: %q", cells(plain), plain)
1706 }
1707 }
1708}
1709
1710func TestInlineDropsLinkTargets(t *testing.T) {
1711 got := Inline("referenced in commit [6c4d1e1454](/krz/gitbay/commit/6c4d) by [cmc](/cmc): landing", "md")
1712 if got != "referenced in commit 6c4d1e1454 by cmc: landing" {
1713 t.Errorf("Inline = %q", got)
1714 }
1715}
1716```
1717
1718`internal/termtext/testdata/doc.md` covers every node the renderer handles:
1719
1720````markdown
1721# A heading
1722
1723A paragraph long enough to wrap at sixty columns, with **strong** and *emphasis*, `code`, a [link](https://example.com/page), a [forge link](/krz/gitbay/issues/1), an autolink <https://example.com>, and ~~struck~~ text.
1724
1725- one
1726- two, which is long enough that its continuation line has to hang under the text rather than the bullet
1727 - nested
1728
17291. first
17302. second
1731
1732- [x] done
1733- [ ] open
1734
1735> quoted text
1736
1737```go
1738func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") }
1739```
1740
1741![alt text](/img.png)
1742
1743---
1744
1745| a | b |
1746|---|---|
1747| 1 | 2 |
1748````
1749
1750- [ ] **Step 2: Run and see it fail**
1751
1752Run: `go test ./internal/termtext -count=1`
1753Expected: FAIL to compile.
1754
1755- [ ] **Step 3: Implement**
1756
1757`internal/termtext/termtext.go`:
1758
1759```go
1760// Package termtext renders markdown and org to text for a terminal:
1761// wrapped to a width, links reduced to their text, code highlighted
1762// with 16 colours. Width 0 is plain: no wrapping and no SGR, for
1763// piped output.
1764package termtext
1765
1766import (
1767 "bytes"
1768 "strings"
1769 "unicode"
1770 "unicode/utf8"
1771
1772 "github.com/alecthomas/chroma/v2/quick"
1773 "golang.org/x/text/width"
1774)
1775
1776type Options struct {
1777 Width int
1778 Color bool
1779 Base string
1780}
1781
1782func Render(src, format string, o Options) string {
1783 if format == "org" {
1784 return Org(src, o)
1785 }
1786 return Markdown(src, o)
1787}
1788
1789const (
1790 sgrReset = "\x1b[0m"
1791 sgrBold = "\x1b[1m"
1792 sgrDim = "\x1b[2m"
1793 sgrUnderline = "\x1b[4m"
1794)
1795
1796// out collects rendered lines. Every block goes through it so the
1797// prefixes (indent, list marker, quote bar) and the wrap live in one
1798// place.
1799type out struct {
1800 o Options
1801 b strings.Builder
1802 noURLs bool // links as their text only (Inline)
1803}
1804
1805func (w *out) paint(sgr, s string) string {
1806 if !w.o.Color || s == "" {
1807 return s
1808 }
1809 return sgr + s + sgrReset
1810}
1811
1812// para writes s wrapped to the width, the first line after first and
1813// the rest after rest. Hard breaks in s ("\n") start a new line.
1814func (w *out) para(s, first, rest string) {
1815 prefix := first
1816 for _, hard := range strings.Split(s, "\n") {
1817 for _, line := range wrap(hard, w.o.Width-cells(rest)) {
1818 w.b.WriteString(prefix + line + "\n")
1819 prefix = rest
1820 }
1821 }
1822}
1823
1824// code writes lines verbatim under prefix plus four spaces,
1825// highlighted when colour is on.
1826func (w *out) code(src, lang, prefix string) {
1827 src = strings.TrimRight(src, "\n")
1828 if w.o.Color && w.o.Width > 0 {
1829 var hb bytes.Buffer
1830 if lang == "" {
1831 lang = "plaintext"
1832 }
1833 if quick.Highlight(&hb, src, lang, "terminal16", "monokai") == nil {
1834 src = strings.TrimRight(hb.String(), "\n")
1835 }
1836 }
1837 for _, line := range strings.Split(src, "\n") {
1838 w.b.WriteString(prefix + " " + line + "\n")
1839 }
1840}
1841
1842func (w *out) rule(prefix string) {
1843 w.b.WriteString(prefix + w.paint(sgrDim, "───") + "\n")
1844}
1845
1846func (w *out) blank() { w.b.WriteString("\n") }
1847
1848func (w *out) String() string {
1849 return strings.TrimRight(w.b.String(), "\n") + "\n"
1850}
1851
1852// link is a link as terminal text: its text, then the target when the
1853// target says something the text does not. Relative targets are made
1854// absolute against Base.
1855func (w *out) link(text, target string) string {
1856 if w.noURLs && text != "" {
1857 return text
1858 }
1859 if strings.HasPrefix(target, "/") && w.o.Base != "" {
1860 target = strings.TrimRight(w.o.Base, "/") + target
1861 }
1862 if text == "" {
1863 return target
1864 }
1865 if target == "" || target == text || strings.TrimPrefix(strings.TrimPrefix(target, "https://"), "http://") == text {
1866 return text
1867 }
1868 return text + " (" + target + ")"
1869}
1870
1871// wrap breaks s at spaces into lines of at most width cells. A word
1872// wider than width is a line of its own. width <= 0 is no wrapping.
1873func wrap(s string, width int) []string {
1874 if width <= 0 {
1875 return []string{s}
1876 }
1877 var lines []string
1878 var cur string
1879 for _, word := range strings.Fields(s) {
1880 switch {
1881 case cur == "":
1882 cur = word
1883 case cells(cur)+1+cells(word) <= width:
1884 cur += " " + word
1885 default:
1886 lines = append(lines, cur)
1887 cur = word
1888 }
1889 }
1890 if cur != "" || len(lines) == 0 {
1891 lines = append(lines, cur)
1892 }
1893 return lines
1894}
1895
1896func cells(s string) int {
1897 n := 0
1898 for i := 0; i < len(s); {
1899 if s[i] == 0x1b {
1900 j := strings.IndexByte(s[i:], 'm')
1901 if j < 0 {
1902 break
1903 }
1904 i += j + 1
1905 continue
1906 }
1907 r, size := utf8.DecodeRuneInString(s[i:])
1908 i += size
1909 switch {
1910 case unicode.In(r, unicode.Mn, unicode.Me) || r == '‍':
1911 case width.LookupRune(r).Kind() == width.EastAsianWide || width.LookupRune(r).Kind() == width.EastAsianFullwidth:
1912 n += 2
1913 default:
1914 n++
1915 }
1916 }
1917 return n
1918}
1919
1920func stripSGR(s string) string {
1921 var b strings.Builder
1922 for i := 0; i < len(s); i++ {
1923 if s[i] == 0x1b {
1924 if j := strings.IndexByte(s[i:], 'm'); j >= 0 {
1925 i += j
1926 continue
1927 }
1928 }
1929 b.WriteByte(s[i])
1930 }
1931 return b.String()
1932}
1933```
1934
1935`internal/termtext/markdown.go`:
1936
1937```go
1938package termtext
1939
1940import (
1941 "fmt"
1942 "strings"
1943
1944 "github.com/yuin/goldmark"
1945 "github.com/yuin/goldmark/ast"
1946 "github.com/yuin/goldmark/extension"
1947 east "github.com/yuin/goldmark/extension/ast"
1948 "github.com/yuin/goldmark/text"
1949)
1950
1951// md parses as the web does (CommonMark plus GFM); raw HTML is dropped
1952// there and here.
1953var md = goldmark.New(goldmark.WithExtensions(extension.GFM))
1954
1955func Markdown(src string, o Options) string {
1956 return renderMarkdown(src, &out{o: o})
1957}
1958
1959func renderMarkdown(src string, w *out) string {
1960 source := []byte(src)
1961 doc := md.Parser().Parse(text.NewReader(source))
1962 r := mdRenderer{w: w, src: source}
1963 r.blocks(doc, "", "")
1964 return w.String()
1965}
1966
1967// Inline is src as one line of plain text, links reduced to their
1968// text, for event lines.
1969func Inline(src, format string) string {
1970 w := &out{noURLs: true}
1971 var s string
1972 if format == "org" {
1973 s = renderOrg(src, w)
1974 } else {
1975 s = renderMarkdown(src, w)
1976 }
1977 return strings.Join(strings.Fields(s), " ")
1978}
1979
1980type mdRenderer struct {
1981 w *out
1982 src []byte
1983}
1984
1985// blocks renders n's children with a blank line between them. The
1986// first child's first line is prefixed by first, every other line by
1987// rest.
1988func (r mdRenderer) blocks(n ast.Node, first, rest string) {
1989 p := first
1990 for c := n.FirstChild(); c != nil; c = c.NextSibling() {
1991 if c != n.FirstChild() {
1992 r.w.blank()
1993 }
1994 r.block(c, p, rest)
1995 p = rest
1996 }
1997}
1998
1999func (r mdRenderer) block(n ast.Node, first, rest string) {
2000 switch n := n.(type) {
2001 case *ast.Heading:
2002 r.w.para(r.w.paint(sgrBold, r.inline(n)), first, rest)
2003 case *ast.Paragraph:
2004 r.w.para(r.inline(n), first, rest)
2005 case *ast.TextBlock:
2006 r.w.para(r.inline(n), first, rest)
2007 case *ast.List:
2008 i := n.Start
2009 p := first
2010 for item := n.FirstChild(); item != nil; item = item.NextSibling() {
2011 marker := "• "
2012 if n.IsOrdered() {
2013 marker = fmt.Sprintf("%d. ", i)
2014 i++
2015 }
2016 hang := rest + strings.Repeat(" ", cells(marker))
2017 for c := item.FirstChild(); c != nil; c = c.NextSibling() {
2018 if c == item.FirstChild() {
2019 r.block(c, p+marker, hang)
2020 } else {
2021 if !n.IsTight {
2022 r.w.blank()
2023 }
2024 r.block(c, hang, hang)
2025 }
2026 }
2027 p = rest
2028 }
2029 case *ast.FencedCodeBlock:
2030 r.w.code(r.lines(n), string(n.Language(r.src)), rest)
2031 case *ast.CodeBlock:
2032 r.w.code(r.lines(n), "", rest)
2033 case *ast.Blockquote:
2034 bar := r.w.paint(sgrDim, "│ ")
2035 r.blocks(n, first+bar, rest+bar)
2036 case *ast.ThematicBreak:
2037 r.w.rule(first)
2038 case *ast.HTMLBlock:
2039 // Dropped, as the web drops it.
2040 default:
2041 // GFM tables and anything else: the source, as a code block.
2042 r.w.code(r.lines(n), "", rest)
2043 }
2044}
2045
2046func (r mdRenderer) lines(n ast.Node) string {
2047 var b strings.Builder
2048 ls := n.Lines()
2049 for i := 0; i < ls.Len(); i++ {
2050 seg := ls.At(i)
2051 b.Write(seg.Value(r.src))
2052 }
2053 return b.String()
2054}
2055
2056func (r mdRenderer) inline(n ast.Node) string {
2057 var b strings.Builder
2058 for c := n.FirstChild(); c != nil; c = c.NextSibling() {
2059 switch c := c.(type) {
2060 case *ast.Text:
2061 b.Write(c.Segment.Value(r.src))
2062 switch {
2063 case c.HardLineBreak():
2064 b.WriteString("\n")
2065 case c.SoftLineBreak():
2066 b.WriteString(" ")
2067 }
2068 case *ast.String:
2069 b.Write(c.Value)
2070 case *ast.CodeSpan:
2071 b.WriteString(r.inline(c))
2072 case *ast.Emphasis:
2073 sgr := sgrUnderline
2074 if c.Level == 2 {
2075 sgr = sgrBold
2076 }
2077 b.WriteString(r.w.paint(sgr, r.inline(c)))
2078 case *ast.Link:
2079 b.WriteString(r.w.link(r.inline(c), string(c.Destination)))
2080 case *ast.AutoLink:
2081 u := string(c.URL(r.src))
2082 b.WriteString(r.w.link(u, u))
2083 case *ast.Image:
2084 b.WriteString("[image: " + r.inline(c) + "]")
2085 case *ast.RawHTML:
2086 case *east.TaskCheckBox:
2087 if c.IsChecked {
2088 b.WriteString("[x] ")
2089 } else {
2090 b.WriteString("[ ] ")
2091 }
2092 default:
2093 b.WriteString(r.inline(c))
2094 }
2095 }
2096 return b.String()
2097}
2098```
2099
2100`Inline` calls `renderOrg`, which Task 3.2 adds. Until then, add a stub to `org.go` so the package compiles: `func renderOrg(src string, w *out) string { return src }`.
2101
2102- [ ] **Step 4: Generate the golden files and read them**
2103
2104Run: `go test ./internal/termtext -run TestMarkdownGolden -update -count=1`, then open each `testdata/doc.md.*.golden` and check by eye:
2105- plain: no `\x1b`, paragraphs unwrapped, `link (https://example.com/page)`, `forge link (https://forge.test/krz/gitbay/issues/1)`, the autolink once, `[image: alt text]`, bullets `•`, `[x] done`, the table as its source indented four spaces.
2106- 60: no non-code line over 60 cells; the nested item indented under its parent's text.
2107- 60color: bold heading, underlined emphasis, dim quote bar, SGR in the code line.
2108
2109Fix the renderer until each reads right, regenerate, then:
2110
2111Run: `go test ./internal/termtext -count=1`
2112Expected: PASS.
2113
2114- [ ] **Step 5: Commit**
2115
2116```bash
2117git add internal/termtext
2118git commit -m "termtext: markdown for a terminal" -m "Ref #254"
2119```
2120
2121### Task 3.2: `internal/termtext`, org
2122
2123**Files:**
2124- Create: `internal/termtext/org.go`
2125- Test: `internal/termtext/org_test.go`, `testdata/doc.org`, goldens
2126
2127**Interfaces:**
2128- Produces: `func Org(src string, o Options) string`, `func renderOrg(src string, w *out) string`.
2129
2130- [ ] **Step 1: Check go-org's node types**
2131
2132Run: `go doc github.com/niklasfasching/go-org/org | grep -E '^type|^func String'`
2133The code below uses `Headline{Lvl, Title, Children}`, `Paragraph{Children}`, `List{Kind, Items}`, `ListItem{Bullet, Children}`, `DescriptiveListItem{Term, Details}`, `Block{Name, Parameters, Children}`, `Example{Children}`, `HorizontalRule`, `Text{Content}`, `LineBreak`, `ExplicitLineBreak`, `Emphasis{Kind, Content}`, `RegularLink{Protocol, Description, URL}`, `Keyword`, and `org.String(nodes ...Node) string`. Adjust field names to what `go doc` prints.
2134
2135- [ ] **Step 2: Write the golden test**
2136
2137`internal/termtext/org_test.go`:
2138
2139```go
2140package termtext
2141
2142import (
2143 "os"
2144 "strings"
2145 "testing"
2146)
2147
2148func TestOrgGolden(t *testing.T) {
2149 src, err := os.ReadFile("testdata/doc.org")
2150 if err != nil {
2151 t.Fatal(err)
2152 }
2153 golden(t, "doc.org.plain.golden", Org(string(src), Options{Base: "https://forge.test"}))
2154 golden(t, "doc.org.60.golden", Org(string(src), Options{Width: 60, Base: "https://forge.test"}))
2155 golden(t, "doc.org.60color.golden", Org(string(src), Options{Width: 60, Color: true, Base: "https://forge.test"}))
2156}
2157
2158// #+INCLUDE reads nothing from the server's disk.
2159func TestOrgIncludeIsInert(t *testing.T) {
2160 got := Org("#+INCLUDE: \"/etc/passwd\"\n\ntext\n", Options{})
2161 if strings.Contains(got, "root:") {
2162 t.Fatalf("include read a file: %q", got)
2163 }
2164}
2165```
2166
2167`testdata/doc.org`:
2168
2169```org
2170#+TITLE: ignored keyword
2171
2172* A heading
2173A paragraph long enough to wrap at sixty columns, with *bold*, /italic/, _underline_, =verbatim=, ~code~, a [[https://example.com/page][link]], and a bare [[https://example.com]].
2174
2175- one
2176- two, which is long enough that its continuation line has to hang under the text rather than the bullet
2177 - nested
2178
21791. first
21802. second
2181
2182- term :: its description
2183
2184#+BEGIN_SRC go
2185func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") }
2186#+END_SRC
2187
2188#+BEGIN_QUOTE
2189quoted text
2190#+END_QUOTE
2191
2192-----
2193
2194| a | b |
2195|---+---|
2196| 1 | 2 |
2197```
2198
2199- [ ] **Step 3: Run and see it fail**
2200
2201Run: `go test ./internal/termtext -run 'TestOrg' -count=1`
2202Expected: FAIL to compile.
2203
2204- [ ] **Step 4: Implement `internal/termtext/org.go`** (replacing the `renderOrg` stub from Task 3.1)
2205
2206```go
2207package termtext
2208
2209import (
2210 "bytes"
2211 "errors"
2212 "io"
2213 "log"
2214 "strings"
2215
2216 "github.com/niklasfasching/go-org/org"
2217)
2218
2219func Org(src string, o Options) string {
2220 return renderOrg(src, &out{o: o})
2221}
2222
2223// renderOrg parses with the same restrictions as the web: no file is
2224// ever read (#+INCLUDE, #+SETUPFILE), and parse warnings go nowhere.
2225func renderOrg(src string, w *out) string {
2226 c := org.New()
2227 c.ReadFile = func(string) ([]byte, error) { return nil, errors.New("org: includes are disabled") }
2228 c.Log = log.New(io.Discard, "", 0)
2229 doc := c.Parse(bytes.NewReader([]byte(src)), "")
2230 r := orgRenderer{w: w}
2231 r.nodes(doc.Nodes, "", "")
2232 return w.String()
2233}
2234
2235type orgRenderer struct{ w *out }
2236
2237func (r orgRenderer) nodes(ns []org.Node, first, rest string) {
2238 p := first
2239 wrote := false
2240 for _, n := range ns {
2241 if skipOrg(n) {
2242 continue
2243 }
2244 if wrote {
2245 r.w.blank()
2246 }
2247 r.block(n, p, rest)
2248 p, wrote = rest, true
2249 }
2250}
2251
2252func skipOrg(n org.Node) bool {
2253 switch n.(type) {
2254 case org.Keyword, org.PropertyDrawer, org.Comment:
2255 return true
2256 }
2257 return false
2258}
2259
2260func (r orgRenderer) block(n org.Node, first, rest string) {
2261 switch n := n.(type) {
2262 case org.Headline:
2263 r.w.para(r.w.paint(sgrBold, r.inline(n.Title)), first, rest)
2264 if len(n.Children) > 0 {
2265 r.w.blank()
2266 r.nodes(n.Children, rest, rest)
2267 }
2268 case org.Paragraph:
2269 r.w.para(r.inline(n.Children), first, rest)
2270 case org.List:
2271 p := first
2272 for i, item := range n.Items {
2273 if i > 0 && n.Kind == "descriptive" {
2274 r.w.blank()
2275 }
2276 switch item := item.(type) {
2277 case org.ListItem:
2278 marker := "• "
2279 if n.Kind == "ordered" {
2280 marker = item.Bullet + " "
2281 }
2282 hang := rest + strings.Repeat(" ", cells(marker))
2283 r.nodes(item.Children, p+marker, hang)
2284 case org.DescriptiveListItem:
2285 term := r.w.paint(sgrBold, r.inline(item.Term))
2286 r.w.para(term, p, rest)
2287 r.nodes(item.Details, rest+" ", rest+" ")
2288 default:
2289 r.w.code(org.String(item), "", rest)
2290 }
2291 p = rest
2292 }
2293 case org.Block:
2294 switch strings.ToUpper(n.Name) {
2295 case "SRC":
2296 lang := ""
2297 if len(n.Parameters) > 0 {
2298 lang = n.Parameters[0]
2299 }
2300 r.w.code(org.String(n.Children...), lang, rest)
2301 case "QUOTE":
2302 bar := r.w.paint(sgrDim, "│ ")
2303 r.nodes(n.Children, first+bar, rest+bar)
2304 default:
2305 r.w.code(org.String(n.Children...), "", rest)
2306 }
2307 case org.Example:
2308 r.w.code(org.String(n.Children...), "", rest)
2309 case org.HorizontalRule:
2310 r.w.rule(first)
2311 default:
2312 r.w.code(org.String(n), "", rest)
2313 }
2314}
2315
2316func (r orgRenderer) inline(ns []org.Node) string {
2317 var b strings.Builder
2318 for _, n := range ns {
2319 switch n := n.(type) {
2320 case org.Text:
2321 b.WriteString(n.Content)
2322 case org.LineBreak:
2323 b.WriteString(" ")
2324 case org.ExplicitLineBreak:
2325 b.WriteString("\n")
2326 case org.Emphasis:
2327 s := r.inline(n.Content)
2328 switch n.Kind {
2329 case "*":
2330 s = r.w.paint(sgrBold, s)
2331 case "/", "_":
2332 s = r.w.paint(sgrUnderline, s)
2333 }
2334 b.WriteString(s)
2335 case org.RegularLink:
2336 b.WriteString(r.w.link(r.inline(n.Description), n.URL))
2337 default:
2338 b.WriteString(org.String(n))
2339 }
2340 }
2341 return b.String()
2342}
2343```
2344
2345Blank lines between list items: go-org separates items with no blank line in a tight list; if the goldens show blank lines between `• one` and `• two`, remove the `blank()` in `nodes` for list children by rendering item children with a local loop that skips it.
2346
2347- [ ] **Step 5: Generate the goldens and read them**
2348
2349Run: `go test ./internal/termtext -run TestOrgGolden -update -count=1`. Check by eye as in Task 3.1; `#+TITLE` must not appear. Then:
2350
2351Run: `go test ./internal/termtext -count=1`
2352Expected: PASS.
2353
2354- [ ] **Step 6: Commit**
2355
2356```bash
2357git add internal/termtext
2358git commit -m "termtext: org for a terminal" -m "Ref #254"
2359```
2360
2361### Task 3.3: `view`, and `issue show` on it
2362
2363**Files:**
2364- Create: `internal/control/view.go`
2365- Modify: `internal/control/issue.go` (`runIssueShow` plain formatter, lines ~231-245)
2366- Test: `internal/control/view_test.go`
2367
2368**Interfaces:**
2369- Consumes: `Term`, `stamp`, `parseStamp`, `cells`, `pad`, `termtext.Render`, `termtext.Inline`.
2370- Produces:
2371 - `func (c *Ctx) when(s string) string` — `2006-01-02 15:04 UTC` at a terminal, `stamp(s)` plain.
2372 - `func (c *Ctx) view(w io.Writer) *view`
2373 - `func (v *view) title(ref, title, state string)`
2374 - `func (v *view) fields(kv ...string)` — alternating key, value; empty values skipped.
2375 - `func (v *view) body(src, format string)`
2376 - `func (v *view) event(text, format, ts string)`
2377 - `func (v *view) comment(author, ts, body, format string)`
2378 - `func (c *Ctx) siteURL(parts ...string) string` — `server.site_url` joined with `/`.
2379
2380- [ ] **Step 1: Write the failing test**
2381
2382```go
2383package control
2384
2385import (
2386 "bytes"
2387 "strings"
2388 "testing"
2389
2390 "gitbay.org/gitbay/internal/protocol"
2391 "gitbay.org/gitbay/internal/store"
2392)
2393
2394func showIssue(t *testing.T, term Term) string {
2395 t.Helper()
2396 st, repo, uid := newQueueTestRepo(t)
2397 n, err := st.CreateIssue(repo.ID, uid, "A title", "Body with a [link](/x/y).", "md")
2398 if err != nil {
2399 t.Fatal(err)
2400 }
2401 if _, err := st.AddIssueComment(issueIDByNumber(t, st, repo.ID, n), 0, "referenced in commit [abc1234567](/o/r/commit/abc) by [alice](/alice)", "md"); err != nil {
2402 t.Fatal(err)
2403 }
2404 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
2405 c.Cfg.Server.SiteURL = "https://forge.test"
2406 c.Term = term
2407 if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK {
2408 t.Fatalf("exit %d: %s", code, errOut)
2409 }
2410 return c.Stdout.(*bytes.Buffer).String()
2411}
2412
2413func TestIssueShowPlain(t *testing.T) {
2414 out := showIssue(t, Term{})
2415 for _, want := range []string{
2416 "#1 A title open\n",
2417 " author alice, ",
2418 " url https://forge.test/",
2419 "Body with a link (https://forge.test/x/y).",
2420 " · referenced in commit abc1234567 by alice ",
2421 } {
2422 if !strings.Contains(out, want) {
2423 t.Errorf("missing %q in:\n%s", want, out)
2424 }
2425 }
2426 if strings.Contains(out, "\x1b") || strings.Contains(out, "](") {
2427 t.Errorf("markup or SGR in plain view:\n%s", out)
2428 }
2429}
2430
2431func TestIssueShowTerminal(t *testing.T) {
2432 out := showIssue(t, Term{Cols: 60, Color: true})
2433 if !strings.Contains(out, sgrGreen+"open"+sgrReset) {
2434 t.Errorf("state not coloured:\n%s", out)
2435 }
2436 if !strings.Contains(out, " UTC") {
2437 t.Errorf("no web-format timestamp:\n%s", out)
2438 }
2439 for _, line := range strings.Split(stripSGR(out), "\n") {
2440 if cells(line) > 60 {
2441 t.Errorf("line over 60 cells: %q", line)
2442 }
2443 }
2444}
2445```
2446
2447Before writing this, read `internal/store/issues.go` for the real names and signatures of the create-issue and add-comment functions and for how a system comment is stored (author `system`, or an author id of 0, or a kind column), and adjust `showIssue` to match. Write `issueIDByNumber` only if no lookup exists (`st.IssueByNumber(repoID, n)` likely does).
2448
2449- [ ] **Step 2: Run and see it fail**
2450
2451Run: `go test ./internal/control -run TestIssueShow -count=1`
2452Expected: FAIL.
2453
2454- [ ] **Step 3: Implement `internal/control/view.go`**
2455
2456```go
2457package control
2458
2459import (
2460 "io"
2461 "strings"
2462
2463 "gitbay.org/gitbay/internal/termtext"
2464)
2465
2466// when is a stored timestamp in a view: the web's format at a
2467// terminal, RFC3339 to the second when plain.
2468func (c *Ctx) when(s string) string {
2469 if c.Term.Cols == 0 {
2470 return stamp(s)
2471 }
2472 t, ok := parseStamp(s)
2473 if !ok {
2474 return s
2475 }
2476 return t.Format("2006-01-02 15:04 UTC")
2477}
2478
2479// siteURL is the instance's address with path segments appended.
2480func (c *Ctx) siteURL(parts ...string) string {
2481 return strings.TrimRight(c.Cfg.Server.SiteURL, "/") + "/" + strings.Join(parts, "/")
2482}
2483
2484// view lays out a show: a title line, aligned fields, a body, events,
2485// comments. Plain output is the same lines without colour or wrapping.
2486type view struct {
2487 c *Ctx
2488 w io.Writer
2489}
2490
2491func (c *Ctx) view(w io.Writer) *view { return &view{c: c, w: w} }
2492
2493func (v *view) opts() termtext.Options {
2494 return termtext.Options{Width: max(0, v.c.Term.Cols-2), Color: v.c.Term.Color, Base: v.c.Cfg.Server.SiteURL}
2495}
2496
2497func (v *view) title(ref, title, state string) {
2498 t := v.c.Term
2499 io.WriteString(v.w, ref+" "+t.paint(sgrBold, title)+" "+t.paint(stateColor(state), state)+"\n")
2500}
2501
2502// fields prints key/value pairs aligned on the widest key, skipping
2503// empty values.
2504func (v *view) fields(kv ...string) {
2505 wide := 0
2506 for i := 0; i+1 < len(kv); i += 2 {
2507 if kv[i+1] != "" {
2508 wide = max(wide, cells(kv[i]))
2509 }
2510 }
2511 io.WriteString(v.w, "\n")
2512 for i := 0; i+1 < len(kv); i += 2 {
2513 if kv[i+1] == "" {
2514 continue
2515 }
2516 io.WriteString(v.w, " "+v.c.Term.paint(sgrDim, pad(kv[i], wide))+" "+kv[i+1]+"\n")
2517 }
2518}
2519
2520func (v *view) body(src, format string) {
2521 if strings.TrimSpace(src) == "" {
2522 return
2523 }
2524 io.WriteString(v.w, "\n")
2525 for _, line := range strings.Split(strings.TrimRight(termtext.Render(src, format, v.opts()), "\n"), "\n") {
2526 if line == "" {
2527 io.WriteString(v.w, "\n")
2528 continue
2529 }
2530 io.WriteString(v.w, " "+line+"\n")
2531 }
2532}
2533
2534// event is one line for a system comment: its text without link
2535// targets, the time at the right edge at a terminal.
2536func (v *view) event(text, format, ts string) {
2537 line := "· " + termtext.Inline(text, format)
2538 when := v.c.when(ts)
2539 if cols := v.c.Term.Cols; cols > 0 {
2540 room := cols - 2 - 2 - cells(when)
2541 line = pad(clip(line, room), room)
2542 }
2543 io.WriteString(v.w, " "+v.c.Term.paint(sgrDim, line+" "+when)+"\n")
2544}
2545
2546func (v *view) comment(author, ts, body, format string) {
2547 head := "── " + author + ", " + v.c.when(ts) + " "
2548 if cols := v.c.Term.Cols; cols > 0 {
2549 head += strings.Repeat("─", max(0, cols-cells(head)))
2550 }
2551 io.WriteString(v.w, "\n"+v.c.Term.paint(sgrDim, head)+"\n")
2552 v.body(body, format)
2553}
2554```
2555
2556`issue show`'s formatter:
2557
2558```go
2559 return c.emit(d, func(w io.Writer) {
2560 v := c.view(w)
2561 v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State)
2562 v.fields(
2563 "author", d.Author+", "+c.when(d.CreatedAt),
2564 "assignees", strings.Join(d.Assignees, ", "),
2565 "labels", strings.Join(d.Labels, ", "),
2566 "milestone", d.Milestone,
2567 "url", c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10)),
2568 )
2569 v.body(d.Body, d.BodyFormat)
2570 events := false
2571 for _, cm := range cs {
2572 if cm.Author != "system" {
2573 continue
2574 }
2575 if !events {
2576 io.WriteString(w, "\n")
2577 events = true
2578 }
2579 v.event(cm.Body, cm.BodyFormat, cm.CreatedAt)
2580 }
2581 for _, cm := range cs {
2582 if cm.Author == "system" {
2583 continue
2584 }
2585 v.comment(cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat)
2586 }
2587 })
2588```
2589
2590Remove the `_ = repo` line. Use the system-comment test that Step 1's store reading found in place of `cm.Author == "system"` if it differs. Check the web issue URL segment (`issues`) against `internal/httpd/routes.go`.
2591
2592In plain mode `event` prints ` · <text> <stamp>` with two spaces between, which the test expects.
2593
2594- [ ] **Step 4: Run**
2595
2596Run: `go build ./... && go test ./internal/control -count=1`
2597Expected: PASS. Existing tests asserting the old `issue show` text (`#1 A title [open] by alice`, `--- alice at`) change to the new layout; e2e tests grepping `issue show` output: `grep -rn '"issue", "show"' e2e/` and update their assertions to substrings the new layout prints.
2598
2599- [ ] **Step 5: Commit**
2600
2601```bash
2602git add internal/control e2e
2603git commit -m "control: view layout; issue show on it" -m "Ref #254"
2604```
2605
2606### Task 3.4: every other show on `view`
2607
2608**Files:**
2609- Modify: `internal/control/mr.go` (`mr show`), `build.go` (`build show`), `release.go` (`release show`), `snippet.go` (`snippet show`), `repo.go` (`repo show`, `repo settings show`), `org.go` (`org show`), `teams.go` (`org team show`), `profile.go` (`profile show`), `admin.go` (`admin user show`), `wiki.go` (`wiki show`), `notifications.go` (`notifications settings show`), `theme.go` (`web theme show`)
2610
2611- [ ] **Step 1: Convert each** with this mapping, reading each formatter first:
2612 - The first line becomes `v.title(<identifier>, <name or title>, <state or visibility>)`. A show with no state passes `""` (the trailing space is harmless; `title` may skip it when empty — make it do so).
2613 - Every `key: value` line becomes a pair in one `v.fields(...)` call; timestamps through `c.when`; add `"url", c.siteURL(...)` where the web has a page for the object.
2614 - A body (MR description, release notes, snippet file text is content, not markup: print it verbatim after a blank line; wiki page source → `v.body(src, format)` with format from the file extension, `org` for `.org`).
2615 - Comments and events on `mr show` exactly as on `issue show`; reviews and checks become `fields` rows (`check ci/build success, 22s`) or a `table` after the fields when there is more than one.
2616 - Single-value shows (`web theme show`, `notifications settings show`) keep their one-line output if they print one line today.
2617
2618- [ ] **Step 2: Remove the Part 2 `rawOutput` entries** added "until the view layout" in `e2e/readonly_test.go`.
2619
2620- [ ] **Step 3: Run**
2621
2622Run: `go test ./internal/control -count=1 && go test ./e2e -run TestReadOnlyCommandsWriteNothing -count=1`
2623Expected: PASS. A show that exceeds 60 columns outside a code block is a layout bug; fix it in the formatter.
2624
2625- [ ] **Step 4: Commit**
2626
2627```bash
2628git add internal/control e2e
2629git commit -m "control: every show on the view layout" -m "Ref #254"
2630```
2631
2632### Task 3.5: the pager
2633
2634**Files:**
2635- Modify: `cmd/gitbay/ssh.go` (`runSSH` → `runSSHPaged`)
2636- Modify: `cmd/gitbay/main.go` (`runPass`)
2637- Test: `cmd/gitbay/term_test.go`
2638
2639**Interfaces:**
2640- Produces:
2641 - `func pagerArgv(env func(string) (string, bool)) []string` — nil means no pager.
2642 - `func pages(server, args []string) bool`
2643 - `func runSSHPaged(t target, serverArgv []string, stdin io.Reader, page bool) int`; `runSSH(t, argv, stdin)` becomes `runSSHPaged(t, argv, stdin, false)`.
2644
2645- [ ] **Step 1: Write the failing test**
2646
2647```go
2648func TestPagerArgv(t *testing.T) {
2649 env := func(m map[string]string) func(string) (string, bool) {
2650 return func(k string) (string, bool) { v, ok := m[k]; return v, ok }
2651 }
2652 cases := []struct {
2653 env map[string]string
2654 want string
2655 }{
2656 {nil, "less"},
2657 {map[string]string{"PAGER": "more -s"}, "more -s"},
2658 {map[string]string{"PAGER": "more", "GITBAY_PAGER": "bat -p"}, "bat -p"},
2659 {map[string]string{"PAGER": "more", "GITBAY_PAGER": ""}, ""},
2660 }
2661 for _, c := range cases {
2662 if got := strings.Join(pagerArgv(env(c.env)), " "); got != c.want {
2663 t.Errorf("%v: got %q want %q", c.env, got, c.want)
2664 }
2665 }
2666}
2667
2668func TestPages(t *testing.T) {
2669 yes := [][]string{{"issue", "show"}, {"mr", "diff"}, {"build", "log"}, {"repo", "log"}}
2670 for _, s := range yes {
2671 if !pages(s, nil) {
2672 t.Errorf("%v should page", s)
2673 }
2674 }
2675 if pages([]string{"build", "log"}, []string{"--follow"}) {
2676 t.Error("build log --follow must not page")
2677 }
2678 if pages([]string{"issue", "show"}, []string{"--json"}) {
2679 t.Error("--json must not page")
2680 }
2681 if pages([]string{"issue", "list"}, nil) {
2682 t.Error("list must not page")
2683 }
2684}
2685```
2686
2687(add `strings` to the test imports.)
2688
2689- [ ] **Step 2: Run and see it fail**
2690
2691Run: `go test ./cmd/gitbay -run 'TestPagerArgv|TestPages' -count=1`
2692Expected: FAIL to compile.
2693
2694- [ ] **Step 3: Implement**
2695
2696```go
2697// pagerArgv is the pager to run long output through: GITBAY_PAGER,
2698// then PAGER, then less. An empty GITBAY_PAGER turns paging off.
2699func pagerArgv(env func(string) (string, bool)) []string {
2700 if v, ok := env("GITBAY_PAGER"); ok {
2701 return strings.Fields(v)
2702 }
2703 if v, ok := env("PAGER"); ok && v != "" {
2704 return strings.Fields(v)
2705 }
2706 return []string{"less"}
2707}
2708
2709// pages reports whether a command's output goes through the pager at a
2710// terminal: views, diffs and logs, never a follow or JSON.
2711func pages(server, args []string) bool {
2712 if len(server) == 0 || slices.Contains(args, "--json") || slices.Contains(args, "--follow") {
2713 return false
2714 }
2715 switch server[len(server)-1] {
2716 case "show", "diff", "log":
2717 return true
2718 }
2719 return false
2720}
2721```
2722
2723In `runSSHPaged`, after `cmd` is built and before `cmd.Run()`:
2724
2725```go
2726 var pager *exec.Cmd
2727 var pw io.WriteCloser
2728 if page && term.IsTerminal(int(os.Stdout.Fd())) {
2729 if argv := pagerArgv(os.LookupEnv); len(argv) > 0 {
2730 pager = exec.Command(toolpath.Look(argv[0]), argv[1:]...)
2731 pager.Stdout, pager.Stderr = os.Stdout, os.Stderr
2732 if _, ok := os.LookupEnv("LESS"); !ok {
2733 pager.Env = append(os.Environ(), "LESS=FRX")
2734 }
2735 if w, err := pager.StdinPipe(); err == nil && pager.Start() == nil {
2736 pw = w
2737 cmd.Stdout = pw
2738 } else {
2739 pager = nil
2740 }
2741 }
2742 }
2743 err := cmd.Run()
2744 if pager != nil {
2745 pw.Close()
2746 pager.Wait()
2747 }
2748```
2749
2750The `GITBAY_TERM` value is computed from `os.Stdout` before this block, so the server still sees the terminal. In `runPass`, the final call becomes `runSSHPaged(t, append(o.server, args...), stdin, pages(o.server, args))`.
2751
2752- [ ] **Step 4: Run**
2753
2754Run: `go build ./... && go vet ./cmd/gitbay && go test ./cmd/gitbay -count=1`
2755Expected: PASS. At a terminal against a local instance: `gitbay issue show <n>` opens `less` only when longer than the screen; `GITBAY_PAGER= gitbay issue show <n>` never does; `gitbay issue show <n> | cat` never does.
2756
2757- [ ] **Step 5: Commit and open MR 3**
2758
2759```bash
2760git add cmd/gitbay
2761git commit -m "gitbay: page views, diffs and logs at a terminal" -m "Ref #254"
2762git push -u origin cli-output-views
2763gitbay mr create --source cli-output-views --target main --title "CLI output: show views and the pager"
2764```
2765
2766---
2767
2768# Part 4: help (branch `cli-output-help`)
2769
2770### Task 4.1: `Flags` and `Examples` on `Command`, and the registry test
2771
2772**Files:**
2773- Modify: `internal/control/control.go` (`Flag`, `Command` fields)
2774- Create: `internal/control/help_test.go`
2775
2776**Interfaces:**
2777- Produces:
2778
2779```go
2780// Flag is one flag in a command's help.
2781type Flag struct {
2782 Name string // "--state"
2783 Arg string // "open|closed|all"; empty for a switch
2784 Desc string // what it does, lower case, no full stop
2785 Default string // empty for none
2786}
2787```
2788
2789`Command` gains `Flags []Flag` and `Examples []string` (each the full argv after the program, repository named).
2790
2791- [ ] **Step 1: Write the test**
2792
2793```go
2794package control
2795
2796import (
2797 "regexp"
2798 "slices"
2799 "strings"
2800 "testing"
2801
2802 "gitbay.org/gitbay/internal/protocol"
2803)
2804
2805var usageFlag = regexp.MustCompile(`--[a-z][a-z0-9-]*`)
2806
2807// Help is written once, in the registry. Every flag in a usage line has
2808// a description, every description names a flag in the usage line, and
2809// every command has an example that runs it.
2810func TestHelpIsComplete(t *testing.T) {
2811 for _, cmd := range Commands() {
2812 path := strings.Join(cmd.Path, " ")
2813 inUsage := map[string]bool{}
2814 for _, f := range usageFlag.FindAllString(cmd.Usage, -1) {
2815 if f != "--json" {
2816 inUsage[f] = true
2817 }
2818 }
2819 described := map[string]bool{}
2820 for _, f := range cmd.Flags {
2821 described[f.Name] = true
2822 if f.Desc == "" {
2823 t.Errorf("%s: %s has no description", path, f.Name)
2824 }
2825 if !inUsage[f.Name] {
2826 t.Errorf("%s: %s is described but not in the usage", path, f.Name)
2827 }
2828 }
2829 for f := range inUsage {
2830 if !described[f] {
2831 t.Errorf("%s: %s is in the usage with no description", path, f)
2832 }
2833 }
2834 if len(cmd.Examples) == 0 {
2835 t.Errorf("%s: no example", path)
2836 }
2837 for _, ex := range cmd.Examples {
2838 argv, err := protocol.Tokenize(ex)
2839 if err != nil {
2840 t.Errorf("%s: example %q: %v", path, ex, err)
2841 continue
2842 }
2843 got, _, ok := Lookup(argv)
2844 if !ok || !slices.Equal(got.Path, cmd.Path) {
2845 t.Errorf("%s: example %q runs %v", path, ex, got.Path)
2846 }
2847 }
2848 }
2849}
2850```
2851
2852- [ ] **Step 2: Add the type and fields** to `control.go`; run `go test ./internal/control -run TestHelpIsComplete -count=1`. Expected: FAIL listing every command. Keep the list: Tasks 4.2 and 4.3 fill it.
2853
2854- [ ] **Step 3: Commit** (the test fails; commit it with the next task instead if CI must stay green per commit — it must, since `ff` merges every commit. Do not commit yet; carry it into Task 4.2's commit.)
2855
2856### Task 4.2: flag descriptions and examples, work nouns
2857
2858**Files:**
2859- Modify: registrations in `internal/control/issue.go`, `mr.go`, `build.go`, `release.go`, `milestone.go`, `label.go`, `orglabel.go`, `search.go`, `explore.go`, `dashboard.go`, `thread.go`, `diffcomment.go`, `status.go`, `wiki.go`, `snippet.go`, `read.go`, `repo.go`, `repo*.go`
2860
2861**Rules for the text:**
2862- `Desc`: lower case, no full stop, says what the flag changes, under 50 characters: `which issues`, `only issues carrying this label`, `rows per page`, `continue from the previous page`, `read the body from stdin`.
2863- `Arg`: the placeholder exactly as the usage line spells it (`<l>`, `open|closed|all`, `-`).
2864- `Default`: only when the command applies one (`open`, `30`); read the `Run` function to find it.
2865- Examples: the most common real use first, a second only when a flag combination is worth showing. Repository `krz/gitbay`, user `cmc`, numbers that look real. Stdin examples end in `--file - < notes.md`.
2866
2867Example, for `issue list`:
2868
2869```go
2870register(Command{Path: []string{"issue", "list"},
2871 Summary: "list issues",
2872 Usage: "issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>] [--author <user>] [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>]",
2873 Flags: []Flag{
2874 {"--state", "open|closed|all", "which issues", "open"},
2875 {"--label", "<l>", "only issues carrying this label", ""},
2876 {"--assignee", "<user>", "only issues assigned to this user", ""},
2877 {"--author", "<user>", "only issues opened by this user", ""},
2878 {"--milestone", "<title>|none", "only issues in this milestone, or in none", ""},
2879 {"--search", "<text>", "match title and body", ""},
2880 {"--limit", "<n>", "rows per page", ""},
2881 {"--cursor", "<c>", "continue from the previous page", ""},
2882 },
2883 Examples: []string{
2884 "issue list krz/gitbay --label bug --state all",
2885 "issue list krz/gitbay --assignee cmc",
2886 },
2887 ReadOnly: true, Run: runIssueList})
2888```
2889
2890(keep the registration's existing field layout and values; only `Flags` and `Examples` are new.) `--limit`/`--cursor` descriptions are the same on every paged command.
2891
2892- [ ] **Step 1: Fill** every command in the files above.
2893- [ ] **Step 2: Run** `go test ./internal/control -run TestHelpIsComplete -count=1`. Expected: failures only for commands in files Task 4.3 covers.
2894- [ ] **Step 3: No commit yet** (the test still fails).
2895
2896### Task 4.3: flag descriptions and examples, everything else
2897
2898**Files:**
2899- Modify: every remaining registration in `internal/control/*.go` (account, keys, tokens, orgs, teams, admin, runners, webhooks, mirrors, notifications, web, profile, register, import, audit, help).
2900
2901- [ ] **Step 1: Fill** with the rules in Task 4.2. Admin commands' examples use a made-up user (`alice`). Secrets: examples read them from stdin, never argv (`repo secret set krz/gitbay DEPLOY_KEY --file - < key`).
2902- [ ] **Step 2: Run** `go test ./internal/control -count=1`. Expected: PASS.
2903- [ ] **Step 3: Commit** (Tasks 4.1–4.3 together)
2904
2905```bash
2906git add internal/control
2907git commit -m "control: every command's flags described, with examples" -m "Ref #254"
2908```
2909
2910### Task 4.4: help layouts
2911
2912**Files:**
2913- Create: `internal/control/help.go` (move `runHelp`, `helpEntry` and the `help` registration out of `control.go`)
2914- Test: `internal/control/help_test.go`
2915
2916**Interfaces:**
2917- Consumes: `Flag`, `Command.Flags`, `Command.Examples`, `Term`, `hostOf`.
2918- Produces:
2919 - `var nounSummaries = map[string]string{...}` — one line per first path element.
2920 - `func NounSummaries() map[string]string`
2921 - `helpEntry` gains `Flags []Flag \`json:"flags,omitempty"\`` and `Examples []string \`json:"examples,omitempty"\``; `Flag` gets JSON tags `name`, `arg`, `desc`, `default` (omitempty on the last three).
2922
2923- [ ] **Step 1: Write the failing tests**
2924
2925```go
2926func helpOut(t *testing.T, term Term, prefix ...string) string {
2927 t.Helper()
2928 var out, errOut bytes.Buffer
2929 c := &Ctx{Stdout: &out, Stderr: &errOut, Term: term}
2930 c.Cfg.Server.SiteURL = "https://forge.test"
2931 if code := Dispatch(c, append([]string{"help"}, prefix...)); code != protocol.ExitOK {
2932 t.Fatalf("help %v: exit %d: %s", prefix, code, errOut.String())
2933 }
2934 return out.String()
2935}
2936
2937func TestHelpVerb(t *testing.T) {
2938 out := helpOut(t, Term{Cols: 100}, "issue", "list")
2939 for _, want := range []string{
2940 "list issues\n",
2941 "USAGE\n gitbay issue list [<owner/name>] [flags]\n",
2942 "FLAGS\n",
2943 " --state open|closed|all",
2944 "which issues (default open)\n",
2945 " --json",
2946 "EXAMPLES\n gitbay issue list krz/gitbay --label bug --state all\n",
2947 } {
2948 if !strings.Contains(out, want) {
2949 t.Errorf("missing %q in:\n%s", want, out)
2950 }
2951 }
2952 plain := helpOut(t, Term{}, "issue", "list")
2953 if !strings.Contains(plain, " ssh git@forge.test issue list krz/gitbay --label bug --state all\n") {
2954 t.Errorf("plain examples not ssh:\n%s", plain)
2955 }
2956 if !strings.Contains(plain, "USAGE\n ssh git@forge.test issue list <owner/name> [flags]\n") {
2957 t.Errorf("plain usage:\n%s", plain)
2958 }
2959}
2960
2961func TestHelpNoun(t *testing.T) {
2962 out := helpOut(t, Term{Cols: 100}, "issue")
2963 for _, want := range []string{"issues\n", "READ\n", "WRITE\n", " list ", " create ", "gitbay issue <verb> --help for flags.\n"} {
2964 if !strings.Contains(out, want) {
2965 t.Errorf("missing %q in:\n%s", want, out)
2966 }
2967 }
2968 if strings.Index(out, " list ") > strings.Index(out, "WRITE") {
2969 t.Errorf("list is not under READ:\n%s", out)
2970 }
2971}
2972
2973func TestEveryNounHasASummary(t *testing.T) {
2974 for _, cmd := range Commands() {
2975 if nounSummaries[cmd.Path[0]] == "" {
2976 t.Errorf("no noun summary for %q", cmd.Path[0])
2977 }
2978 }
2979}
2980```
2981
2982- [ ] **Step 2: Run and see them fail**
2983
2984Run: `go test ./internal/control -run 'TestHelpVerb|TestHelpNoun|TestEveryNounHasASummary' -count=1`
2985Expected: FAIL.
2986
2987- [ ] **Step 3: Implement `help.go`**
2988
2989`nounSummaries`: one entry per distinct `cmd.Path[0]` in the registry (list them with `go test -run TestEveryNounHasASummary` output). Reuse the CLI's `group(...)` short texts from `cmd/gitbay/main.go` where the noun matches, so the two agree.
2990
2991`runHelp`:
2992
2993```go
2994func runHelp(c *Ctx, args []string) int {
2995 prefix := joinPath(args)
2996 var matched []Command
2997 for _, cmd := range registry {
2998 p := joinPath(cmd.Path)
2999 if prefix == "" || p == prefix || strings.HasPrefix(p, prefix+" ") {
3000 matched = append(matched, cmd)
3001 }
3002 }
3003 if len(matched) == 0 {
3004 return c.fail(protocol.ExitNotFound, "no command matches %q; try: help", prefix)
3005 }
3006 slices.SortFunc(matched, func(a, b Command) int { return strings.Compare(joinPath(a.Path), joinPath(b.Path)) })
3007 entries := make([]helpEntry, len(matched))
3008 for i, cmd := range matched {
3009 entries[i] = helpEntry{Path: joinPath(cmd.Path), Summary: cmd.Summary, Usage: cmd.Usage, Flags: cmd.Flags, Examples: cmd.Examples}
3010 }
3011 return c.emit(entries, func(w io.Writer) {
3012 switch {
3013 case prefix == "":
3014 for _, e := range entries {
3015 fmt.Fprintf(w, "%-24s %s\n", e.Path, e.Summary)
3016 }
3017 case joinPath(matched[0].Path) == prefix:
3018 c.helpVerb(w, matched[0], matched[1:])
3019 default:
3020 c.helpNoun(w, prefix, matched)
3021 }
3022 })
3023}
3024```
3025
3026(`matched[0]` is the exact match when one exists, because it sorts first.)
3027
3028```go
3029// program is how help spells the command it documents: the CLI at a
3030// terminal (only the CLI sends GITBAY_TERM), ssh otherwise.
3031func (c *Ctx) program() string {
3032 if c.Term.Cols > 0 {
3033 return "gitbay"
3034 }
3035 return "ssh git@" + hostOf(c.Cfg.Server.SiteURL)
3036}
3037
3038func (c *Ctx) heading(w io.Writer, s string) {
3039 fmt.Fprintln(w, c.Term.paint(sgrBold, s))
3040}
3041
3042func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) {
3043 fmt.Fprintln(w, cmd.Summary)
3044 fmt.Fprintln(w)
3045 c.heading(w, "USAGE")
3046 shape := cmd.Usage
3047 if i := strings.Index(shape, " [--"); i >= 0 {
3048 shape = shape[:i]
3049 } else if i := strings.Index(shape, " --"); i >= 0 {
3050 shape = shape[:i]
3051 }
3052 if c.Term.Cols > 0 {
3053 shape = strings.Replace(shape, "<owner/name>", "[<owner/name>]", 1)
3054 }
3055 if len(cmd.Flags) > 0 {
3056 shape += " [flags]"
3057 }
3058 fmt.Fprintf(w, " %s %s\n", c.program(), shape)
3059 fmt.Fprintln(w)
3060 c.heading(w, "FLAGS")
3061 rows := make([][2]string, 0, len(cmd.Flags)+1)
3062 for _, f := range cmd.Flags {
3063 name := f.Name
3064 if f.Arg != "" {
3065 name += " " + f.Arg
3066 }
3067 desc := f.Desc
3068 if f.Default != "" {
3069 desc += " (default " + f.Default + ")"
3070 }
3071 rows = append(rows, [2]string{name, desc})
3072 }
3073 rows = append(rows, [2]string{"--json", "machine-readable output"})
3074 wide := 0
3075 for _, r := range rows {
3076 wide = max(wide, cells(r[0]))
3077 }
3078 for _, r := range rows {
3079 fmt.Fprintf(w, " %s %s\n", pad(r[0], wide), r[1])
3080 }
3081 if len(cmd.Examples) > 0 {
3082 fmt.Fprintln(w)
3083 c.heading(w, "EXAMPLES")
3084 for _, ex := range cmd.Examples {
3085 fmt.Fprintf(w, " %s %s\n", c.program(), ex)
3086 }
3087 }
3088 if len(below) > 0 {
3089 fmt.Fprintln(w)
3090 c.heading(w, "SEE ALSO")
3091 for _, b := range below {
3092 fmt.Fprintf(w, " %s %s\n", c.program(), joinPath(b.Path))
3093 }
3094 }
3095}
3096
3097func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command) {
3098 head := nounSummaries[strings.Fields(prefix)[0]]
3099 fmt.Fprintln(w, head)
3100 fmt.Fprintln(w)
3101 c.heading(w, "USAGE")
3102 fmt.Fprintf(w, " %s %s <verb> ...\n", c.program(), prefix)
3103 wide := 0
3104 for _, cmd := range cmds {
3105 wide = max(wide, cells(strings.TrimPrefix(joinPath(cmd.Path), prefix+" ")))
3106 }
3107 for _, section := range []struct {
3108 title string
3109 read bool
3110 }{{"READ", true}, {"WRITE", false}} {
3111 first := true
3112 for _, cmd := range cmds {
3113 if cmd.ReadOnly != section.read {
3114 continue
3115 }
3116 if first {
3117 fmt.Fprintln(w)
3118 c.heading(w, section.title)
3119 first = false
3120 }
3121 verb := strings.TrimPrefix(joinPath(cmd.Path), prefix+" ")
3122 fmt.Fprintf(w, " %s %s\n", pad(verb, wide), cmd.Summary)
3123 }
3124 }
3125 fmt.Fprintln(w)
3126 fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), prefix)
3127}
3128```
3129
3130`TestHelpVerb` expects the plain USAGE with `ssh git@forge.test`; confirm `hostOf("https://forge.test")` returns `forge.test`.
3131
3132- [ ] **Step 4: Run**
3133
3134Run: `go test ./internal/control -count=1`
3135Expected: PASS. Any existing test of `help` plain output (grep `"help"` in `internal/control/*_test.go` and `e2e/`) moves to the new layout; `help --json` consumers are unaffected except for the two added fields.
3136
3137- [ ] **Step 5: Commit**
3138
3139```bash
3140git add internal/control
3141git commit -m "help: noun and verb layouts, flags and examples from the registry" -m "Ref #254"
3142```
3143
3144### Task 4.5: the CLI's summaries come from the registry; grouped root help
3145
3146**Files:**
3147- Create: `cmd/gitbay/summaries_gen.go` (generated), `cmd/gitbay/summaries_test.go`
3148- Modify: `cmd/gitbay/main.go` (`pass` loses its `short` parameter; `group` checked against `NounSummaries`; root help function)
3149
3150**Interfaces:**
3151- Consumes: `control.Commands()`, `control.NounSummaries()`.
3152- Produces: `var summaries map[string]string` (package `main`, generated), `var rootSections []rootSection`.
3153
3154- [ ] **Step 1: Write the test**
3155
3156`cmd/gitbay/summaries_test.go`:
3157
3158```go
3159package main
3160
3161import (
3162 "flag"
3163 "fmt"
3164 "os"
3165 "slices"
3166 "strings"
3167 "testing"
3168
3169 "gitbay.org/gitbay/internal/control"
3170)
3171
3172var updateSummaries = flag.Bool("update", false, "rewrite summaries_gen.go")
3173
3174// summaries_gen.go is the registry's one-line summaries, so the CLI's
3175// command list and completions say what help says.
3176func TestSummariesAreCurrent(t *testing.T) {
3177 var b strings.Builder
3178 b.WriteString("// Code generated by TestSummariesAreCurrent -update; DO NOT EDIT.\n\npackage main\n\nvar summaries = map[string]string{\n")
3179 var lines []string
3180 for _, cmd := range control.Commands() {
3181 lines = append(lines, fmt.Sprintf("\t%q: %q,\n", strings.Join(cmd.Path, " "), cmd.Summary))
3182 }
3183 slices.Sort(lines)
3184 b.WriteString(strings.Join(lines, ""))
3185 b.WriteString("}\n")
3186 if *updateSummaries {
3187 os.WriteFile("summaries_gen.go", []byte(b.String()), 0o644)
3188 }
3189 got, _ := os.ReadFile("summaries_gen.go")
3190 if string(got) != b.String() {
3191 t.Fatal("summaries_gen.go is stale: go test ./cmd/gitbay -run TestSummariesAreCurrent -update")
3192 }
3193}
3194
3195func TestRootSectionsCoverEveryCommand(t *testing.T) {
3196 seen := map[string]int{}
3197 for _, s := range rootSections {
3198 for _, n := range s.names {
3199 seen[n]++
3200 }
3201 }
3202 for _, c := range newRoot().Commands() {
3203 name := c.Name()
3204 if name == "help" || name == "completion" {
3205 continue
3206 }
3207 if seen[name] != 1 {
3208 t.Errorf("%s is in %d root sections", name, seen[name])
3209 }
3210 }
3211}
3212
3213func TestGroupsSayWhatTheServerSays(t *testing.T) {
3214 nouns := control.NounSummaries()
3215 for _, c := range newRoot().Commands() {
3216 if s, ok := nouns[c.Name()]; ok && c.Short != s {
3217 t.Errorf("%s: CLI says %q, server says %q", c.Name(), c.Short, s)
3218 }
3219 }
3220}
3221```
3222
3223- [ ] **Step 2: Generate and wire**
3224
3225Run: `go test ./cmd/gitbay -run TestSummariesAreCurrent -update -count=1` (creates the file; the test compiles only after Step 3, so first create an empty `summaries_gen.go` with `package main\n\nvar summaries = map[string]string{}\n`).
3226
3227Change `pass`:
3228
3229```go
3230func pass(use string, o passOpts) *cobra.Command {
3231 return &cobra.Command{
3232 Use: use,
3233 Short: summaries[strings.Join(o.server, " ")],
3234```
3235
3236and drop the second argument from every `pass(` call in `main.go`:
3237
3238```bash
3239sed -i '' -E 's/pass\(("[^"]*"), "([^"\\]|\\.)*", /pass(\1, /' cmd/gitbay/main.go
3240```
3241
3242then `go build ./cmd/gitbay` and fix any call the pattern missed by hand. Make each `group(...)` short text equal its `nounSummaries` entry where the noun exists on the server.
3243
3244Root help:
3245
3246```go
3247type rootSection struct {
3248 title string
3249 names []string
3250}
3251
3252var rootSections = []rootSection{
3253 {"WORK", []string{"issue", "mr", "build", "release", "milestone", "label", "search"}},
3254 {"REPOSITORIES", []string{"repo", "wiki", "status", "webhook", "init"}},
3255 {"YOU", []string{"dashboard", "feed", "notifications", "auth", "profile", "snippet", "web"}},
3256 {"INSTANCE", []string{"org", "explore", "register", "migrate", "remote", "admin", "audit"}},
3257}
3258
3259// rootHelp is gitbay --help: the nouns grouped by what they are for.
3260func rootHelp(root *cobra.Command) {
3261 byName := map[string]*cobra.Command{}
3262 wide := 0
3263 for _, c := range root.Commands() {
3264 byName[c.Name()] = c
3265 wide = max(wide, len(c.Name()))
3266 }
3267 fmt.Println("gitbay: command-line client for a gitbay forge")
3268 fmt.Println()
3269 fmt.Println("USAGE")
3270 fmt.Println(" gitbay <command> [<owner/name>] [flags]")
3271 for _, s := range rootSections {
3272 fmt.Println()
3273 fmt.Println(s.title)
3274 for _, n := range s.names {
3275 if c := byName[n]; c != nil {
3276 fmt.Printf(" %-*s %s\n", wide, n, c.Short)
3277 }
3278 }
3279 }
3280 fmt.Println()
3281 fmt.Println("gitbay <command> --help for its verbs; gitbay help <prefix> for the server reference.")
3282}
3283```
3284
3285In `newRoot`, after the commands are added: `root.SetHelpFunc(func(cmd *cobra.Command, args []string) { if cmd == root { rootHelp(root); return }; defaultHelp(cmd, args) })` with `defaultHelp := root.HelpFunc()` captured before the call. `helpCmd`'s bare case calls `rootHelp(root)` instead of `root.Help()`. Local commands whose `Short` carries usage (`audit`, `init`, `migrate`, `register`, `search`, `explore`, `feed`, `dashboard`) get summary-only `Short` strings; their usage stays in `Use` and `Long`.
3286
3287- [ ] **Step 3: Run**
3288
3289Run: `go build ./... && go vet ./... && go test ./cmd/gitbay -count=1`
3290Expected: PASS, including `TestEveryCommandIsReachable`.
3291
3292- [ ] **Step 4: Commit and open MR 4**
3293
3294```bash
3295git add cmd/gitbay
3296git commit -m "gitbay: summaries generated from the registry; grouped root help" -m "Ref #254"
3297git push -u origin cli-output-help
3298gitbay mr create --source cli-output-help --target main --title "CLI output: help from the registry"
3299```
3300
3301---
3302
3303# Part 5: docs (branch `cli-output-docs`)
3304
3305### Task 5.1: wiki and changelog
3306
3307**Files:**
3308- Modify: `.gitbay/wiki/Users.org` ("Output rules"), `.gitbay/wiki/Admin.org`, `CHANGELOG.org`
3309
3310- [ ] **Step 1: Users.org.** In "Output rules", change the list rule's timestamp wording to "timestamps are RFC3339 to the second, UTC", replace the sentence about the CLI padding tabs, and add after the list:
3311
3312```org
3313** At a terminal
3314
3315The =gitbay= CLI sends =GITBAY_TERM=<cols>[,color]= on the SSH session
3316when stdout is a terminal. The server then prints:
3317
3318- lists under a header, padded, fitted to the width (the title or
3319 description column is cut with =…= first), states in colour, ages as
3320 =2h ago=, and the next page as a command on stderr;
3321- =show= views with a title line, aligned fields, the body rendered
3322 from markdown or org, one line per event, and comments under a rule;
3323 timestamps as =2026-09-23 23:26 UTC=;
3324- help with flag descriptions and examples.
3325
3326=NO_COLOR=, =TERM=dumb= and =--no-color= drop the colour. Views, diffs
3327and logs go through =$GITBAY_PAGER=, else =$PAGER=, else =less=; an
3328empty =GITBAY_PAGER= turns it off. Stock ssh gets the plain output
3329unless it sets the variable: =ssh -o SetEnv=GITBAY_TERM=120,color
3330git@gitbay.org issue list krz/gitbay=.
3331```
3332
3333(If Task 2.3 took Step 6, describe the leading =--term=<cols>[,color]= argument instead of =SetEnv=.)
3334
3335- [ ] **Step 2: Admin.org.** Where the system-sshd forced command (`gitbayd shell`) is documented, add: "Terminal output needs `AcceptEnv GITBAY_TERM` in `sshd_config`; without it every session gets plain output."
3336
3337- [ ] **Step 3: CHANGELOG.org.** A new top section headed with the next minor version and the release date, in the style of the entries below it:
3338
3339```org
3340* v1.36.0 — <date>
3341
3342Terminal output for the CLI (#254).
3343
3344- At a terminal, lists print under a header, fitted to the width, with
3345 states in colour and relative ages; the next page is a command on
3346 stderr. Piped output is the same tab-separated rows, with timestamps
3347 as RFC3339 to the second.
3348- =show= commands print a title line, aligned fields, the body rendered
3349 from markdown or org, events one per line and comments under a rule,
3350 through a pager when longer than the screen.
3351- Help describes every flag, with examples, and =gitbay --help= groups
3352 the commands. =help --json= adds =flags= and =examples=.
3353- =release list= takes =--limit= and =--cursor=, and leaves the title
3354 empty when it repeats the tag. =notifications device add= prints
3355 =registered device <n>=. =dashboard= prints =none= under an empty
3356 section.
3357
3358Operators running the system-sshd forced command add =AcceptEnv
3359GITBAY_TERM= to =sshd_config= for terminal output.
3360```
3361
3362- [ ] **Step 4: Commit and open MR 5**
3363
3364```bash
3365git add .gitbay/wiki/Users.org .gitbay/wiki/Admin.org CHANGELOG.org
3366git commit -m "docs: terminal output rules, sshd AcceptEnv, changelog" -m "Closes #254"
3367git push -u origin cli-output-docs
3368gitbay mr create --source cli-output-docs --target main --title "CLI output: docs and changelog"
3369```
3370
3371Tagging, release and deploy follow the usual release steps once this merges.
docs/specs/2026-09-23-cli-output-refresh-design.md added +284
@@ -0,0 +1,284 @@
1# CLI output refresh
2
3Ref #254. Terminal rendering for tables, `show` views and help, with
4piped output unchanged in shape, plus the fixes from the 2026-09-23 CLI
5audit.
6
7## Problem
8
9The plain output has had no design pass since v1.22.0.
10
11- Tables have no header, no colour and no width limit. The CLI pads
12 tabs with `tabwriter` for the verbs in `listVerbs`
13 (`cmd/gitbay/ssh.go`) and nothing else.
14- `show` views print stored markup verbatim: event lines read
15 `referenced in commit [6c4d1e1454](/krz/gitbay/commit/…) by [cmc](/cmc)`,
16 and timestamps are whatever string the row holds
17 (`2026-09-23T23:26:00.570Z`). The web prints `2026-09-23 23:26 UTC`.
18- Root help carries usage in the summary column. Noun help is two
19 lines per verb with the full usage. Verb help is the same two lines;
20 no flag has a description, and no command has an example.
21- `pass()` in `cmd/gitbay/main.go` holds a second copy of each usage
22 string, which drifts (`build list` still reads
23 `recent builds: <owner/name>`).
24
25## Decision
26
27The server renders. The CLI tells it the terminal's width and whether
28colour is wanted; the command's plain formatter chooses between
29terminal and plain rendering. The CLI adds a pager for long views and
30the grouped root help, and nothing else.
31
32Rejected:
33
34- The CLI rendering from `--json`. A second renderer per command in
35 `cmd/gitbay`, which drifts from the server's, and stock ssh never
36 sees the result.
37- A structured view format on the wire (typed columns, a document
38 tree) styled by the CLI. A new protocol between two programs that
39 ship together, for no visible gain over rendering on the server.
40
41## Transport
42
43The CLI passes `-o SetEnv=GITBAY_TERM=<cols>[,color]` when stdout is a
44terminal. `color` is left out when `NO_COLOR` is set and non-empty,
45`TERM` is `dumb`, or `--no-color` is given (stripped by the CLI before
46dispatch). Piped stdout sends nothing.
47
48sshd accepts an `env` request named `GITBAY_TERM` and ignores every
49other name, as today. `runExec` parses the value into `Ctx.Term`:
50
51```go
52type Term struct {
53 Cols int // 0: plain output
54 Color bool
55}
56```
57
58A missing or malformed value, or `Cols` under 40, is the zero value.
59The HTTP surfaces never set it. Under the system-sshd forced command
60(`gitbayd shell`) the variable arrives only if the operator adds
61`AcceptEnv GITBAY_TERM`; without it output is plain. The Admin wiki
62page says so.
63
64`SetEnv` needs OpenSSH 7.8. The CLI shares one connection through
65ControlMaster; the first task verifies that a multiplexed session
66carries `SetEnv`. If it does not, the CLI sends a leading
67`--term=<cols>[,color]` argument instead, and `Dispatch` strips it
68wherever it appears, as it strips `--json`.
69
70Stock ssh users opt in with `ssh -o SetEnv=GITBAY_TERM=120,color`.
71
72`alignColumns`, `listVerbs` and the `tabwriter` in `runSSH` are
73removed.
74
75## Tables
76
77`internal/control/table.go`:
78
79```go
80t := c.table("#", "STATE", "TITLE", "AUTHOR", "UPDATED")
81for _, is := range issues {
82 t.row(ref("#", is.Number), state(is.State), text(is.Title), text(is.Author), age(is.UpdatedAt))
83}
84t.flush()
85```
86
87Cells are typed: `ref`, `state`, `text`, `flex`, `age`, `num`. The
88`flex` column (a title or description, one per table) is the one that
89shrinks first.
90
91Plain (`Term.Cols == 0`): one row per item, cells joined by tabs, no
92header, `age` as RFC3339 to the second in UTC (`2026-09-23T23:26:00Z`).
93This is the current shape; only the timestamp format changes.
94
95Terminal:
96
97- Header row in capitals, dim when `Color`.
98- Columns padded with two spaces between them.
99- Width: when a row exceeds `Cols`, the flexible column is cut with
100 `…`, down to 8 cells. If that is not enough, the other `text`
101 columns are cut from the right. `ref`, `state`, `age` and `num`
102 are never cut. Widths are measured in display cells
103 (`golang.org/x/text/width`), not bytes.
104- `age` is relative: `just now` under a minute, then `5m ago`,
105 `2h ago`, `3d ago` under 14 days, `3w ago` under 8 weeks, then
106 `2006-01-02`.
107- `state` colour, ANSI 16-colour so the terminal's theme sets the
108 shade, matching the web's state tokens: green (`--ok`) for `open`,
109 `success`, `approved`; magenta (`--done`) for `merged`; red (`--bad`)
110 for `failed`, `error`, `changes requested`; dim (`--neutral`) for
111 `closed`, `draft`, `pending`, `canceled`. Anything else is
112 uncoloured.
113
114An empty table prints `nothing to list` on stderr, as `emit` does now.
115
116`emitPage` in terminal mode prints the next cursor on stderr as
117`more: gitbay <path> <args> --cursor <c>`. Plain mode keeps the
118`next\t<c>` row.
119
120Every list command moves to `table`: each `emit`/`emitPage` whose
121plain formatter prints one row per item. Mutations and single-value
122reads keep their one line.
123
124## Show views
125
126`internal/termtext` renders markdown (goldmark) and org (go-org) syntax
127trees to terminal text at a given width:
128
129- Paragraphs wrapped at `Cols - 2`, indented two spaces.
130- Headings bold; list items with `•` or their number, continuation
131 lines hanging.
132- Code blocks indented four spaces, not wrapped, highlighted with
133 chroma's `terminal16` formatter when `Color`.
134- Emphasis bold or underlined when `Color`, plain text otherwise.
135- Links as their text, followed by ` (<url>)` when the URL differs
136 from the text. Relative forge links are made absolute from
137 `server.site_url`.
138- Images as `[image: <alt>]`.
139
140The same package has a plain mode: no ANSI, no wrapping, links
141reduced as above.
142
143A `view` helper in `internal/control` lays out every `show`:
144
145```
146#253 Recorded CLI walkthrough on the landing page closed
147
148 author cmc, 2026-09-22 18:04 UTC
149 closed 2026-09-23 23:26 UTC by cmc in fc2380f3f0
150 labels docs, web
151 milestone v1.35.0
152 url https://gitbay.org/krz/gitbay/issues/253
153
154 <rendered body>
155
156 · cmc referenced this in 6c4d1e1454 2026-09-23 23:26 UTC
157 · cmc closed this in fc2380f3f0 2026-09-23 23:26 UTC
158
159── cmc, 2026-09-23 23:40 UTC ──────────────────────────────────────
160 <rendered comment>
161```
162
163Title bold and state coloured when `Color`; events dim. Timestamps in
164views use the web's format, `2006-01-02 15:04 UTC`. Plain mode prints
165the same lines without colour or wrapping, with RFC3339 timestamps.
166Commands: `issue show`, `mr show`, `build show`, `release show`,
167`snippet show`, `repo show`, `milestone show`, `org show`,
168`profile show`, and any other `show` whose output has a body or more
169than one field.
170
171### Pager
172
173When stdout is a terminal, the CLI runs `show`, `diff` and `log`
174verbs through a pager: `$GITBAY_PAGER`, else `$PAGER`, else
175`less -FRX`. `-F` exits when the output fits one screen. An empty
176`GITBAY_PAGER` disables it. `build log --follow` is never paged. The
177pager's exit does not change the command's exit code.
178
179## Help
180
181`Command` gains:
182
183```go
184type Flag struct {
185 Name string // "--state"
186 Arg string // "open|closed|all", empty for a switch
187 Desc string // "which issues"
188 Default string // "open", empty for none
189}
190
191Flags []Flag
192Examples []string // full argv after the program, owner/name explicit
193```
194
195`help --json` adds `flags` and `examples` to each entry.
196
197Server `help` with a prefix, in terminal mode:
198
199- Noun (a prefix matching more than one command): the noun's summary,
200 `USAGE gitbay <noun> <verb> [<owner/name>] ...`, then `READ` and
201 `WRITE` sections from `ReadOnly`, one line per verb with its
202 summary, then `gitbay <noun> <verb> --help for flags.`
203- Verb (a prefix matching one command): summary, `USAGE`, `FLAGS`
204 one per line with description and `(default …)`, `--json` last,
205 then `EXAMPLES`.
206
207Examples print with a `gitbay ` prefix in terminal mode and
208`ssh <ssh host> ` in plain mode; both are valid because each example
209names its repository.
210
211Plain mode without a prefix stays one line per command. With a prefix,
212plain mode prints the same sections as terminal mode without colour.
213
214The CLI's noun and verb `--help` already go to the server. `pass()`
215drops its short text; the cobra command takes its one-line summary
216from a table generated from the registry (`go generate`, checked by a
217test that the file is current), so root and completion text never
218drift.
219
220Root help stays local to the CLI and is grouped:
221
222```
223WORK issue mr build release milestone label search
224REPOSITORIES repo wiki status webhook init
225YOU dashboard feed notifications auth profile snippet web
226INSTANCE org explore register migrate remote admin audit
227```
228
229one noun per line with its summary. A test fails when a top-level
230command is in no section or two.
231
232Registry test: every `--flag` token in `Usage` has a `Flags` entry
233and every `Flags` entry appears in `Usage`; every command has a
234non-empty `Desc` per flag and at least one example; every example
235resolves through `Lookup` to its own command.
236
237## Audit fixes
238
239- `release list` takes `--limit`/`--cursor`; the title column is
240 empty when the title equals the tag.
241- `notifications device add` prints `registered device <n>`.
242- `dashboard` prints `none` under an empty section.
243
244Two audit findings need no change. `build log --follow` already
245says what to do when it gives up on a queued build
246(`buildfollow.go`). A missing positional through `c.usage()` already
247prints the registered usage and exits 2, which is the rule; converting
248198 call sites to `usageWith` for an extra line is not worth the
249churn.
250- The stale `build list` help goes with the `pass()` short text.
251
252## Rules
253
254The Users wiki "Output rules" gains an "At a terminal" section:
255headers, colour, width, relative ages, the pager, `GITBAY_TERM`,
256`NO_COLOR`. The piped rules stand, with timestamps stated as RFC3339
257to the second.
258
259## Testing
260
261- `table`: plain rows byte-identical to the current formatter for a
262 fixture per cell type; terminal golden files at 60 and 120 columns,
263 with and without colour; wide-character titles.
264- `termtext`: golden files for markdown and org fixtures covering
265 every node type above, at 60 columns, plain and colour.
266- `Term` parsing: valid, malformed, under 40 columns, absent.
267- e2e: every `ReadOnly` command in `readArgs`
268 (`e2e/readonly_test.go`) run twice through ssh: plain, asserting no
269 `\x1b` in stdout; with `GITBAY_TERM=60,color`, asserting no line
270 wider than 60 display cells after stripping ANSI, code blocks
271 excepted.
272- CLI: `SetEnv` sent only when stdout is a terminal; `NO_COLOR`,
273 `TERM=dumb` and `--no-color` drop `color`; pager selection order.
274
275## Delivery
276
277Five MRs, each shippable alone:
278
2791. Audit fixes.
2802. Transport, `Term`, `table`, and every list command.
2813. `termtext`, `view`, every show command, the pager.
2824. `Flags`/`Examples` for every command, help layouts, the generated
283 summary table, grouped root help.
2845. Users and Admin wiki pages, CHANGELOG.
internal/control/dashboard.go +13
@@ -175,6 +175,9 @@ func runDashboard(c *Ctx, args []string) int {
175175 fmt.Fprintln(w, "open issues:")
176176 printDashboardItems(w, d.Issues, "#")
177177 fmt.Fprintln(w, "pinned:")
178 if len(d.Pinned) == 0 {
179 fmt.Fprintln(w, " none")
180 }
178181 for _, p := range d.Pinned {
179182 mark := ""
180183 if p.Archived {
@@ -183,10 +186,16 @@ func runDashboard(c *Ctx, args []string) int {
183186 fmt.Fprintf(w, " %s\t%s\t%s%s\n", p.Path, p.Visibility, p.Description, mark)
184187 }
185188 fmt.Fprintln(w, "recent activity:")
189 if len(d.Activity) == 0 {
190 fmt.Fprintln(w, " none")
191 }
186192 for _, e := range d.Activity {
187193 fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", e.CreatedAt, e.Actor, e.Kind, e.Repo, string(e.Data))
188194 }
189195 fmt.Fprintln(w, "builds:")
196 if len(d.Builds) == 0 {
197 fmt.Fprintln(w, " none")
198 }
190199 for _, b := range d.Builds {
191200 fmt.Fprintf(w, " %s\t%d\t%s\t%s\t%.10s\t%s\n", b.Repo, b.Number, b.Job, b.Status, b.SHA, b.Ref)
192201 }
@@ -229,6 +238,10 @@ func runDashboard(c *Ctx, args []string) int {
229238}
230239
231240func printDashboardItems(w io.Writer, items []DashboardItem, marker string) {
241 if len(items) == 0 {
242 fmt.Fprintln(w, " none")
243 return
244 }
232245 for _, item := range items {
233246 fmt.Fprintf(w, " %s%s%d\t%s\t%s\n", item.Repo, marker, item.Number, item.Title, item.Author)
234247 }
internal/control/dashboard_test.go +17
@@ -4,8 +4,25 @@ import (
44 "bytes"
55 "strings"
66 "testing"
7
8 "gitbay.org/gitbay/internal/protocol"
9 "gitbay.org/gitbay/internal/store"
710)
811
12func TestDashboardEmptySectionsSayNone(t *testing.T) {
13 st, _, uid := newQueueTestRepo(t)
14 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
15 if code := Dispatch(c, []string{"dashboard"}); code != protocol.ExitOK {
16 t.Fatalf("exit %d: %s", code, errOut)
17 }
18 out := c.Stdout.(*bytes.Buffer).String()
19 for _, h := range []string{"waiting on your review:", "assigned to you:", "open merge requests:", "open issues:"} {
20 if !strings.Contains(out, h+"\n none\n") {
21 t.Errorf("%q not followed by none:\n%s", h, out)
22 }
23 }
24}
25
926// The push queue is the one worker queue whose worst failure — a key_id
1027// or team_id Apple did not issue, which config validation cannot check —
1128// dead-letters every row on its first attempt with nothing but a log
internal/control/notifications.go +1 −1
@@ -279,7 +279,7 @@ func runNotificationsDeviceAdd(c *Ctx, args []string) int {
279279 return c.fail(protocol.ExitFailure, "%v", err)
280280 }
281281 return c.emit(map[string]any{"id": id, "status": "registered"}, func(w io.Writer) {
282 fmt.Fprintf(w, "device %d registered\n", id)
282 fmt.Fprintf(w, "registered device %d\n", id)
283283 })
284284}
285285
internal/control/notifications_test.go +13
@@ -243,6 +243,19 @@ func TestNotificationsDeviceAddReturnsTheID(t *testing.T) {
243243 }
244244}
245245
246func TestNotificationsDeviceAddPlainSaysRegisteredDevice(t *testing.T) {
247 c := notifTestCtx(t, "alice")
248 c.Stdin = strings.NewReader("DEVTOKEN\n")
249 var out bytes.Buffer
250 c.Stdout, c.JSON = &out, false
251 if code := runNotificationsDeviceAdd(c, nil); code != 0 {
252 t.Fatalf("exit %d", code)
253 }
254 if got := out.String(); !strings.HasPrefix(got, "registered device ") {
255 t.Errorf("device add printed %q", got)
256 }
257}
258
246259func TestNotificationsSettingsShowsPush(t *testing.T) {
247260 c := notifTestCtx(t, "alice")
248261 var out bytes.Buffer
internal/control/release.go +43 −6
@@ -10,6 +10,7 @@ import (
1010 "path/filepath"
1111 "regexp"
1212 "strconv"
13 "strings"
1314
1415 "gitbay.org/gitbay/internal/gitutil"
1516 "gitbay.org/gitbay/internal/policy"
@@ -28,7 +29,7 @@ func init() {
2829 ReadsStdin: true, Run: runReleaseEdit})
2930 register(Command{Path: []string{"release", "list"},
3031 Summary: "list releases",
31 Usage: "release list <owner/name>", ReadOnly: true, Run: runReleaseList})
32 Usage: "release list <owner/name> [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runReleaseList})
3233 register(Command{Path: []string{"release", "show"},
3334 Summary: "show a release with assets",
3435 Usage: "release show <owner/name> <tag>", ReadOnly: true, Run: runReleaseShow})
@@ -196,25 +197,61 @@ func runReleaseEdit(c *Ctx, args []string) int {
196197 })
197198}
198199
200// splitReleaseCursor pulls "<created_at>|<id>" apart. The id is what a
201// deleted release loses, so the created_at half carries the sort
202// position even when the row the cursor names is gone.
203func splitReleaseCursor(key string) (created string, id int64, ok bool) {
204 i := strings.LastIndex(key, "|")
205 if i < 0 {
206 return "", 0, false
207 }
208 created = key[:i]
209 n, err := strconv.ParseInt(key[i+1:], 10, 64)
210 if err != nil || created == "" {
211 return "", 0, false
212 }
213 return created, n, true
214}
215
199216func runReleaseList(c *Ctx, args []string) int {
200 if len(args) != 1 {
217 rest, p, code := parsePageFlags(c, args, "release", false)
218 if code >= 0 {
219 return code
220 }
221 if len(rest) != 1 {
201222 return c.usage()
202223 }
203 repo, code := resolveRepo(c, args[0], policy.CanRead)
224 repo, code := resolveRepo(c, rest[0], policy.CanRead)
204225 if code >= 0 {
205226 return code
206227 }
207 rels, err := c.Store.ListReleases(repo.ID)
228 var afterCreated string
229 var afterID int64
230 if p.key != "" {
231 var ok bool
232 afterCreated, afterID, ok = splitReleaseCursor(p.key)
233 if !ok {
234 return c.fail(protocol.ExitUsage, "bad cursor")
235 }
236 }
237 rels, err := c.Store.ListReleasesPage(repo.ID, p.queryLimit(), afterCreated, afterID)
208238 if err != nil {
209239 return c.fail(protocol.ExitFailure, "%v", err)
210240 }
241 rels, next := trimPage(p, rels, "release", func(r store.Release) string {
242 return r.CreatedAt + "|" + strconv.FormatInt(r.ID, 10)
243 })
211244 var ds []releaseOut
212245 for _, r := range rels {
213246 ds = append(ds, releaseToOut(r, false))
214247 }
215 return c.emit(ds, func(w io.Writer) {
248 return c.emitPage(p, ds, next, func(w io.Writer) {
216249 for _, d := range ds {
217 fmt.Fprintf(w, "%s\t%s\t%d asset(s)\n", d.Tag, d.Title, len(d.Assets))
250 title := d.Title
251 if title == d.Tag {
252 title = ""
253 }
254 fmt.Fprintf(w, "%s\t%s\t%d asset(s)\n", d.Tag, title, len(d.Assets))
218255 }
219256 })
220257}
internal/control/release_test.go added +86
@@ -0,0 +1,86 @@
1package control
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/protocol"
9 "gitbay.org/gitbay/internal/store"
10)
11
12func TestReleaseListPagesAndHidesTagTitle(t *testing.T) {
13 st, repo, uid := newQueueTestRepo(t)
14 for _, tag := range []string{"v1", "v2", "v3"} {
15 title := tag
16 if tag == "v2" {
17 title = "Second"
18 }
19 if _, err := st.CreateRelease(repo.ID, tag, title, "", uid, "md"); err != nil {
20 t.Fatal(err)
21 }
22 }
23 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
24 if code := Dispatch(c, []string{"release", "list", repo.Path(), "--limit", "2"}); code != protocol.ExitOK {
25 t.Fatalf("exit %d: %s", code, errOut)
26 }
27 lines := strings.Split(strings.TrimSpace(c.Stdout.(*bytes.Buffer).String()), "\n")
28 if len(lines) != 3 || lines[0] != "v3\t\t0 asset(s)" || lines[1] != "v2\tSecond\t0 asset(s)" || !strings.HasPrefix(lines[2], "next\t") {
29 t.Fatalf("page 1:\n%s", strings.Join(lines, "\n"))
30 }
31 cursor := strings.TrimPrefix(lines[2], "next\t")
32
33 c, errOut = pruneCtx(st, t.TempDir(), store.User{ID: uid})
34 if code := Dispatch(c, []string{"release", "list", repo.Path(), "--cursor", cursor}); code != protocol.ExitOK {
35 t.Fatalf("exit %d: %s", code, errOut)
36 }
37 if got := c.Stdout.(*bytes.Buffer).String(); got != "v1\t\t0 asset(s)\n" {
38 t.Fatalf("page 2: %q", got)
39 }
40}
41
42// The cursor names the pointed-to release by (created_at, id), not by a
43// live lookup of that id: deleting it between page requests must not
44// blank the next page.
45func TestReleaseListPageSurvivesTheCursorReleaseBeingDeleted(t *testing.T) {
46 st, repo, uid := newQueueTestRepo(t)
47 for _, tag := range []string{"v1", "v2", "v3"} {
48 if _, err := st.CreateRelease(repo.ID, tag, tag, "", uid, "md"); err != nil {
49 t.Fatal(err)
50 }
51 }
52 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
53 if code := Dispatch(c, []string{"release", "list", repo.Path(), "--limit", "2"}); code != protocol.ExitOK {
54 t.Fatalf("exit %d: %s", code, errOut)
55 }
56 lines := strings.Split(strings.TrimSpace(c.Stdout.(*bytes.Buffer).String()), "\n")
57 if len(lines) != 3 || !strings.HasPrefix(lines[2], "next\t") {
58 t.Fatalf("page 1:\n%s", strings.Join(lines, "\n"))
59 }
60 cursor := strings.TrimPrefix(lines[2], "next\t")
61
62 rels, err := st.ListReleases(repo.ID)
63 if err != nil {
64 t.Fatal(err)
65 }
66 var v2ID int64
67 for _, r := range rels {
68 if r.Tag == "v2" {
69 v2ID = r.ID
70 }
71 }
72 if v2ID == 0 {
73 t.Fatal("v2 not found")
74 }
75 if err := st.DeleteRelease(v2ID); err != nil {
76 t.Fatal(err)
77 }
78
79 c, errOut = pruneCtx(st, t.TempDir(), store.User{ID: uid})
80 if code := Dispatch(c, []string{"release", "list", repo.Path(), "--cursor", cursor}); code != protocol.ExitOK {
81 t.Fatalf("exit %d: %s", code, errOut)
82 }
83 if got := c.Stdout.(*bytes.Buffer).String(); got != "v1\t\t0 asset(s)\n" {
84 t.Fatalf("page 2 after deleting the cursor release: %q", got)
85 }
86}
internal/store/releases.go +21 −1
@@ -90,7 +90,27 @@ func (s *Store) ReleaseByTag(repoID int64, tag string) (Release, error) {
9090
9191// ListReleases returns releases newest-first, assets included.
9292func (s *Store) ListReleases(repoID int64) ([]Release, error) {
93 rows, err := s.DB.Query(releaseSelect+" WHERE r.repo_id = ? ORDER BY r.created_at DESC", repoID)
93 return s.ListReleasesPage(repoID, 0, "", 0)
94}
95
96// ListReleasesPage lists newest first. limit 0 is every row; afterCreated
97// and afterID are the (created_at, id) of the last release of the
98// previous page, afterID 0 for the first page. The pair, not a lookup by
99// id, is what survives the pointed-to release being deleted between
100// requests.
101func (s *Store) ListReleasesPage(repoID int64, limit int, afterCreated string, afterID int64) ([]Release, error) {
102 q := releaseSelect + " WHERE r.repo_id = ?"
103 args := []any{repoID}
104 if afterID > 0 {
105 q += " AND (r.created_at, r.id) < (?, ?)"
106 args = append(args, afterCreated, afterID)
107 }
108 q += " ORDER BY r.created_at DESC, r.id DESC"
109 if limit > 0 {
110 q += " LIMIT ?"
111 args = append(args, limit)
112 }
113 rows, err := s.DB.Query(q, args...)
94114 if err != nil {
95115 return nil, err
96116 }