control: repository screens (CLI views stage 3a) !556

merged merged by cmc on 2026-10-02 04:57 UTC · krz/gitbay:cli-views-3a into main

37 files changed, +1040 −73

Layout: unified · split

CHANGELOG.org +6
@@ -25,6 +25,12 @@ anything beyond "replace the binary and restart" is needed.
2525 dashboard drops the server build and queue tables (=admin stats= has
2626 them; =--json= is unchanged) and shows a job's failed build only
2727 while it is that job's latest.
28- Every repository command at a terminal is a screen (#319): =repo
29 log=, =tree=, =refs=, =symbols=, =list=, =search=, =bookmarks=,
30 =topics=, the secret, deploy-key, domain, access, mirror and runner
31 lists, =repo settings show= (now with approvals, resolved threads,
32 code owners and website), =repo deps status=, and =repo commit=, whose
33 diff follows the screen.
2834
2935* v1.41.0 — 2026-10-01
3036
docs/plans/2026-10-01-cli-views-stage3.md added +326
@@ -0,0 +1,326 @@
1# CLI views implementation plan (stage 3)
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:** Every command that still draws its terminal output through `table` or `view` draws a screen, and `view` keeps only its piped behaviour.
6
7**Architecture:** Stages 1 and 2 built `screen` (`internal/control/screen.go`), `emitView`/`emitPageView`, and eleven screens. This stage adds one helper, `listScreen`, and migrates the remaining 70 commands noun by noun: a builder per command (or per shared emitter), the command switched to `emitView`/`emitPageView`, its piped output pinned before and after. The last task strips `view`'s terminal branches, so a caller missed here falls back to the plain layout rather than a half-styled one.
8
9**Tech Stack:** Go, `internal/control`.
10
11**Spec:** `docs/specs/2026-10-01-cli-views-design.md`. Stage 1–2 plan: `docs/plans/2026-10-01-cli-views.md` (its "Corrections" section applies here too).
12
13**Issues:** #316 (tracking), #319 (this stage).
14
15## Global Constraints
16
17- Piped output and `--json` byte-identical for every migrated command; prove it with `pinPlain` before touching the command.
18- Rendering stays in `internal/control`; nothing in `cmd/gitbay` changes.
19- Colour is never the only signal (`stripSGR(colour) == colourless` holds for every screen; the renderer guarantees it if builders use cells, never raw SGR).
20- Every legend and `more` argv passes `checkActions` (registered path, declared flags).
21- Never put in a cell or legend: webhook secrets, API token values, push device tokens beyond `ShortToken`, mirror tokens, mailbox credentials, `web login` URLs, base64 page content. A legend command that reads a secret takes it from stdin and carries no value (`repo secret set <path> <NAME>`, never `--secret`).
22- Destructive commands appear in a legend without their confirmation flag (`release delete <repo> <tag>`, never `--yes`).
23- Commit messages end `Ref #319`; the last commit of the stage `Closes #319`. No attribution lines. Signed commits, `--strategy ff`.
24- Locally: `go build ./... && go vet ./... && go test ./internal/control/`. CI runs e2e, including `e2e/readonly_test.go`, which runs every read command at 60 columns and fails on a line wider than 60 (suggested commands are exempt only in unit tests; keep legend argv short).
25
26## Reference
27
28### Helpers that exist
29
30| Name | Where | Use |
31|---|---|---|
32| `screen`, `field`, `section{title,n,note,rows,more,empty}`, `row`, `rowOf`, `action{group,argv}` | `screen.go` | the model |
33| `c.emitView(data, plain, build)` / `c.emitPageView(p, items, next, plain, build)` | `control.go`, `cursor.go` | route terminal to `build` |
34| `discussion(cs)`, `events(cs)` | `screen.go` | comment and system-event sections |
35| `cRef cGlyph cYou cFlex cText cMeta cAge cNum cSize cSwatch cState cLink cMark` | `table.go` | cells |
36| `glyph(state)` | `term.go` | `✓` success/ok/approved/verified/passed/merged, `✗` failure/failed/error/bad sig, `◐` pending/running/queued, `○` closed/draft/canceled/skipped |
37| `labelsMark`, `checksMark`, `reviewMark` | `marks.go` | row marks |
38| `keyValues`, `c.usedText`, `c.expiresText` | `audit.go`, `identity.go` | reuse in builders |
39| `checkActions`, `pinPlain`, `screenCtx`, `renderString`, `sectionCounts`, `actionArgvs`, `actionVerbs` | `*_test.go` | tests |
40
41### Migration recipe (every command)
42
431. **Pin.** In the task's test file, add `Test<Cmd>PlainPinned`: build the fixture, `Dispatch` the command with no `Term`, `pinPlain(t, "<cmd-slug>", out.String())`. Run with `-update-plain`, then without. Commit nothing yet. If the screen tests in the same file do not compile yet, keep them out of the file until the pin exists (stage 2 lost a file juggling this; copy the full file aside with `cp` and check `grep -c 'func Test'` after restoring).
442. **Screen test.** Construct the command's data value directly (no store), call the builder, assert section titles and counts, the lead glyph of a representative row, the action argv, and `checkActions(t, s)`.
453. **Builder.** Write `<cmd>Screen(c *Ctx, ...) screen` next to the command, from the spec row below.
464. **Wire.** Replace `c.emit(d, plain)` with `c.emitView(d, plain, func() screen { return <cmd>Screen(...) })` (`emitPage` → `emitPageView`). Inside `plain`, delete every `c.Term.Cols > 0` branch and keep the `== 0` path unconditionally; a terminal no longer reaches `plain`. Move terminal-only data loads out of `plain` to a `c.Term.Cols > 0 && !c.JSON` block before the emit.
475. **Orphans.** `grep` every helper the deleted branches called; delete those now unused, with their tests.
486. **Run** `go test ./internal/control/ -run '<Cmd>'`, then the package. Pins must still match.
49
50### Worked example: a list with a shared emitter (`repo topics`, `repo topics add`, `repo topics remove`)
51
52`runRepoTopics` (`repo.go:963`) and `editTopics` (`repo.go:987`) both print `[]string` through a one-column table.
53
54Test (`internal/control/stage3repo_test.go`):
55
56```go
57func TestRepoTopicsPlainPinned(t *testing.T) {
58 st, repo, uid := newQueueTestRepo(t)
59 owner := store.User{ID: uid, Username: "alice"}
60 c, out, errOut := mrTestCtx(st, owner)
61 if code := Dispatch(c, []string{"repo", "topics", "add", repo.Path(), "cli", "forge"}); code != protocol.ExitOK {
62 t.Fatalf("exit %d: %s", code, errOut)
63 }
64 pinPlain(t, "repo-topics-add", out.String())
65 c, out, errOut = mrTestCtx(st, owner)
66 if code := Dispatch(c, []string{"repo", "topics", repo.Path()}); code != protocol.ExitOK {
67 t.Fatalf("exit %d: %s", code, errOut)
68 }
69 pinPlain(t, "repo-topics", out.String())
70}
71
72func TestTopicsScreen(t *testing.T) {
73 s := topicsScreen(screenCtx(80, false), screenRepo, []string{"cli", "forge"})
74 if n := sectionCounts(s); n["Topics"] != 2 {
75 t.Errorf("sections = %v", n)
76 }
77 if got := actionVerbs(s); !slices.Equal(got, []string{"topics", "topics"}) {
78 t.Errorf("actions = %q", got)
79 }
80 checkActions(t, s)
81}
82```
83
84Helper and builder (`screen.go` and `repo.go`):
85
86```go
87// listScreen is a list command's terminal screen: one section of rows
88// and the commands that apply.
89func listScreen(title string, rows []row, actions ...action) screen {
90 return screen{sections: []section{{title: title, n: len(rows), rows: rows}}, actions: actions}
91}
92```
93
94```go
95// topicsScreen is a repository's topics at a terminal, after a read or
96// an edit.
97func topicsScreen(c *Ctx, repo store.Repo, topics []string) screen {
98 rows := make([]row, len(topics))
99 for i, t := range topics {
100 rows[i] = rowOf(cRef(t))
101 }
102 return listScreen("Topics", rows,
103 action{"Edit", []string{"repo", "topics", "add", repo.Path(), "<topic>"}},
104 action{"Edit", []string{"repo", "topics", "remove", repo.Path(), "<topic>"}},
105 )
106}
107```
108
109Both `runRepoTopics` and `editTopics` call `c.emitView(topics, plain, func() screen { return topicsScreen(c, repo, topics) })` (`now` in `editTopics`). An empty list still prints "nothing to list" on stderr: `emit` handles that before `build` runs.
110
111### Worked example: a show (`org show`, also serving `org members list`)
112
113```go
114// orgShowScreen is org show at a terminal: the organisation and its
115// members, and where to go from here.
116func orgShowScreen(c *Ctx, d orgShowOut) screen {
117 members := section{title: "Members", n: len(d.Members)}
118 for _, m := range d.Members {
119 members.rows = append(members.rows, rowOf(cRef(m.User), cState(m.Role)))
120 }
121 return screen{
122 fields: []field{{"Org", []cell{cLink(d.Org, c.siteURL(d.Org))}}},
123 sections: []section{members},
124 actions: []action{
125 {"Members", []string{"org", "members", "add", d.Org, "<user>"}},
126 {"Org", []string{"org", "team", "list", d.Org}},
127 {"Org", []string{"org", "label", "list", d.Org}},
128 {"Org", []string{"org", "milestone", "list", d.Org}},
129 },
130 }
131}
132```
133
134If `runOrgShow`'s output type is a local `type out struct`, hoist it to package level as `orgShowOut` (same JSON tags) as stage 2 did for `repoShowOut`. `org members list` delegates to `runOrgShow`, so it migrates with it.
135
136### Worked example: a stream with a header (`repo commit`)
137
138The screen has no diff part. `runRepoCommit` (`sig.go:250`) keeps `c.emit` and branches itself:
139
140```go
141 return c.emit(d, func(w io.Writer) {
142 if c.Term.Cols == 0 {
143 // existing plain header and diff, unchanged
144 return
145 }
146 c.render(w, commitScreen(c, repo, d, body))
147 fmt.Fprintf(w, "\n%s", c.Term.diff(d.Diff))
148 })
149```
150
151`commitScreen` fields: `Commit` (`cRef(sha10)`, `cText(subject)`), `Author` (`cText(name+" <"+email+">")`, `cAge(date)`), `Signature` (`cGlyph(state)`, `cState(state)`, `cMeta(signer, fingerprint)`), `URL` when `!c.Term.Links`; body = message without subject, format `"text"`; section `Checks` (`cGlyph(state)`, `cFlex(context)`); actions `Read: repo log <path> --ref <sha>`, `Read: repo tree <path> --ref <sha>`. The legend prints before the diff, which is what a reader scrolling up from the end of a long patch expects to find above it.
152
153## Spec rows
154
155Columns: **title** of the section (or "fields" for a show), **lead** (the glyph cell second in the row, after the ref, or none), **row** cells after the lead, **actions** (`Group: argv`), **notes**. `<p>` is `repo.Path()`.
156
157---
158
159## Task 1: `listScreen` and the repository lists (MR 3a)
160
161**Files:** `screen.go` (`listScreen`), `repo.go`, `pagescmd.go`, `deploykey.go`, `build.go` (secrets), `mirrorcmd.go`, `runnerrepo.go`, test `stage3repo_test.go`, pins under `testdata/plain/`.
162
163| Command | Title | Lead | Row | Actions | Notes |
164|---|---|---|---|---|---|
165| `repo topics` / `topics add` / `topics remove` | Topics | none | `cRef(t)` | worked example | shared `topicsScreen` |
166| `repo domain list` (`pagescmd.go:196`) | Pages domains | `cGlyph(state)` (`expired` → `"failed"`) | `cRef(domain)`, `cState(state)` | `Domains: repo domain add <p> <domain>`, `repo domain verify <p> <domain>` | |
167| `repo deploy-key list` (`deploykey.go:93`) | Deploy keys | none | `cRef(fp)`, `cState(mode)`, `cFlex(label)`, `cMeta(algo, "used "+usedText, expiresText)` | `Keys: repo deploy-key add <p>`, `repo deploy-key remove <p> <fingerprint>` | key arrives on stdin |
168| `repo secret list` (`build.go:555`) | Build secrets | none | `cRef(name)` | `Secrets: repo secret set <p> <NAME>`, `repo secret remove <p> <NAME>` | names only, never values |
169| `repo access list` (`repo.go:753`) | Access | none | `cRef(user)`, `cState(role)`, `cMeta("via "+source)` | `Access: repo access grant <p> <user> write`, `repo access revoke <p> <user>` | |
170| `repo mirror list` (`mirrorcmd.go:99`) | Mirrors | `cGlyph("error"/"pending"/"ok")` from LastError/Pending | `cRef(id)`, `cFlex(url)`, `cMeta(direction, "synced "+relAge(LastSync))`, `cMark(LastError, sgrRed)` | `Mirrors: repo mirror sync <p>`, `repo mirror remove <p> <id>` | never the token |
171| `repo runner list` (`runnerrepo.go:102`) | Runners | `cGlyph("running")` when BuildNumber≠0 | `cRef(fp)`, `cFlex(username)`, `cMeta(algo, seen, "building "+repo+" #n "+job)` | `Runners: repo runner remove <p> <fingerprint>` | |
172| `repo bookmarks` (`repo.go:1225`) | Bookmarks | none | `cLink(path, url)`, `cState(vis)`, `cFlex(desc)`, `cMeta(n+" bookmarks")` | `Read: repo show <first path>` | drop `cNum` so no header row |
173| `repo search` (`repo.go:1045`) | Repositories matching "q" | none | `cLink`, `cState(vis)`, `cFlex(desc)`, `cMeta(topics joined)` | `Read: repo show <first path>` | |
174| `repo list` (`repo.go:339`) | Repositories | none | `cLink`, `cState(vis[, archived])`, `cFlex(desc)` | `Read: repo show <first path>`, `New: repo create <owner>/<name>` | `emitPageView` |
175
176Steps: recipe per command; one commit for `listScreen` + topics, one per row group of three or four, each `Ref #319`.
177
178## Task 2: Repository browsing (MR 3a)
179
180**Files:** `sig.go` (log), `read.go` (tree, refs), `symbols.go`, test `stage3repo_test.go`.
181
182| Command | Title | Lead | Row | Actions | Notes |
183|---|---|---|---|---|---|
184| `repo log` (`sig.go:144`) | Commits on `<ref>` (+ " touching `<path>`") | `cGlyph(sig state)` | `cRef(sha10)`, `cFlex(subject)`, `cMeta(author, relAge(date))` | `Read: repo commit <p> <first sha>`, `repo tree <p> --ref <ref>` | delete the in-closure terminal table |
185| `repo tree` (`read.go:281`) | `<p>[/dir] at <ref>` | none | dirs `cMark(name+"/", sgrBlue)`, files `cText(name)`; `cSize`; `cMeta(sha10)` | `Read: repo cat <p> <dir/first file> --ref <ref>`, `repo log <p> --ref <ref> --path <dir>` | `sgrBlue` replaces the last `sgrCyan` in `read.go` |
186| `repo refs` (`read.go:~85`) | two sections, Branches and Tags | none | `cRef(name)`, `cMeta(sha10[, "default"])` | `Read: repo log <p> --ref <default>` | default branch first; tags from `sorted` |
187| `repo symbols` (`symbols.go:50`) | Symbols matching "q" | none | `cRef(name)`, `cText(kind)`, `cFlex(path:line)` | `Read: repo cat <p> <first path> --ref <ref>` | keep the two stderr notes; `emitPageView` |
188
189## Task 3: Repository shows (MR 3a, then open it)
190
191**Files:** `repo.go` (settings), `deps.go`, `sig.go` (commit), test `stage3repo_test.go`.
192
193| Command | Fields | Sections | Actions | Notes |
194|---|---|---|---|---|
195| `repo settings show` (`repo.go:783`) | `Repo` (`cRef(p)`), then one field per setting, `on`/`off` via `cState`; add the four not shown today (approvals, resolved, codeowners, website) to the **screen only** | none | `Protect: repo settings protect <p> <branch>`, `repo settings require-mr <p> on` (check the real arg form) | piped output unchanged |
196| `repo deps status` (`deps.go:85`) | `Checks` (`cGlyph("ok")`/`cGlyph("failed")` when LastError, `cText("on")`), `Last check` (`cAge`), `Error` (`cMark(…, sgrRed)`), `Tracked in` (`cLink("#n", …"issues"…)`) | Behind: `cRef(name)`, `cText(ecosystem)`, `cMeta(current+" → "+latest)` | `Deps: repo deps disable <p>` | the disabled branch is its own `emitView` with one field `Checks: off` and `Deps: repo deps enable <p>` |
197| `repo commit` (`sig.go:250`) | worked example | Checks | worked example | stream: keep `emit` |
198
199End of task: CHANGELOG line under Unreleased, push `cli-views-3a`, `gitbay mr create --source cli-views-3a --target main --title "control: repository screens (CLI views stage 3a)"`.
200
201## Task 4: Account (MR 3b)
202
203**Files:** `identity.go`, `register.go`, `sig.go` (pgp), `token.go`, `web.go`, `profile.go`, test `stage3account_test.go`.
204
205| Command | Title / fields | Lead | Row | Actions | Notes |
206|---|---|---|---|---|---|
207| `keys list` (`identity.go:117`) | SSH keys | `cYou()` on `c.Source`'s key | `cRef(fp)`, `cState(scope)`, `cFlex(label)`, `cMeta(algo, usedText, expiresText)` | `Keys: keys label <fingerprint> <text>`, `keys remove <fingerprint>` | `●` replaces the "this session" mark; add `"this session"` to its meta so the glyph is not the only signal |
208| `email list` (`register.go:58`) | Emails | `cYou()` when unverified | `cRef(addr)`, `cState(verified\|unverified)`, `cMeta("primary"?, VerifiedBy)` | `Email: email add <address>`, `email primary <address>` | |
209| `pgp list` (`sig.go:80`) | OpenPGP keys | `cGlyph("failed")` when revoked or expired | `cRef(fp)`, `cFlex(emails parsed from UIDsJSON)`, `cMeta("revoked"/"expires …")` | `Keys: pgp remove <fingerprint>` | parse UIDs for the screen only |
210| `token list` (`token.go:103`) | API tokens | `cGlyph("failed")` when expired | `cRef(name)`, `cState(scope)`, `cMeta("by "+createdBy, expiresText)` | `Tokens: token revoke <name>` | never a value |
211| `web sessions list` (`web.go:34`) | Browser sessions | none | `cRef(id)`, `cMeta("since "+relAge, "until "+relAge, usedText)` | `Sessions: web sessions revoke <id>` | never `web login` |
212| `whoami` (`identity.go:75`) | fields `User`, `Role` (admin only), `Instance`, `Key` (label or fp prefix), `Scope` | | | `Account: keys list`, `token list`, `email list` | move the `SSHKeyByFingerprint` lookup before emit |
213| `profile show`, `profile set`, `org profile` (shared `emitProfile`, `profile.go:254`) | fields `Profile` (`cText(name)`, `cMeta(kind, description)`), `Website`, `Activity`; body About | Links, Orgs, Members, Repos (`cLink`, `cState`, `cFlex`) | `Edit: profile set --description <text>` (own user profile only); `Edit: org profile <org> --description <text>` (org) | one builder; empty sections drop out after `set` |
214
215## Task 5: Notifications and webhooks (MR 3b)
216
217**Files:** `notifications.go`, `webhook.go`, test `stage3notify_test.go`.
218
219| Command | Title / fields | Lead | Row | Actions | Notes |
220|---|---|---|---|---|---|
221| `notifications list` (`notifications.go:452`) | Notifications | `cYou()` when unread | `cRef(repo)`, `cFlex(actor+" "+summary)`, `cMeta(kind, relAge)` | `Inbox: notifications read --all`; `Read: issue show`/`mr show <repo> <n>` for the first issue/MR row (number from the path's last segment) | keep the early empty-page exit; `emitPageView` |
222| settings `show`/`mail`/`reply`/`watch`/`push` (shared `emitNotificationSettings`, `:254`) | fields Mail, Reply, Watch, Push, each `cState("on"/"off")` | | | one action per setting offering the opposite value: `Settings: notifications settings mail off`, …; `Devices: notifications device list` | one builder serves five commands |
223| `notifications device list` (`:387`) | Push devices | none | `cRef(id)`, `cFlex(label)`, `cMeta(ShortToken, relAge(added))` | `Devices: notifications device remove <id>` | `ShortToken` only |
224| `webhook list` (`webhook.go:104`) | Webhooks | `cGlyph("ok")` active / `cGlyph("closed")` inactive | `cRef(id)`, `cFlex(url clipped to scheme+host+"/…")`, `cMeta(events, "signed"?)` | `Hooks: webhook deliveries <p>` | elide the URL path on the screen; piped unchanged |
225| `webhook deliveries` (`webhook.go:159`) | Deliveries | `cGlyph` with `delivered`→`"ok"` | `cRef(id)`, `cFlex(event)`, `cMeta(n+" attempts", "HTTP "+status)`, `cMark(LastError, sgrRed)` | `Retry: webhook redeliver <p> <first failed id>` | |
226
227## Task 6: Organisations, labels and milestones (MR 3b)
228
229**Files:** `org.go`, `orglabel.go`, `teams.go`, `label.go`, `milestone.go`, test `stage3org_test.go`.
230
231| Command | Title / fields | Lead | Row | Actions | Notes |
232|---|---|---|---|---|---|
233| `org list` (`org.go:91`) | Organizations | none | `cLink(org)`, `cState(role)` | `Read: org show <first org>` | |
234| `org show` + `org members list` | worked example | | | | |
235| `org label list` (`orglabel.go:142`) and `label list` (`label.go:41`) | Labels | none | `cSwatch(color)`, `cRef(name)`, `cMeta(n+" issues", m+" MRs", "org"?)` | org: `Labels: org label set <org> <label> --color rrggbb`; repo: `Labels: label set <p> <label> --color rrggbb`, `Read: issue list <p> --label <first>` | one `labelsScreen(c, labels, actions...)`; dropping `cNum` drops the header row |
236| `milestone list` + `org milestone list` (shared `emitMilestones`, `milestone.go:123`) | Milestones | `cGlyph(state)`; `cYou()` when open and overdue | `cRef(title)`, `cMeta(due or "overdue …", "c/t closed", "org"?)` | caller passes its actions: repo `Milestones: milestone create <p> <title>`, `milestone list <p> --state all`; org the `org milestone` equivalents | give `emitMilestones` an `actions []action` parameter |
237| `org team list` (`teams.go:141`) | Teams | none | `cRef(name)` | `Read: org team show <org> <first team>` | |
238| `org team show` (`teams.go:166`) | fields `Team` (`cLink(org/team)`), `Members` | Grants: `cLink(repo)`, `cState(role)` | `Team: org team add <org> <team> <user>`, `org team grant <org> <team> <owner/name> write` | |
239
240End of task: CHANGELOG line, push `cli-views-3b`, MR "control: account, notification and organisation screens (CLI views stage 3b)".
241
242## Task 7: Work items and content (MR 3c)
243
244**Files:** `query.go`, `search.go`, `dashboard.go` (feed), `mr.go` (revisions), `milestone.go` (templates), `build.go` (jobs), `status.go`, `release.go`, `snippet.go`, `wiki.go`, `explore.go`, `audit.go`, test `stage3work_test.go`.
245
246| Command | Title / fields | Lead | Row | Actions | Notes |
247|---|---|---|---|---|---|
248| `query run`, `issue list --query`, `mr list --query` (shared `runItemQuery`, `query.go:196`) | query name, or "Results" from `listByQuery` | `cGlyph(state or "draft")` | `cLink(ref)`, `cFlex(title)`, `cMeta(author, milestone, relAge(updated))` | `Read: issue show`/`mr show <repo> <n>` for the first row by kind | one builder, title passed in |
249| `query list` (`query.go:326`) | Saved queries | none | `cRef(name)`, `cFlex(query)`, `cMeta("pinned"?)` | `Queries: query run <first>`, `query pin <first>` | |
250| `query show` (`query.go:351`) | fields Query, Matches, Pinned | | | `Queries: query run <name>`, `query pin`/`unpin <name>` | |
251| `search` (`search.go:92`) | sections Repositories, Issues, Merge requests | `cGlyph(state)` for issue/MR rows | repo `cLink`, `cFlex(title)`; item `cLink(ref)`, `cFlex(title)`, `cMeta(author)` | `Narrow: search <q> --kind issue` | `writeSearchTable` stays for piped |
252| `feed` (`dashboard.go:340`) | Activity | none | `l.termCells(c)` | none | `emitPageView`; "Next page" covers paging |
253| `mr revisions` (`mr.go:2080`) | Revisions | `cMark("●", "")` for the current one, plain dot, not `cYou` | `cRef("v"+n)`, `cRef(sha10)`, `cAge` | `Compare: mr range-diff <p> <n>` | keep the stderr note |
254| `issue templates` (`milestone.go:288`) | Issue templates | none | `cRef(name)` | `New: issue create <p> --title <title>` | never the body |
255| `build jobs` (`build.go:443`) | Jobs | none | `cRef(name)`, `cMeta(when)` | `Run: build trigger <p> <first job>` | |
256| `status list` (`status.go:107`) | fields Commit, Combined (`cGlyph`+`cState`) | Statuses: `cGlyph(state)`, `cText(context)`, `cFlex(desc)`, `cMeta(creator)` | `Report: status set <p> <sha> --context <context> --state success` | drop the pseudo-row on the screen |
257| `release list` (`release.go:253`) | Releases | none | `cLink(tag)`, `cFlex(subtitle)`, `cMeta(n+" assets", relAge)` | `Read: release show <p> <first tag>` | `emitPageView` |
258| `release show` (`release.go:317`) | fields Release (`cRef(tag)`, `cText(title)`), Author, Released; body Notes | Assets: `cRef(name)`, `cSize`, `cMeta(sha12)` | `Get: release asset get <p> <tag> <first asset>`, `Edit: release edit <p> <tag> --title <title>` | rebind `repo` (it is `_` today) |
259| `snippet list` (`snippet.go:244`) | Snippets | none | `cRef(id)`, `cState(vis)`, `cFlex(desc)`, `cMeta(files, relAge)` | `Read: snippet show <first id>` | `emitPageView` |
260| `snippet show` (`snippet.go:212`) | fields Snippet, Owner, Visibility, Updated | Files: `cRef(name)`, `cSize` | `Get: snippet file get <id> <first file>`, `Edit: snippet edit <id> --visibility public` | never file content |
261| `wiki list` (`wiki.go:83`) | Wiki | none | `cLink(page)`, `cMeta("home"?)` | `Read: wiki show <p> <home>` | |
262| `wiki show` (`wiki.go:126`) | fields Page, File, URL; body Content (format md/org); binary pages get a `Binary` field and no body | | `Read: wiki list <p>` | never Base64 |
263| `explore` (`explore.go:60`) | Explore | none | `cLink(path)`, `cFlex(desc)`, `cMeta(topics, "archived"?)` | `Read: repo show <first path>` | `emitPageView` |
264| `audit` (`audit.go:35`) | Audit | none | `cAge`, `cText(actor)`, `cText(action)`, `cFlex(keyValues(data))` | `Filter: audit --since 24h` | |
265
266## Task 8: Administration (MR 3c)
267
268**Files:** `admin.go`, `adminhost.go`, `adminmail.go`, test `stage3admin_test.go`.
269
270| Command | Title / fields | Lead | Row | Actions | Notes |
271|---|---|---|---|---|---|
272| `admin user list` (`admin.go:134`) | Accounts | `cYou()` when pending | `cRef(name)`, `cState(state)`, `cMeta("admin"?, "seen "+relAge)` | `Filter: admin user list --state pending` | `emitPageView` |
273| `admin user show` (`admin.go:175`) | fields User (`cRef`, `cState`, `cMeta("admin"?)`), Seen, Repos (+limit), Sessions | Keys, Emails, PGP keys, Orgs, API tokens | `Manage: admin user disable <name>` or `enable`, `admin user promote`/`demote <name>` by current state | token names and scopes only |
274| `admin runners` (`admin.go:540`) | fields Queue (`cText(n+" pending")`, `cMeta(claimed, wait avg, wait max, reaped)`) | Runners: lead `cGlyph("running")` when holding; `cRef(user)`, `cMeta(scope, seen, held)` | `Prune: admin runners remove <fingerprint>` (first idle runner) | never `forget` |
275| `admin repo list` (`admin.go:407`) | Repositories | none | `cLink(path)`, `cState(vis[, archived])`, `cSize(bytes)`, `cMeta("pushed "+relAge)` | `Filter: admin repo list --visibility private` | keeps the header (size column) |
276| `admin mr prune` (`admin.go:616`) | Pruned | `cGlyph("ok")` / `cGlyph("skipped")` for "already gone" | `cLink("!n")`, `cRef(head)` or `cMeta("already gone")` | none | |
277| `admin stats` (`adminhost.go:291`) | fields Users, Orgs, Repos, Issues (+open), MRs (+open), Disk (`cSize(db)`, `cMeta(repos, lfs)`) | Repositories: top 20 by bytes, `cLink(path)`, `cSize`; `more: admin repo list` | `Admin: admin user list`, `admin runners` | the dashboard's Problems line points here |
278| `admin mail inbound check` (`adminmail.go:25`) | fields Inbound (server / mailbox), Messages (`cMeta(unseen)`), Auth (`cMark("✗ …", sgrRed)` on warning, else `cMark("✓ dkim", sgrGreen)`) | | none | off: one field `Inbound: off` |
279
280## Task 9: `view` becomes piped-only; docs; MR 3c
281
282**Files:** `view.go`, `view_test.go`, `.gitbay/wiki/Users.org`, `CHANGELOG.org`.
283
284- [ ] **Step 1: Check nothing is left.** Run the stage-2 inventory again:
285
286```bash
287cd internal/control && grep -lE 'c\.table\(|c\.view\(' *.go | grep -v _test
288```
289
290and, for each file, confirm every `c.view(`/`c.table(` sits inside a `plain` closure passed to `emitView`/`emitPageView`, or behind `c.Term.Cols == 0`. List any exception in the MR body.
291
292- [ ] **Step 2: Failing test.**
293
294```go
295// view is the piped writer: at a terminal it writes what it writes
296// piped, so a caller that never migrated falls back to the plain layout.
297func TestViewIgnoresTerminal(t *testing.T) {
298 render := func(term Term) string {
299 var b bytes.Buffer
300 c := &Ctx{Term: term}
301 v := c.view(&b)
302 v.title("#1", "A title", "open")
303 v.fields("author", "alice")
304 v.section("files")
305 return b.String()
306 }
307 if got, want := render(Term{Cols: 40, Color: true}), render(Term{}); got != want {
308 t.Errorf("terminal view differs from piped:\n%q\n%q", got, want)
309 }
310}
311```
312
313- [ ] **Step 3: Strip `view`'s terminal branches.** In `view.go`: `title`, `section`, `fields`, `body`, `event`, `comment` keep only their `Cols == 0` paths; `opts` returns `termtext.Options{Base: …}` without width or colour; `when` keeps its terminal format only if a screen still calls it (`grep -n 'c.when(' *.go`), otherwise its piped form only. `safe()` calls stay: they are no-ops piped.
314
315- [ ] **Step 4: Run** the package; fix any test that asserted a terminal `view` (each is now a screen test or a piped test).
316
317- [ ] **Step 5: Docs.** Users wiki: replace the "other `show` views" bullet with "every list and show is a screen; `--json` and piped output are unchanged", and drop the per-command lists added in stage 2. CHANGELOG: one line for 3c.
318
319- [ ] **Step 6: Verify, commit `Closes #319`, push `cli-views-3c`, MR** "control: work item and admin screens; view is piped-only (CLI views stage 3c)".
320
321## Self-review notes
322
323- Coverage: the 70 commands from the inventory (25 nouns) appear in tasks 1–8, the eleven `view` callers outside the original list included (`admin mail inbound check`, `repo deps status`, `org show`, `admin stats`, `repo commit`, `org team show`, `emitProfile`, notification settings, `repo settings show`, `admin user show`, `admin runners`); task 9 removes `view`'s terminal branches.
324- Shared emitters each get one builder: `editTopics`, `emitProfile`, `emitNotificationSettings`, `emitMilestones`, `runOrgShow`, `runItemQuery`, label lists.
325- Rows give exact cells and actions; code is written out in full for the helper and the three patterns (list with shared emitter, show, stream with header). The per-command builders follow those patterns from their table rows; that is a deliberate trade against 70 near-identical code blocks.
326- Argument forms marked "check the real arg form" (`repo settings require-mr`) are verified by `checkActions` in the screen test; adjust the argv to whatever passes.
e2e/term_test.go +3 −3
@@ -25,7 +25,7 @@ func TestTermEnvSelectsTerminalOutput(t *testing.T) {
2525 t.Errorf("plain repo list: %q", plain)
2626 }
2727 term, _, _ := inst.sshTerm(t, key, "80,color", "repo", "list")
28 if !strings.HasPrefix(term, "\x1b[2malice/app\x1b[0m ") {
28 if !strings.HasPrefix(term, "\x1b[1m\x1b[34mRepositories (1)") {
2929 t.Errorf("terminal repo list: %q", term)
3030 }
3131}
@@ -75,10 +75,10 @@ func TestTermEnvOverMultiplexedSession(t *testing.T) {
7575 exec.Command("ssh", "-o", "ControlPath="+sock, "-O", "exit", "git@127.0.0.1").Run()
7676 })
7777
78 if out := mux("80"); !strings.HasPrefix(out, "alice/app ") {
78 if out := mux("80"); !strings.HasPrefix(out, "Repositories (1)\nalice/app public") {
7979 t.Fatalf("master session: %q", out)
8080 }
81 if out := mux("80,color"); !strings.HasPrefix(out, "\x1b[2malice/app") {
81 if out := mux("80,color"); !strings.HasPrefix(out, "\x1b[1m\x1b[34mRepositories") {
8282 t.Errorf("second session kept the master's GITBAY_TERM: %q", out)
8383 }
8484 if out := mux(""); !strings.HasPrefix(out, "alice/app\t") {
internal/control/build.go +10 −1
@@ -564,12 +564,21 @@ func runSecretList(c *Ctx, args []string) int {
564564 if err != nil {
565565 return c.fail(protocol.ExitFailure, "%v", err)
566566 }
567 return c.emit(names, func(w io.Writer) {
567 return c.emitView(names, func(w io.Writer) {
568568 tb := c.table(w, "NAME")
569569 for _, n := range names {
570570 tb.row(cRef(n))
571571 }
572572 tb.flush()
573 }, func() screen {
574 rows := make([]row, len(names))
575 for i, n := range names {
576 rows[i] = rowOf(cRef(n))
577 }
578 return listScreen("Build secrets", rows,
579 action{"Secrets", []string{"repo", "secret", "set", repo.Path(), "<NAME>"}},
580 action{"Secrets", []string{"repo", "secret", "remove", repo.Path(), "<NAME>"}},
581 )
573582 })
574583}
575584
internal/control/deploykey.go +11 −1
@@ -119,13 +119,23 @@ func runDeployKeyList(c *Ctx, args []string) int {
119119 ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label, k.LastUsedAt, k.ExpiresAt})
120120 }
121121 now := time.Now()
122 return c.emit(ds, func(w io.Writer) {
122 return c.emitView(ds, func(w io.Writer) {
123123 tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL", "USED", "EXPIRES")
124124 for _, d := range ds {
125125 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label),
126126 cText(c.usedText(d.LastUsedAt)), cText(c.expiresText(d.ExpiresAt, now)))
127127 }
128128 tb.flush()
129 }, func() screen {
130 rows := make([]row, len(ds))
131 for i, d := range ds {
132 rows[i] = rowOf(cFlexRef(d.Fingerprint), cState(d.Mode), cText(d.Label),
133 cMeta(d.Algo, "used "+c.usedText(d.LastUsedAt), c.expiresText(d.ExpiresAt, now)))
134 }
135 return listScreen("Deploy keys", rows,
136 action{"Keys", []string{"repo", "deploy-key", "add", repo.Path()}},
137 action{"Keys", []string{"repo", "deploy-key", "remove", repo.Path(), "<fingerprint>"}},
138 )
129139 })
130140}
131141
internal/control/deps.go +29 −2
@@ -4,6 +4,7 @@ import (
44 "errors"
55 "fmt"
66 "io"
7 "strconv"
78
89 "gitbay.org/gitbay/internal/policy"
910 "gitbay.org/gitbay/internal/protocol"
@@ -92,9 +93,14 @@ func runDepsStatus(c *Ctx, args []string) int {
9293 }
9394 check, err := c.Store.DepCheckFor(repo.ID)
9495 if errors.Is(err, store.ErrNotFound) {
95 return c.emit(map[string]any{"enabled": false}, func(w io.Writer) {
96 return c.emitView(map[string]any{"enabled": false}, func(w io.Writer) {
9697 v := c.view(w)
9798 v.fields("checks", fmt.Sprintf("off (repo deps enable %s)", repo.Path()))
99 }, func() screen {
100 return screen{
101 fields: []field{{"Checks", []cell{cText("off")}}},
102 actions: []action{{"Deps", []string{"repo", "deps", "enable", repo.Path()}}},
103 }
98104 })
99105 }
100106 if err != nil {
@@ -109,7 +115,7 @@ func runDepsStatus(c *Ctx, args []string) int {
109115 for _, r := range reports {
110116 out.Behind = append(out.Behind, DepBehind{r.Ecosystem, r.Name, r.Current, r.Latest})
111117 }
112 return c.emit(out, func(w io.Writer) {
118 return c.emitView(out, func(w io.Writer) {
113119 tracked := ""
114120 if check.IssueNumber != 0 {
115121 tracked = fmt.Sprintf("#%d", check.IssueNumber)
@@ -129,5 +135,26 @@ func runDepsStatus(c *Ctx, args []string) int {
129135 }
130136 tb.flush()
131137 }
138 }, func() screen {
139 state := []cell{cGlyph("ok"), cText("on")}
140 if check.LastError != "" {
141 state = []cell{cGlyph("failed"), cText("on")}
142 }
143 s := screen{fields: []field{
144 {"Checks", state},
145 {"Last check", []cell{cAge(check.LastCheck)}},
146 {"Error", []cell{cMark(check.LastError, sgrRed)}},
147 }}
148 if check.IssueNumber != 0 {
149 n := strconv.FormatInt(check.IssueNumber, 10)
150 s.fields = append(s.fields, field{"Tracked in", []cell{cLink("#"+n, c.siteURL(repo.Path(), "issues", n))}})
151 }
152 behind := section{title: "Behind", n: len(out.Behind)}
153 for _, b := range out.Behind {
154 behind.rows = append(behind.rows, rowOf(cRef(b.Name), cText(b.Ecosystem), cMeta(b.Current+" → "+b.Latest)))
155 }
156 s.sections = []section{behind}
157 s.actions = []action{{"Deps", []string{"repo", "deps", "disable", repo.Path()}}}
158 return s
132159 })
133160}
internal/control/mirrorcmd.go +21 −1
@@ -122,7 +122,7 @@ func runMirrorList(c *Ctx, args []string) int {
122122 // The token never leaves the server, in any encoding.
123123 ds = append(ds, out{m.ID, m.Direction, m.URL, m.Username, m.Dirty, m.LastSync, m.LastError})
124124 }
125 return c.emit(ds, func(w io.Writer) {
125 return c.emitView(ds, func(w io.Writer) {
126126 tb := c.table(w, "ID", "DIRECTION", "URL", "LAST", "STATUS")
127127 for _, d := range ds {
128128 status := "ok"
@@ -135,6 +135,26 @@ func runMirrorList(c *Ctx, args []string) int {
135135 tb.row(cRef(fmt.Sprintf("%d", d.ID)), cText(d.Direction), cText(d.URL), cText("last "+orDash(d.LastSync)), cState(status))
136136 }
137137 tb.flush()
138 }, func() screen {
139 rows := make([]row, len(ds))
140 for i, d := range ds {
141 state := "ok"
142 if d.Pending {
143 state = "pending"
144 }
145 if d.LastError != "" {
146 state = "error"
147 }
148 synced := "never synced"
149 if d.LastSync != "" {
150 synced = "synced " + relAge(d.LastSync, termNow())
151 }
152 rows[i] = rowOf(cRef(strconv.FormatInt(d.ID, 10)), cGlyph(state), cFlex(d.URL), cMeta(d.Direction, synced), cMark(d.LastError, sgrRed))
153 }
154 return listScreen("Mirrors", rows,
155 action{"Mirrors", []string{"repo", "mirror", "sync", repo.Path()}},
156 action{"Mirrors", []string{"repo", "mirror", "remove", repo.Path(), "<id>"}},
157 )
138158 })
139159}
140160
internal/control/pagescmd.go +14 −1
@@ -220,11 +220,24 @@ func runDomainList(c *Ctx, args []string) int {
220220 }
221221 list = append(list, out{d.Domain, state, d.VerifiedAt})
222222 }
223 return c.emit(list, func(w io.Writer) {
223 return c.emitView(list, func(w io.Writer) {
224224 tb := c.table(w, "DOMAIN", "STATE")
225225 for _, d := range list {
226226 tb.row(cRef(d.Domain), cState(d.State))
227227 }
228228 tb.flush()
229 }, func() screen {
230 rows := make([]row, len(list))
231 for i, d := range list {
232 state := d.State
233 if state == "expired" {
234 state = "failed"
235 }
236 rows[i] = rowOf(cGlyph(state), cRef(d.Domain), cState(d.State))
237 }
238 return listScreen("Pages domains", rows,
239 action{"Domains", []string{"repo", "domain", "add", repo.Path(), "<domain>"}},
240 action{"Domains", []string{"repo", "domain", "verify", repo.Path(), "<domain>"}},
241 )
229242 })
230243}
internal/control/read.go +51 −26
@@ -108,24 +108,34 @@ func runRepoRefs(c *Ctx, args []string) int {
108108 // Tags read newest version first; the JSON keeps git's order.
109109 sorted := slices.Clone(tags)
110110 gitutil.SortVersions(sorted)
111 return c.emit(d, func(w io.Writer) {
111 return c.emitView(d, func(w io.Writer) {
112112 tb := c.table(w, "KIND", "NAME", "SHA")
113 if c.Term.Cols > 0 {
114 for _, ref := range d.Branches {
115 if ref.Name == repo.DefaultBranch {
116 tb.row(cText("default"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
117 }
118 }
119 }
120113 for _, ref := range d.Branches {
121 if c.Term.Cols == 0 || ref.Name != repo.DefaultBranch {
122 tb.row(cText("branch"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
123 }
114 tb.row(cText("branch"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
124115 }
125116 for _, ref := range sorted {
126117 tb.row(cText("tag"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
127118 }
128119 tb.flush()
120 }, func() screen {
121 branches := section{title: "Branches", n: len(d.Branches)}
122 for _, r := range d.Branches {
123 if r.Name == repo.DefaultBranch {
124 branches.rows = append(branches.rows, rowOf(cRef(r.Name), cMeta(fmt.Sprintf("%.10s", r.SHA), "default")))
125 }
126 }
127 for _, r := range d.Branches {
128 if r.Name != repo.DefaultBranch {
129 branches.rows = append(branches.rows, rowOf(cRef(r.Name), cMeta(fmt.Sprintf("%.10s", r.SHA))))
130 }
131 }
132 tags := section{title: "Tags", n: len(sorted)}
133 for _, r := range sorted {
134 tags.rows = append(tags.rows, rowOf(cRef(r.Name), cMeta(fmt.Sprintf("%.10s", r.SHA))))
135 }
136 return screen{sections: []section{branches, tags}, actions: []action{
137 {"Read", []string{"repo", "log", repo.Path(), "--ref", repo.DefaultBranch}},
138 }}
129139 })
130140}
131141
@@ -323,21 +333,7 @@ func runRepoTree(c *Ctx, args []string) int {
323333 }
324334 d.Entries = append(d.Entries, eo)
325335 }
326 return c.emit(d, func(w io.Writer) {
327 if c.Term.Cols > 0 {
328 tb := c.table(w, "NAME", "SIZE", "SHA")
329 for _, e := range d.Entries {
330 name, size := cText(e.Name), cText("")
331 if e.Type == "tree" {
332 name = cMark(e.Name+"/", sgrCyan)
333 } else {
334 size = cSize(e.Size)
335 }
336 tb.row(name, size, cMark(e.SHA[:min(10, len(e.SHA))], sgrDim))
337 }
338 tb.flush()
339 return
340 }
336 return c.emitView(d, func(w io.Writer) {
341337 tb := c.table(w, "SHA", "SIZE", "NAME")
342338 for _, e := range d.Entries {
343339 name := e.Name
@@ -351,6 +347,35 @@ func runRepoTree(c *Ctx, args []string) int {
351347 tb.row(cRef(e.SHA[:min(10, len(e.SHA))]), size, cFlex(name))
352348 }
353349 tb.flush()
350 }, func() screen {
351 title := repo.Path()
352 if dirPath != "" {
353 title += "/" + dirPath
354 }
355 rows := make([]row, len(d.Entries))
356 firstFile := ""
357 for i, e := range d.Entries {
358 name, size := cText(e.Name), cText("")
359 if e.Type == "tree" {
360 name = cMark(e.Name+"/", sgrBlue)
361 } else {
362 size = cSize(e.Size)
363 if firstFile == "" {
364 firstFile = path.Join(dirPath, e.Name)
365 }
366 }
367 rows[i] = rowOf(name, size, cMeta(e.SHA[:min(10, len(e.SHA))]))
368 }
369 s := listScreen(title+" at "+ref, rows)
370 if firstFile != "" {
371 s.actions = append(s.actions, action{"Read", []string{"repo", "cat", repo.Path(), firstFile, "--ref", ref}})
372 }
373 logArgs := []string{"repo", "log", repo.Path(), "--ref", ref}
374 if dirPath != "" {
375 logArgs = append(logArgs, "--path", dirPath)
376 }
377 s.actions = append(s.actions, action{"Read", logArgs})
378 return s
354379 })
355380}
356381
internal/control/repo.go +114 −7
@@ -360,7 +360,7 @@ func runRepoList(c *Ctx, args []string) int {
360360 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
361361 ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived})
362362 }
363 return c.emitPage(p, ds, next, func(w io.Writer) {
363 return c.emitPageView(p, ds, next, func(w io.Writer) {
364364 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
365365 for _, d := range ds {
366366 cells := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description)}
@@ -370,6 +370,20 @@ func runRepoList(c *Ctx, args []string) int {
370370 tb.row(cells...)
371371 }
372372 tb.flush()
373 }, func() screen {
374 rows := make([]row, len(ds))
375 for i, d := range ds {
376 state := d.Visibility
377 if d.Archived {
378 state += ", archived"
379 }
380 rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cState(state), cFlex(d.Description))
381 }
382 s := listScreen("Repositories", rows)
383 if len(ds) > 0 {
384 s.actions = []action{{"Read", []string{"repo", "show", ds[0].Path}}}
385 }
386 return s
373387 })
374388}
375389
@@ -771,12 +785,21 @@ func runAccessList(c *Ctx, args []string) int {
771785 for _, e := range entries {
772786 ds = append(ds, out{e.Username, e.Role, e.Source})
773787 }
774 return c.emit(ds, func(w io.Writer) {
788 return c.emitView(ds, func(w io.Writer) {
775789 tb := c.table(w, "USER", "ROLE", "SOURCE")
776790 for _, d := range ds {
777791 tb.row(cRef(d.User), cState(d.Role), cText("via "+d.Source))
778792 }
779793 tb.flush()
794 }, func() screen {
795 rows := make([]row, len(ds))
796 for i, d := range ds {
797 rows[i] = rowOf(cRef(d.User), cState(d.Role), cMeta("via "+d.Source))
798 }
799 return listScreen("Access", rows,
800 action{"Access", []string{"repo", "access", "grant", repo.Path(), "<user>", "write"}},
801 action{"Access", []string{"repo", "access", "revoke", repo.Path(), "<user>"}},
802 )
780803 })
781804}
782805
@@ -788,7 +811,7 @@ func runSettingsShow(c *Ctx, args []string) int {
788811 if code >= 0 {
789812 return code
790813 }
791 return c.emit(repo.Settings, func(w io.Writer) {
814 return c.emitView(repo.Settings, func(w io.Writer) {
792815 v := c.view(w)
793816 v.title(repo.Path(), "settings", "")
794817 v.fields(
@@ -801,6 +824,49 @@ func runSettingsShow(c *Ctx, args []string) int {
801824 "git daemon", strconv.FormatBool(repo.Settings.GitDaemon),
802825 "archived", strconv.FormatBool(repo.Settings.Archived),
803826 )
827 }, func() screen {
828 set := repo.Settings
829 onOff := func(b bool) cell {
830 if b {
831 return cText("on")
832 }
833 return cMeta("off")
834 }
835 approvals := cMeta("off")
836 if set.RequireApprovals > 0 {
837 approvals = cText(strconv.Itoa(set.RequireApprovals))
838 }
839 list := func(xs []string) cell {
840 if len(xs) == 0 {
841 return cMeta("none")
842 }
843 return cText(strings.Join(xs, ", "))
844 }
845 mr := "on"
846 if set.RequireMR {
847 mr = "off"
848 }
849 s := screen{fields: []field{
850 {"Repo", []cell{cLink(repo.Path(), c.siteURL(repo.Path())), cMeta("settings")}},
851 {"Protected", []cell{list(set.ProtectedBranches)}},
852 {"Protected tags", []cell{list(set.ProtectedTags)}},
853 {"Require MR", []cell{onOff(set.RequireMR)}},
854 {"Require checks", []cell{onOff(set.RequireChecks)}},
855 {"Contexts", []cell{list(set.RequiredContexts)}},
856 {"Approvals", []cell{approvals}},
857 {"Resolved threads", []cell{onOff(set.RequireResolved)}},
858 {"Code owners", []cell{onOff(set.RequireCodeowners)}},
859 {"Signed commits", []cell{onOff(set.RequireSignedCommits)}},
860 {"Git daemon", []cell{onOff(set.GitDaemon)}},
861 {"Archived", []cell{onOff(set.Archived)}},
862 }, actions: []action{
863 {"Settings", []string{"repo", "settings", "protect", repo.Path(), "<branch>"}},
864 {"Settings", []string{"repo", "settings", "require-mr", repo.Path(), mr}},
865 }}
866 if set.Website != "" {
867 s.fields = append(s.fields, field{"Website", []cell{cText(set.Website)}})
868 }
869 return s
804870 })
805871}
806872
@@ -960,6 +1026,19 @@ func archiveRepo(c *Ctx, repo store.Repo, archived bool) int {
9601026 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, repo.Path()) })
9611027}
9621028
1029// topicsScreen is a repository's topics at a terminal, after a read or
1030// an edit.
1031func topicsScreen(repo store.Repo, topics []string) screen {
1032 rows := make([]row, len(topics))
1033 for i, t := range topics {
1034 rows[i] = rowOf(cRef(t))
1035 }
1036 return listScreen("Topics", rows,
1037 action{"Edit", []string{"repo", "topics", "add", repo.Path(), "<topic>"}},
1038 action{"Edit", []string{"repo", "topics", "remove", repo.Path(), "<topic>"}},
1039 )
1040}
1041
9631042func runTopicsList(c *Ctx, args []string) int {
9641043 if len(args) != 1 {
9651044 return c.usage()
@@ -972,12 +1051,14 @@ func runTopicsList(c *Ctx, args []string) int {
9721051 if err != nil {
9731052 return c.fail(protocol.ExitFailure, "%v", err)
9741053 }
975 return c.emit(topics, func(w io.Writer) {
1054 return c.emitView(topics, func(w io.Writer) {
9761055 tb := c.table(w, "TOPIC")
9771056 for _, t := range topics {
9781057 tb.row(cRef(t))
9791058 }
9801059 tb.flush()
1060 }, func() screen {
1061 return topicsScreen(repo, topics)
9811062 })
9821063}
9831064
@@ -1031,12 +1112,14 @@ func editTopics(c *Ctx, args []string, add bool) int {
10311112 if err != nil {
10321113 return c.fail(protocol.ExitFailure, "%v", err)
10331114 }
1034 return c.emit(now, func(w io.Writer) {
1115 return c.emitView(now, func(w io.Writer) {
10351116 tb := c.table(w, "TOPIC")
10361117 for _, t := range now {
10371118 tb.row(cRef(t))
10381119 }
10391120 tb.flush()
1121 }, func() screen {
1122 return topicsScreen(repo, now)
10401123 })
10411124}
10421125
@@ -1079,12 +1162,22 @@ func runRepoSearch(c *Ctx, args []string) int {
10791162 }
10801163 ds = append(ds, out{r.Path(), r.Visibility, desc, topics})
10811164 }
1082 return c.emit(ds, func(w io.Writer) {
1165 return c.emitView(ds, func(w io.Writer) {
10831166 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
10841167 for _, d := range ds {
10851168 tb.row(cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description))
10861169 }
10871170 tb.flush()
1171 }, func() screen {
1172 rows := make([]row, len(ds))
1173 for i, d := range ds {
1174 rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description), cMeta(strings.Join(d.Topics, ", ")))
1175 }
1176 s := listScreen(fmt.Sprintf("Repositories matching %q", args[0]), rows)
1177 if len(ds) > 0 {
1178 s.actions = []action{{"Read", []string{"repo", "show", ds[0].Path}}}
1179 }
1180 return s
10881181 })
10891182}
10901183
@@ -1249,12 +1342,26 @@ func runRepoBookmarks(c *Ctx, args []string) int {
12491342 Bookmarks: c.Store.BookmarkCount(r.ID),
12501343 })
12511344 }
1252 return c.emit(out, func(w io.Writer) {
1345 return c.emitView(out, func(w io.Writer) {
12531346 tb := c.table(w, "PATH", "COUNT", "DESCRIPTION")
12541347 for _, b := range out {
12551348 tb.row(cRef(b.Path), cNum(int64(b.Bookmarks)), cFlex(b.Description))
12561349 }
12571350 tb.flush()
1351 }, func() screen {
1352 rows := make([]row, len(out))
1353 for i, b := range out {
1354 n := fmt.Sprintf("%d bookmarks", b.Bookmarks)
1355 if b.Bookmarks == 1 {
1356 n = "1 bookmark"
1357 }
1358 rows[i] = rowOf(cLink(b.Path, c.siteURL(b.Path)), cState(b.Visibility), cFlex(b.Description), cMeta(n))
1359 }
1360 s := listScreen("Bookmarks", rows)
1361 if len(out) > 0 {
1362 s.actions = []action{{"Read", []string{"repo", "show", out[0].Path}}}
1363 }
1364 return s
12581365 })
12591366}
12601367
internal/control/runnerrepo.go +17 −1
@@ -114,7 +114,7 @@ func runRepoRunnerList(c *Ctx, args []string) int {
114114 if runners == nil {
115115 runners = []store.RepoRunner{}
116116 }
117 return c.emit(runners, func(w io.Writer) {
117 return c.emitView(runners, func(w io.Writer) {
118118 tb := c.table(w, "FINGERPRINT", "ALGO", "USER", "SEEN", "HELD")
119119 for _, r := range runners {
120120 seen := r.LastSeen
@@ -128,6 +128,22 @@ func runRepoRunnerList(c *Ctx, args []string) int {
128128 tb.row(cRef(r.Fingerprint), cText(r.Algo), cText(r.Username), cAge(seen), cText(held))
129129 }
130130 tb.flush()
131 }, func() screen {
132 rows := make([]row, len(runners))
133 for i, r := range runners {
134 lead, held := cGlyph(""), ""
135 if r.BuildNumber != 0 {
136 lead, held = cGlyph("running"), fmt.Sprintf("building %s #%d %s", r.BuildRepo, r.BuildNumber, r.BuildJob)
137 }
138 seen := "never seen"
139 if r.LastSeen != "" {
140 seen = "seen " + relAge(r.LastSeen, termNow())
141 }
142 rows[i] = rowOf(cFlexRef(r.Fingerprint), lead, cText(r.Username), cMeta(r.Algo, seen, held))
143 }
144 return listScreen("Runners", rows,
145 action{"Runners", []string{"repo", "runner", "remove", repo.Path(), "<fingerprint>"}},
146 )
131147 })
132148}
133149
internal/control/screen.go +6
@@ -57,6 +57,12 @@ type action struct {
5757 argv []string
5858}
5959
60// listScreen is a list command's terminal screen: one section of rows
61// and the commands that apply.
62func listScreen(title string, rows []row, actions ...action) screen {
63 return screen{sections: []section{{title: title, n: len(rows), rows: rows}}, actions: actions}
64}
65
6066// render draws s: the parts in order, one blank line between parts and
6167// between sections.
6268func (c *Ctx) render(w io.Writer, s screen) {
internal/control/sig.go +36 −29
@@ -225,22 +225,28 @@ func runRepoLog(c *Ctx, args []string) int {
225225 }
226226 ds = append(ds, d)
227227 }
228 return c.emit(ds, func(w io.Writer) {
229 if c.Term.Cols > 0 {
230 tb := c.table(w, "SHA", "SUBJECT", "AUTHOR", "WHEN", "SIGNATURE")
231 for _, d := range ds {
232 tb.row(cRef(fmt.Sprintf("%.10s", d.SHA)), cFlex(d.Subject), cText(d.AuthorName),
233 cAge(d.Date), cState(d.Signature.State))
234 }
235 tb.flush()
236 return
237 }
228 return c.emitView(ds, func(w io.Writer) {
238229 tb := c.table(w, "SHA", "STATE", "SUBJECT", "AUTHOR")
239230 for _, d := range ds {
240231 tb.row(cRef(fmt.Sprintf("%.10s", d.SHA)), cState(d.Signature.State), cFlex(d.Subject),
241232 cText(fmt.Sprintf("(%s <%s>)", d.AuthorName, d.AuthorEmail)))
242233 }
243234 tb.flush()
235 }, func() screen {
236 title := "Commits on " + ref
237 if filePath != "" {
238 title += " touching " + filePath
239 }
240 rows := make([]row, len(ds))
241 for i, d := range ds {
242 rows[i] = rowOf(cRef(fmt.Sprintf("%.10s", d.SHA)), cGlyph(d.Signature.State), cFlex(d.Subject), cMeta(d.AuthorName, relAge(d.Date, termNow())))
243 }
244 s := listScreen(title, rows)
245 if len(ds) > 0 {
246 s.actions = append(s.actions, action{"Read", []string{"repo", "commit", repo.Path(), ds[0].SHA[:min(12, len(ds[0].SHA))]}})
247 }
248 s.actions = append(s.actions, action{"Read", []string{"repo", "tree", repo.Path(), "--ref", ref}})
249 return s
244250 })
245251}
246252
@@ -348,27 +354,28 @@ func runRepoCommit(c *Ctx, args []string) int {
348354 fmt.Fprintf(w, "\n%s", d.Diff)
349355 return
350356 }
351 v := c.view(w)
352 v.title(d.SHA[:10], d.Subject, d.Signature.State)
353 signer := d.Signature.Signer
354 if signer != "" && d.Signature.Fingerprint != "" {
355 signer += ", key " + d.Signature.Fingerprint
357 short, url := d.SHA[:min(10, len(d.SHA))], c.siteURL(repo.OwnerName, repo.Name, "commit", d.SHA[:min(12, len(d.SHA))])
358 s := screen{body: body, format: "text", fields: []field{
359 {"Commit", []cell{cLink(short, url), cText(d.Subject)}},
360 {"Author", []cell{cText(fmt.Sprintf("%s <%s>", d.AuthorName, d.AuthorEmail)), cAge(d.Date)}},
361 }}
362 if d.CommitterEmail != "" && d.CommitterEmail != d.AuthorEmail {
363 s.fields = append(s.fields, field{"Committer", []cell{cText(d.CommitterEmail)}})
356364 }
357 v.fields(
358 "author", fmt.Sprintf("%s <%s>, %s", d.AuthorName, d.AuthorEmail, c.when(d.Date)),
359 "committer", d.CommitterEmail,
360 "signer", signer,
361 "url", c.siteURL(repo.OwnerName, repo.Name, "commit", d.SHA[:12]),
362 )
363 v.text(body)
364 if len(d.Checks) > 0 {
365 v.section("checks")
366 tb := c.table(w, "CHECK", "STATE")
367 for _, ch := range d.Checks {
368 tb.row(cText(ch.Context), cState(ch.State))
369 }
370 tb.flush()
365 s.fields = append(s.fields, field{"Signed", []cell{cGlyph(d.Signature.State), cState(d.Signature.State), cMeta(d.Signature.Signer, d.Signature.Fingerprint)}})
366 if !c.Term.Links {
367 s.fields = append(s.fields, field{"URL", []cell{cText(url)}})
368 }
369 checks := section{title: "Checks", n: len(d.Checks)}
370 for _, ch := range d.Checks {
371 checks.rows = append(checks.rows, rowOf(cGlyph(ch.State), cFlex(ch.Context)))
372 }
373 s.sections = []section{checks}
374 s.actions = []action{
375 {"Read", []string{"repo", "log", repo.Path(), "--ref", short}},
376 {"Read", []string{"repo", "tree", repo.Path(), "--ref", short}},
371377 }
378 c.render(w, s)
372379 fmt.Fprintf(w, "\n%s", c.Term.diff(d.Diff))
373380 })
374381}
internal/control/stage3repo_test.go added +316
@@ -0,0 +1,316 @@
1package control
2
3import (
4 "bytes"
5 "crypto/ed25519"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/store"
14 "golang.org/x/crypto/ssh"
15)
16
17// dispatchAs runs argv as u with stdin, piped, and returns stdout; a
18// non-zero exit fails t.
19func dispatchAs(t *testing.T, st *store.Store, u store.User, stdin string, argv ...string) string {
20 t.Helper()
21 return dispatchIn(t, st, u, "", stdin, argv...)
22}
23
24// dispatchIn is dispatchAs with repositories under root.
25func dispatchIn(t *testing.T, st *store.Store, u store.User, root, stdin string, argv ...string) string {
26 t.Helper()
27 out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
28 c := &Ctx{User: u, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Stdin: strings.NewReader(stdin)}
29 c.Cfg.Server.Root = root
30 // Fixtures write a lot as user 1; the write limiter is global to
31 // the package, so leave its budget to the tests that exercise it.
32 c.Cfg.Limits.WriteRate = -1
33 if code := Dispatch(c, argv); code != protocol.ExitOK {
34 t.Fatalf("%v: exit %d: %s", argv, code, errOut)
35 }
36 return out.String()
37}
38
39// atTerminal runs argv as u at a 100-column terminal without colour
40// and returns stdout; a non-zero exit fails t.
41func atTerminal(t *testing.T, st *store.Store, u store.User, argv ...string) string {
42 t.Helper()
43 return atTerminalIn(t, st, u, "", argv...)
44}
45
46// atTerminalIn is atTerminal with repositories under root.
47func atTerminalIn(t *testing.T, st *store.Store, u store.User, root string, argv ...string) string {
48 t.Helper()
49 out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
50 c := &Ctx{User: u, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Stdin: strings.NewReader(""), Term: Term{Cols: 100}}
51 c.Cfg.Server.Root = root
52 // Fixtures write a lot as user 1; the write limiter is global to
53 // the package, so leave its budget to the tests that exercise it.
54 c.Cfg.Limits.WriteRate = -1
55 if code := Dispatch(c, argv); code != protocol.ExitOK {
56 t.Fatalf("%v: exit %d: %s", argv, code, errOut)
57 }
58 return out.String()
59}
60
61// checkLegend fails t for any suggested command in a rendered screen
62// (a line starting "gitbay ", or after "+n more ") that the registry
63// would not dispatch with the flags it names.
64func checkLegend(t *testing.T, out string) {
65 t.Helper()
66 var s screen
67 for _, line := range strings.Split(out, "\n") {
68 if i := strings.Index(line, "more gitbay "); i >= 0 {
69 line = line[i+len("more "):]
70 }
71 for _, cmd := range strings.Split(line, " ") {
72 cmd = strings.TrimSpace(cmd)
73 if !strings.HasPrefix(cmd, "gitbay ") {
74 continue
75 }
76 argv, err := protocol.Tokenize(strings.TrimPrefix(cmd, "gitbay "))
77 if err != nil {
78 t.Errorf("%q: %v", cmd, err)
79 continue
80 }
81 s.actions = append(s.actions, action{"", argv})
82 }
83 }
84 if len(s.actions) == 0 {
85 t.Errorf("no suggested commands in:\n%s", out)
86 }
87 checkActions(t, s)
88}
89
90// newPubKey is an ed25519 public key in authorized_keys form, the same
91// on every run so its fingerprint can be pinned.
92func newPubKey(t *testing.T) string {
93 t.Helper()
94 pub := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, ed25519.SeedSize)).Public().(ed25519.PublicKey)
95 sp, err := ssh.NewPublicKey(pub)
96 if err != nil {
97 t.Fatal(err)
98 }
99 return string(ssh.MarshalAuthorizedKey(sp))
100}
101
102// repoListsFixture is a repository with something in every list task 1
103// migrates.
104func repoListsFixture(t *testing.T) (*store.Store, store.Repo, store.User) {
105 t.Helper()
106 st, repo, uid := newQueueTestRepo(t)
107 owner := store.User{ID: uid, Username: "alice", IsAdmin: true}
108 p := repo.Path()
109 dispatchAs(t, st, owner, "", "repo", "topics", "add", p, "cli", "forge")
110 dispatchAs(t, st, owner, "s3cret\n", "repo", "secret", "set", p, "DEPLOY_TOKEN")
111 dispatchAs(t, st, owner, testRunnerPub, "repo", "deploy-key", "add", p)
112 dispatchAs(t, st, owner, "", "repo", "domain", "add", p, "docs.example.test")
113 dispatchAs(t, st, owner, "", "repo", "bookmark", p)
114 // mirror add resolves the host, so the fixture goes to the store.
115 if _, err := st.AddMirror(repo.ID, "push", "https://mirror.example.test/app.git", "", "tok"); err != nil {
116 t.Fatal(err)
117 }
118 dispatchAs(t, st, owner, newPubKey(t), "repo", "runner", "add", p)
119 return st, repo, owner
120}
121
122func TestRepoListsPlainPinned(t *testing.T) {
123 st, repo, owner := repoListsFixture(t)
124 p := repo.Path()
125 for name, argv := range map[string][]string{
126 "repo-topics": {"repo", "topics", p},
127 "repo-secret-list": {"repo", "secret", "list", p},
128 "repo-deploy-key-list": {"repo", "deploy-key", "list", p},
129 "repo-domain-list": {"repo", "domain", "list", p},
130 "repo-access-list": {"repo", "access", "list", p},
131 "repo-bookmarks": {"repo", "bookmarks"},
132 "repo-search": {"repo", "search", "app"},
133 "repo-list": {"repo", "list"},
134 "repo-mirror-list": {"repo", "mirror", "list", p},
135 "repo-runner-list": {"repo", "runner", "list", p},
136 } {
137 pinPlain(t, name, dispatchAs(t, st, owner, "", argv...))
138 }
139 pinPlain(t, "repo-topics-add", dispatchAs(t, st, owner, "", "repo", "topics", "add", p, "git"))
140}
141
142func TestRepoListScreens(t *testing.T) {
143 st, repo, owner := repoListsFixture(t)
144 p := repo.Path()
145 for _, tc := range []struct {
146 argv []string
147 want []string
148 }{
149 {[]string{"repo", "topics", p}, []string{"Topics (2)\ncli\nforge\n"}},
150 {[]string{"repo", "topics", "add", p, "git"}, []string{"Topics (3)\n"}},
151 {[]string{"repo", "secret", "list", p}, []string{"Build secrets (1)\nDEPLOY_TOKEN\n"}},
152 {[]string{"repo", "deploy-key", "list", p}, []string{"Deploy keys (1)\n", " ro runner@test ssh-ed25519 · used never"}},
153 {[]string{"repo", "domain", "list", p}, []string{"Pages domains (1)\n", "◐ docs.example.test pending"}},
154 {[]string{"repo", "access", "list", p}, []string{"Access (1)\nalice admin via owner\n"}},
155 {[]string{"repo", "mirror", "list", p}, []string{"Mirrors (1)\n1 ◐ https://mirror.example.test/app.git push · never synced"}},
156 {[]string{"repo", "runner", "list", p}, []string{"Runners (1)\n", " alice ssh-ed25519 · never seen"}},
157 {[]string{"repo", "bookmarks"}, []string{"Bookmarks (1)\nalice/app public 1 bookmark\n"}},
158 {[]string{"repo", "search", "app"}, []string{"Repositories matching \"app\" (1)\nalice/app public cli, forge"}},
159 {[]string{"repo", "list"}, []string{"Repositories (1)\nalice/app public\n"}},
160 } {
161 out := atTerminal(t, st, owner, tc.argv...)
162 for _, w := range tc.want {
163 if !strings.Contains(out, w) {
164 t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out)
165 }
166 }
167 if strings.Contains(out, "s3cret") || strings.Contains(out, "tok\n") {
168 t.Errorf("%v: secret on screen:\n%s", tc.argv, out)
169 }
170 checkLegend(t, out)
171 }
172}
173
174// browseFixture is a repository on disk under root: two commits on main
175// (the second touching docs/), a branch, and a tag, with fixed dates so
176// the shas are the same on every run.
177func browseFixture(t *testing.T) (*store.Store, store.Repo, store.User, string) {
178 t.Helper()
179 st, repo, uid := newQueueTestRepo(t)
180 root := t.TempDir()
181 env := append(gitTestEnv(), "GIT_AUTHOR_DATE=2026-09-01T10:00:00Z", "GIT_COMMITTER_DATE=2026-09-01T10:00:00Z")
182 git := func(dir string, args ...string) {
183 t.Helper()
184 cmd := exec.Command("git", args...)
185 cmd.Dir, cmd.Env = dir, env
186 if out, err := cmd.CombinedOutput(); err != nil {
187 t.Fatalf("git %v: %v\n%s", args, err, out)
188 }
189 }
190 src := filepath.Join(root, "src")
191 if err := os.MkdirAll(filepath.Join(src, "docs"), 0o755); err != nil {
192 t.Fatal(err)
193 }
194 os.WriteFile(filepath.Join(src, "README.md"), []byte("# app\n"), 0o644)
195 git(root, "init", "-q", "-b", "main", "src")
196 git(src, "add", ".")
197 git(src, "commit", "-q", "-m", "first")
198 os.WriteFile(filepath.Join(src, "docs", "guide.md"), []byte("# guide\n"), 0o644)
199 git(src, "add", ".")
200 git(src, "commit", "-q", "-m", "docs: guide")
201 git(src, "tag", "v1.0.0")
202 git(src, "branch", "feature")
203 dir := RepoDir(root, repo.OwnerName, repo.Name)
204 os.MkdirAll(filepath.Dir(dir), 0o755)
205 git(root, "clone", "-q", "--bare", src, dir)
206 return st, repo, store.User{ID: uid, Username: "alice"}, root
207}
208
209func TestRepoBrowsePlainPinned(t *testing.T) {
210 st, repo, u, root := browseFixture(t)
211 p := repo.Path()
212 for name, argv := range map[string][]string{
213 "repo-log": {"repo", "log", p},
214 "repo-log-path": {"repo", "log", p, "--path", "docs/guide.md"},
215 "repo-tree": {"repo", "tree", p},
216 "repo-tree-docs": {"repo", "tree", p, "docs"},
217 "repo-refs": {"repo", "refs", p},
218 } {
219 pinPlain(t, name, dispatchIn(t, st, u, root, "", argv...))
220 }
221 sst, srepo, alice, _ := symbolsFixture(t)
222 pinPlain(t, "repo-symbols", dispatchAs(t, sst, alice, "", "repo", "symbols", srepo.Path(), "Pars"))
223}
224
225func TestRepoBrowseScreens(t *testing.T) {
226 st, repo, u, root := browseFixture(t)
227 p := repo.Path()
228 for _, tc := range []struct {
229 argv []string
230 want []string
231 }{
232 {[]string{"repo", "log", p}, []string{"Commits on main (2)\n", " docs: guide t · "}},
233 {[]string{"repo", "log", p, "--path", "docs/guide.md"}, []string{"Commits on main touching docs/guide.md (1)\n"}},
234 {[]string{"repo", "tree", p}, []string{"alice/app at main (2)\n", "docs/", "README.md"}},
235 {[]string{"repo", "tree", p, "docs"}, []string{"alice/app/docs at main (1)\n", "guide.md"}},
236 {[]string{"repo", "refs", p}, []string{"Branches (2)\nmain", "default", "Tags (1)\nv1.0.0"}},
237 } {
238 out := atTerminalIn(t, st, u, root, tc.argv...)
239 for _, w := range tc.want {
240 if !strings.Contains(out, w) {
241 t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out)
242 }
243 }
244 checkLegend(t, out)
245 }
246 sst, srepo, alice, _ := symbolsFixture(t)
247 out := atTerminal(t, sst, alice, "repo", "symbols", srepo.Path(), "Pars")
248 if !strings.Contains(out, "Symbols matching \"Pars\" (") || !strings.Contains(out, "Parse function p.go:1") {
249 t.Errorf("symbols:\n%s", out)
250 }
251 checkLegend(t, out)
252}
253
254func TestRepoShowsPlainPinned(t *testing.T) {
255 st, repo, u, root := browseFixture(t)
256 p := repo.Path()
257 pinPlain(t, "repo-settings-show", dispatchIn(t, st, u, root, "", "repo", "settings", "show", p))
258 pinPlain(t, "repo-deps-status-off", dispatchIn(t, st, u, root, "", "repo", "deps", "status", p))
259 pinPlain(t, "repo-commit", dispatchIn(t, st, u, root, "", "repo", "commit", p, "aee4475ad9"))
260 dispatchIn(t, st, u, root, "", "repo", "deps", "enable", p)
261 pinPlain(t, "repo-deps-status", dispatchIn(t, st, u, root, "", "repo", "deps", "status", p))
262}
263
264func TestRepoShowScreens(t *testing.T) {
265 st, repo, u, root := browseFixture(t)
266 p := repo.Path()
267 for _, tc := range []struct {
268 argv []string
269 want []string
270 }{
271 {[]string{"repo", "settings", "show", p}, []string{"Repo:", "alice/app", "Require MR:", "off", "Approvals:"}},
272 {[]string{"repo", "deps", "status", p}, []string{"Checks: off\n", "gitbay repo deps enable alice/app"}},
273 {[]string{"repo", "commit", p, "aee4475ad9"}, []string{"Commit:", "aee4475ad9 docs: guide", "Author:", "t <t@example.test>", "gitbay repo log alice/app --ref aee4475ad9", "+# guide"}},
274 } {
275 out := atTerminalIn(t, st, u, root, tc.argv...)
276 for _, w := range tc.want {
277 if !strings.Contains(out, w) {
278 t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out)
279 }
280 }
281 checkLegend(t, out)
282 }
283 dispatchIn(t, st, u, root, "", "repo", "deps", "enable", p)
284 out := atTerminalIn(t, st, u, root, "repo", "deps", "status", p)
285 if !strings.Contains(out, "Checks:") || !strings.Contains(out, "gitbay repo deps disable alice/app") {
286 t.Errorf("deps on:\n%s", out)
287 }
288 checkLegend(t, out)
289 if i, j := strings.Index(out, "gitbay repo deps"), strings.Index(out, "Checks:"); i < j {
290 t.Errorf("legend before the header:\n%s", out)
291 }
292}
293
294// At 60 columns no screen line is wider than the terminal, apart from a
295// suggested command: e2e's readonly test holds every read command to it.
296func TestRepoScreensFitSixtyColumns(t *testing.T) {
297 st, repo, owner := repoListsFixture(t)
298 p := repo.Path()
299 for _, argv := range [][]string{
300 {"repo", "deploy-key", "list", p},
301 {"repo", "runner", "list", p},
302 {"repo", "mirror", "list", p},
303 } {
304 out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
305 c := &Ctx{User: owner, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Term: Term{Cols: 60}}
306 c.Cfg.Limits.WriteRate = -1
307 if code := Dispatch(c, argv); code != protocol.ExitOK {
308 t.Fatalf("%v: exit %d: %s", argv, code, errOut)
309 }
310 for _, line := range strings.Split(out.String(), "\n") {
311 if cells(line) > 60 && !strings.HasPrefix(line, "gitbay ") {
312 t.Errorf("%v: %d cells: %q", argv, cells(line), line)
313 }
314 }
315 }
316}
internal/control/symbols.go +21 −1
@@ -115,7 +115,7 @@ func runRepoSymbols(c *Ctx, args []string) int {
115115 for _, r := range rows {
116116 ds = append(ds, symbolOut{r.Name, r.Kind, r.Path, r.Line})
117117 }
118 return c.emitPage(p, ds, next, func(w io.Writer) {
118 return c.emitPageView(p, ds, next, func(w io.Writer) {
119119 tb := c.table(w, "NAME", "KIND", "LOCATION")
120120 for _, d := range ds {
121121 tb.row(cRef(d.Name), cText(d.Kind), cFlex(fmt.Sprintf("%s:%d", d.Path, d.Line)))
@@ -127,6 +127,26 @@ func runRepoSymbols(c *Ctx, args []string) int {
127127 if idx.State == "partial" {
128128 fmt.Fprintf(c.Stderr, "the index is partial: %s\n", idx.Note)
129129 }
130 }, func() screen {
131 rows := make([]row, len(ds))
132 for i, d := range ds {
133 rows[i] = rowOf(cRef(d.Name), cText(d.Kind), cFlex(fmt.Sprintf("%s:%d", d.Path, d.Line)))
134 }
135 if capped {
136 fmt.Fprintf(c.Stderr, "first %d matches; page with --limit and --cursor\n", symbolsUnpaged)
137 }
138 if idx.State == "partial" {
139 fmt.Fprintf(c.Stderr, "the index is partial: %s\n", idx.Note)
140 }
141 s := listScreen(fmt.Sprintf("Symbols matching %q", query), rows)
142 if len(ds) > 0 {
143 cat := []string{"repo", "cat", repo.Path(), ds[0].Path}
144 if ref != "" {
145 cat = append(cat, "--ref", ref)
146 }
147 s.actions = []action{{"Read", cat}}
148 }
149 return s
130150 })
131151}
132152
internal/control/table.go +5
@@ -57,6 +57,11 @@ func cGlyph(state string) cell {
5757 return cell{kind: kindGlyph, s: g, sgr: sgr}
5858}
5959
60// cFlexRef is an identifier too long to keep whole at every width, a
61// key fingerprint: dim like a reference, but cut to fit like a title.
62// Piped and --json output carry it whole.
63func cFlexRef(s string) cell { return cell{kind: kindFlex, s: s, sgr: sgrDim} }
64
6065// cYou is the mark for a row that waits on the viewer.
6166func cYou() cell { return cell{kind: kindGlyph, s: "●", sgr: sgrYellow} }
6267
internal/control/testdata/plain/repo-access-list.txt added +1
@@ -0,0 +1 @@
1alice admin via owner
internal/control/testdata/plain/repo-bookmarks.txt added +1
@@ -0,0 +1 @@
1alice/app 1
internal/control/testdata/plain/repo-commit.txt added +16
@@ -0,0 +1,16 @@
1commit aee4475ad9033773a17e148aafa3105212ac9c10
2Author: t <t@example.test>
3Date: <time>
4
5 docs: guide
6
7 docs/guide.md | 1 +
8 1 file changed, 1 insertion(+)
9
10diff --git a/docs/guide.md b/docs/guide.md
11new file mode 100644
12index 0000000..cd51204
13--- /dev/null
14+++ b/docs/guide.md
15@@ -0,0 +1 @@
16+# guide
internal/control/testdata/plain/repo-deploy-key-list.txt added +1
@@ -0,0 +1 @@
1SHA256:re738AN1/nwapcQTqXzvkuuJNj+U6StCED66ZUJNCdE ssh-ed25519 ro runner@test never never
internal/control/testdata/plain/repo-deps-status-off.txt added +1
@@ -0,0 +1 @@
1 checks off (repo deps enable alice/app)
internal/control/testdata/plain/repo-deps-status.txt added +1
@@ -0,0 +1 @@
1 checks on
internal/control/testdata/plain/repo-domain-list.txt added +1
@@ -0,0 +1 @@
1docs.example.test pending
internal/control/testdata/plain/repo-list.txt added +1
@@ -0,0 +1 @@
1alice/app public
internal/control/testdata/plain/repo-log-path.txt added +1
@@ -0,0 +1 @@
1aee4475ad9 unsigned docs: guide (t <t@example.test>)
internal/control/testdata/plain/repo-log.txt added +2
@@ -0,0 +1,2 @@
1aee4475ad9 unsigned docs: guide (t <t@example.test>)
275e07603f5 unsigned first (t <t@example.test>)
internal/control/testdata/plain/repo-mirror-list.txt added +1
@@ -0,0 +1 @@
11 push https://mirror.example.test/app.git last - pending
internal/control/testdata/plain/repo-refs.txt added +3
@@ -0,0 +1,3 @@
1branch feature aee4475ad9
2branch main aee4475ad9
3tag v1.0.0 aee4475ad9
internal/control/testdata/plain/repo-runner-list.txt added +1
@@ -0,0 +1 @@
1SHA256:z/fSv0Z0RZS+Lccbc6ZoOWwt/fbj1VFJGSBKQbo4icE ssh-ed25519 alice never idle
internal/control/testdata/plain/repo-search.txt added +1
@@ -0,0 +1 @@
1alice/app public
internal/control/testdata/plain/repo-secret-list.txt added +1
@@ -0,0 +1 @@
1DEPLOY_TOKEN
internal/control/testdata/plain/repo-settings-show.txt added +7
@@ -0,0 +1,7 @@
1alice/app settings
2
3 require mr false
4 require checks false
5 require signed commits false
6 git daemon false
7 archived false
internal/control/testdata/plain/repo-symbols.txt added +6
@@ -0,0 +1,6 @@
1Parse function p.go:1
2ParseAll function p.go:2
3ParseArgs function p.go:3
4Parser function p.go:4
5Parser.Run method p.go:40
6parse function p.go:5
internal/control/testdata/plain/repo-topics-add.txt added +3
@@ -0,0 +1,3 @@
1cli
2forge
3git
internal/control/testdata/plain/repo-topics.txt added +2
@@ -0,0 +1,2 @@
1cli
2forge
internal/control/testdata/plain/repo-tree-docs.txt added +1
@@ -0,0 +1 @@
1cd51204800 8 guide.md
internal/control/testdata/plain/repo-tree.txt added +2
@@ -0,0 +1,2 @@
1e2bd1550b1 6 README.md
2604e844ed3 - docs/