docs/plans/2026-09-27-cli-ux.md
2544 lines · 94159 bytes
1# CLI UX small fixes 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:** Close #265, #267 and #268: the dashboard's activity feed reads
6as sentences instead of raw event payloads and stops repeating assigned
7issues; CLI help and usage print the form the caller actually typed
8(`gitbay ...` or `ssh git@host ...`), with the `auth` grouping, the
9`--help` flag and command summaries fixed to match; and five smaller UX
10findings (the unregistered-key message, `issue create` flags, `mr show`
11plurals, a `repo readme` command, and a truncated mirror timestamp).
12
13**Architecture:** No schema changes and no new migrations. The dashboard
14and web feed currently keep two copies of "turn a stored event into a
15sentence" (`internal/httpd/feed.go`) and "the worst of a set of build
16statuses" (`internal/httpd/builds.go`); both move into
17`internal/control` so the CLI can call them directly (same package) and
18`internal/httpd` calls the exported forms. Help and usage already carry
19a `Ctx.Term` set from the CLI's `--term=<cols>[,color]`; `c.program()`
20already picks `"gitbay"` or `"ssh git@<host>"` from it for the `--help`
21path, but `c.usage()`/`c.usageWith()` (the wrong-argument path) do not
22yet call it. Separately, eighteen CLI commands resolve to a server path
23that differs from what cobra's tree spells (`cmd/gitbay/main.go`'s
24`serverPath` annotations): everything under `auth` except a few whose
25noun already matches the registry (`auth email ...` -> `email ...`,
26`auth export` -> `account export`, `auth keys ...` -> `keys ...`, `auth
27pgp ...` -> `pgp ...`, `auth token ...` -> `token ...`, `auth whoami` ->
28`whoami`), and `repo topics list` -> `repo topics`. Printing the
29registered path verbatim for one of these gives a command that does not
30exist — `gitbay keys remove <fp>` is `unknown command "keys"`. #267
31decided the CLI sends its own invoking path and the server prints that
32instead of the registered one wherever they differ; the CLI's `auth`
33grouping, still not a registry path at all, gets the same treatment for
34the several registry prefixes it gathers.
35
36**Tech stack:** Go, `golang.org/x/crypto/ssh`, cobra.
37
38**Spec:** none — these are small, independently-scoped fixes; this plan
39is its own spec.
40
41## Global constraints
42
43- Three MRs, each on its own branch off `main`: `cli-ux-activity`
44 (#265), `cli-ux-help` (#267), `cli-ux-fixes` (#268). No MR depends on
45 either of the others; land in any order.
46- Commits are signed (the repository refuses unsigned ones); messages
47 reference the issue they touch (`Ref #N`), and the last commit that
48 finishes an issue says `Closes #N`. No attribution to any assistant,
49 model or AI anywhere: commits, MR bodies, comments.
50- MR: `gitbay mr create --source <branch> --target main --title "..."`;
51 merge with `gitbay mr merge <n> --strategy ff` once CI is green
52 (this repository requires signed commits, so `squash`/`merge` are
53 refused), then delete the branch locally and on the remote. If the
54 merge reports the branch is behind, rebase onto `main`, force-push,
55 merge again.
56- Locally: `go build ./...`, `go vet ./...`, and the unit tests of every
57 touched package. Run at most the one e2e test being written per task
58 (`go test ./e2e -run TestName -count=1`); CI on bay1 runs the full
59 suite.
60- No new migrations; none of #265/#267/#268 touch the schema. The plan
61 numbers 0078–0079 pre-assigned to "plan 6" go unused.
62- Registries that fail CI when a new thing lacks its row: a `ReadOnly`
63 command needs an entry in `readArgs` in `e2e/readonly_test.go`; a new
64 control command needs a `pass()` entry in `cmd/gitbay/main.go`
65 (`cmd/gitbay/summaries_test.go`'s coverage and `summaries_gen.go`
66 currency checks); a command reading stdin needs `ReadsStdin: true`.
67- `--json` output: field shapes are unchanged throughout this plan.
68 Where a task changes plain-text wording it says so; JSON error
69 strings for usage refusals do change in Part 2 (Task 2.1), which is
70 called out there specifically since no other task touches JSON text.
71- The `.gitbay/wiki/Parity.org` page is updated in the same commit that
72 changes the row it describes (Task 3.4).
73- Writing style: plain, direct, no hype; code comments match the
74 surrounding density; no before/after narration in comments or docs.
75
76## Order and dependencies
77
781. **`cli-ux-activity`** — closes #265. Independent.
792. **`cli-ux-help`** — closes #267. Independent.
803. **`cli-ux-fixes`** — closes #268. Independent.
81
82None of these three depend on any of the other five plans running in
83parallel (credentials-and-sessions, ci-trust-and-build-reporting,
84server-hardening, data-at-rest-and-backup, web-ux); nothing here touches
85authentication, secrets, CI, backups or the pages those plans change.
86
87---
88
89# Part 1: dashboard activity, no duplicates, one empty-state wording (branch `cli-ux-activity`, closes #265)
90
91### Task 1.1: move the feed-line sentence renderer into `internal/control`
92
93The web renders "recent activity" as a sentence (`cmc opened issue #12`)
94via `internal/httpd/feed.go`'s unexported `feedLine`/`feedLines`, which
95the CLI cannot reach — `internal/httpd` imports `internal/control`, not
96the other way around. Move the renderer into `internal/control` so both
97sides call the same code; `internal/httpd` becomes a thin caller of the
98exported form.
99
100**Files:**
101- Create: `internal/control/feedline.go` (from `internal/httpd/feed.go`)
102- Create: `internal/control/feedline_test.go` (from `internal/httpd/feed_test.go`)
103- Modify: `internal/httpd/builds.go:150-183` (`worstStatus`, `runStatusPriority` move out; `combinedStatus` calls the moved form)
104- Modify: `internal/httpd/web.go:219`, `:470`, `:493`, `:511` (`feedLine`/`feedLines` → `control.FeedLine`/`control.FeedLines`)
105- Modify: `internal/httpd/ownerpage_test.go:58` (`feedLine{...}` → `control.FeedLine{...}`)
106- Delete: `internal/httpd/feed.go`, `internal/httpd/feed_test.go`
107
108**Interfaces:**
109- Produces: `type FeedLine struct{ Actor, Verb, Ref, Repo, URL string; When string; WhenT time.Time; State string; Jobs []string; sha string }` (exported type, one unexported field kept for the fold logic — same package as its only user); `func FeedLines(events []store.FeedEvent) []FeedLine`; `func WorstStatus(statuses []string) string`.
110- Consumes (Task 1.2, 1.3): the same `FeedLines`/`FeedLine`.
111
112- [ ] **Step 1: Run the existing web feed tests to see the baseline pass**
113
114Run: `go test ./internal/httpd -run TestFeedLines -count=1`
115Expected: PASS (nothing changed yet).
116
117- [ ] **Step 2: Move the renderer**
118
119`git mv internal/httpd/feed.go internal/control/feedline.go` and
120`git mv internal/httpd/feed_test.go internal/control/feedline_test.go`.
121In `internal/control/feedline.go`, change `package httpd` to
122`package control`, capitalize the moved identifiers, and drop the now-
123unused `"gitbay.org/gitbay/internal/store"` import path prefix
124adjustments are unnecessary (the import path is the same from either
125package). Concretely:
126
127```go
128package control
129
130import (
131 "encoding/json"
132 "fmt"
133 "slices"
134 "strings"
135 "time"
136
137 "gitbay.org/gitbay/internal/store"
138)
139
140// FeedLine is one activity entry, already phrased and linked.
141type FeedLine struct {
142 Actor string
143 Verb string // "opened issue", "merged", "ran 2 jobs on"
144 Ref string // "#12", "!35", "v0.4.0", a short sha
145 Repo string
146 URL string
147 When string // the stored timestamp, for anything still reading it raw
148 WhenT time.Time // parsed from When, for ago/whenT rendering
149 State string // a build run's combined status; empty for anything else
150 Jobs []string // job names folded into a build run
151 sha string // the commit a build event fired on, for fold-matching
152}
153```
154
155Keep the rest of the function bodies (`FeedLines`, `issueVerb`, `mrVerb`,
156`parseEventTime`) unchanged apart from `feedLines` → `FeedLines` and
157`feedLine{` → `FeedLine{`; `issueVerb`/`mrVerb`/`parseEventTime` stay
158unexported (nothing outside the package calls them directly). In
159`internal/control/feedline_test.go`, change `package httpd` to
160`package control` and `feedLines(` → `FeedLines(` throughout (ten call
161sites, all named `feedLines(events)`).
162
163- [ ] **Step 3: Move `worstStatus`**
164
165In `internal/httpd/builds.go`, cut `runStatusPriority` and `worstStatus`
166(the two declarations at lines 150–183) and paste them into
167`internal/control/feedline.go`, renaming `worstStatus` to `WorstStatus`
168and updating its one internal call site in `FeedLines`
169(`out[i].State = worstStatus(statuses[i])` → `WorstStatus(...)`). In
170`internal/httpd/builds.go`, `combinedStatus` becomes:
171
172```go
173func combinedStatus(builds []control.BuildOut) string {
174 statuses := make([]string, len(builds))
175 for i, b := range builds {
176 statuses[i] = b.Status
177 }
178 return control.WorstStatus(statuses)
179}
180```
181
182- [ ] **Step 4: Update `internal/httpd/web.go`'s call sites**
183
184Line 219 (`dashboard`'s anonymous struct): `Feed []control.FeedLine`.
185Line 470: `func (s *Server) ownerFeed(tab, kind, name string) []control.FeedLine`,
186its final `return feedLines(events)` becomes `return control.FeedLines(events)`.
187Line 493 inside `dashboard`: `feedLines(events)` → `control.FeedLines(events)`.
188Line 511 (`ownerPage.Log`): `Log []control.FeedLine`.
189
190- [ ] **Step 5: Update `internal/httpd/ownerpage_test.go:58`**
191
192```go
193d.Log = []control.FeedLine{{Actor: "cmc", Verb: "opened issue", Ref: "#12", Repo: "krz/gitbay", URL: "/krz/gitbay/issues/12"}}
194```
195
196- [ ] **Step 6: Build and test both packages**
197
198Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/httpd -count=1`
199Expected: PASS. A compile error naming `feedLine`/`feedLines`/`worstStatus`
200means a call site in `internal/httpd` was missed — `grep -rn
201"feedLine\|worstStatus" internal/httpd/*.go` should come back empty
202except inside comments.
203
204- [ ] **Step 7: Commit**
205
206```bash
207git add internal/control/feedline.go internal/control/feedline_test.go internal/httpd/feed.go internal/httpd/feed_test.go internal/httpd/builds.go internal/httpd/web.go internal/httpd/ownerpage_test.go
208git commit -m "control: move the feed-line sentence renderer from httpd, so the CLI can share it" -m "Ref #265"
209```
210
211### Task 1.2: a labelled event's sentence names the labels
212
213`issue.labeled`/`mr.labeled` events currently fall through `issueVerb`/
214`mrVerb`'s default case (`"issue " + s"`, i.e. "issue labeled"), naming
215neither what changed nor which labels — on the web today, not only in
216the CLI this plan is fixing. Give both label events their own verb and
217carry the label list alongside the ref.
218
219**Files:**
220- Modify: `internal/control/feedline.go` (`FeedLine`, `FeedLines`, `issueVerb`, `mrVerb`)
221- Modify: `internal/control/feedline_test.go`
222- Modify: `internal/web/templates/dashboard.html:49`, `internal/web/templates/owner.html:39`
223
224**Interfaces:**
225- Produces: `FeedLine.Extra string` — trailing detail rendered after the ref; empty for every event kind but a labelled one.
226
227- [ ] **Step 1: Write the failing test**
228
229```go
230func TestFeedLinesNamesTheLabelsOnALabelledIssue(t *testing.T) {
231 events := []store.FeedEvent{
232 {RepoPath: "krz/gitbay", Actor: "cmc", Kind: "issue.labeled",
233 Data: `{"number":262,"labels":["ops","security"]}`},
234 }
235 lines := FeedLines(events)
236 if len(lines) != 1 {
237 t.Fatalf("FeedLines returned %d lines, want 1", len(lines))
238 }
239 l := lines[0]
240 if l.Verb != "labelled" || l.Ref != "#262" || l.Extra != "ops, security" {
241 t.Errorf("got %+v", l)
242 }
243}
244
245func TestFeedLinesNamesTheLabelsOnALabelledMR(t *testing.T) {
246 events := []store.FeedEvent{
247 {RepoPath: "krz/gitbay", Actor: "cmc", Kind: "mr.labeled",
248 Data: `{"number":471,"labels":["review"]}`},
249 }
250 lines := FeedLines(events)
251 if len(lines) != 1 || lines[0].Verb != "labelled" || lines[0].Ref != "!471" || lines[0].Extra != "review" {
252 t.Errorf("got %+v", lines)
253 }
254}
255```
256
257- [ ] **Step 2: Run and see them fail**
258
259Run: `go test ./internal/control -run TestFeedLinesNamesTheLabels -count=1`
260Expected: FAIL (`Verb = "issue labeled"`/`"merge request labeled"`, `Extra` unset).
261
262- [ ] **Step 3: Implement**
263
264Add a field to the local decode struct and the `FeedLine` type, then set
265`Extra` for the two label kinds. In `FeedLines`, the local `d` struct
266gains `Labels []string`:
267
268```go
269var d struct {
270 Number int64 `json:"number"`
271 Job string `json:"job"`
272 Tag string `json:"tag"`
273 SHA string `json:"sha"`
274 Labels []string `json:"labels"`
275}
276```
277
278`FeedLine` gains, after `Jobs`:
279
280```go
281 // Extra is trailing detail shown after the ref: the label list on a
282 // labelled event, empty for everything else.
283 Extra string
284```
285
286In the `case "issue":`/`case "mr":` arms, after setting `l.Verb, l.Ref`:
287
288```go
289 case "issue":
290 l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number)
291 l.URL = fmt.Sprintf("/%s/issues/%d", e.RepoPath, d.Number)
292 if rest == "labeled" {
293 l.Extra = strings.Join(d.Labels, ", ")
294 }
295 case "mr":
296 l.Verb, l.Ref = mrVerb(rest), fmt.Sprintf("!%d", d.Number)
297 l.URL = fmt.Sprintf("/%s/mrs/%d", e.RepoPath, d.Number)
298 if rest == "labeled" {
299 l.Extra = strings.Join(d.Labels, ", ")
300 }
301```
302
303`issueVerb` and `mrVerb` each gain a case:
304
305```go
306 case "labeled":
307 return "labelled"
308```
309
310- [ ] **Step 4: Run**
311
312Run: `go test ./internal/control -count=1`
313Expected: PASS.
314
315- [ ] **Step 5: Templates carry `Extra`**
316
317`internal/web/templates/dashboard.html:49` and
318`internal/web/templates/owner.html:39` both gain `{{if .Extra}} {{.Extra}}{{end}}`
319right after the closing `</a>` of the ref link, before the `<br>`:
320
321```
322{{range .Feed}}<p class="feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a>{{if .Extra}} {{.Extra}}{{end}}<br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p>
323```
324
325- [ ] **Step 6: Build**
326
327Run: `go build ./... && go test ./internal/control ./internal/httpd -count=1`
328Expected: PASS.
329
330- [ ] **Step 7: Commit**
331
332```bash
333git add internal/control/feedline.go internal/control/feedline_test.go internal/web/templates/dashboard.html internal/web/templates/owner.html
334git commit -m "feed: a labelled event names the labels" -m "Ref #265"
335```
336
337### Task 1.3: dashboard and feed render activity as sentences, not raw payloads
338
339`gitbay dashboard`'s "recent activity" table and `gitbay feed` both
340print the event's kind and its raw JSON payload
341(`2026-09-28T02:36:51Z cmc issue.labeled krz/gitbay
342{"number":262,"labels":["ops","security"]}`). Render the same sentence
343the web shows instead; the payload stays available under `--json`
344(`DashboardOut.Activity`/`FeedOut.Data` are untouched).
345
346**Files:**
347- Modify: `internal/control/dashboard.go` (`runDashboard`'s `activityRows`, `runFeed`'s plain formatter)
348- Test: `internal/control/dashboard_test.go`
349
350**Interfaces:**
351- Consumes: `FeedLines`, `FeedLine` (Task 1.1/1.2).
352
353- [ ] **Step 1: Write the failing test**
354
355```go
356func TestDashboardActivityIsASentence(t *testing.T) {
357 c := notifTestCtx(t, "cmc")
358 repoID, err := c.Store.CreateRepo("user", c.User.ID, "gitbay", "public")
359 if err != nil {
360 t.Fatal(err)
361 }
362 repo, err := c.Store.RepoByID(repoID)
363 if err != nil {
364 t.Fatal(err)
365 }
366 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.labeled", `{"number":262,"labels":["ops","security"]}`)
367
368 var out bytes.Buffer
369 c.Stdout, c.Stderr = &out, &out
370 if code := runDashboard(c, nil); code != 0 {
371 t.Fatalf("exit %d: %s", code, out.String())
372 }
373 if strings.Contains(out.String(), `{"number"`) {
374 t.Errorf("raw payload leaked into plain output:\n%s", out.String())
375 }
376 if !strings.Contains(out.String(), "cmc labelled krz/gitbay#262 ops, security") {
377 t.Errorf("no sentence in output:\n%s", out.String())
378 }
379}
380```
381
382- [ ] **Step 2: Run and see it fail**
383
384Run: `go test ./internal/control -run TestDashboardActivityIsASentence -count=1`
385Expected: FAIL (output has `KIND`/`DATA` columns and the raw JSON).
386
387- [ ] **Step 3: Implement in `runDashboard`**
388
389Replace the `activityRows`/`section("recent activity:", ...)` block:
390
391```go
392 lines := FeedLines(events)
393 activityRows := make([][]cell, len(lines))
394 for i, l := range lines {
395 sentence := fmt.Sprintf("%s %s %s%s", l.Actor, l.Verb, l.Repo, l.Ref)
396 if l.Extra != "" {
397 sentence += " " + l.Extra
398 }
399 activityRows[i] = []cell{cAge(l.When), cFlex(sentence)}
400 }
401 section("recent activity:", []string{"WHEN", "EVENT"}, activityRows)
402```
403
404`events` is already in scope (it is what `d.Activity = feedOutputs(events)`
405was built from, a few lines above); nothing else in `runDashboard` reads
406it again, so no variable needs renaming.
407
408- [ ] **Step 4: Run**
409
410Run: `go test ./internal/control -run TestDashboardActivityIsASentence -count=1`
411Expected: PASS.
412
413- [ ] **Step 5: Same fix in `runFeed`, its own failing test first**
414
415```go
416func TestFeedIsASentence(t *testing.T) {
417 c := notifTestCtx(t, "cmc")
418 repoID, err := c.Store.CreateRepo("user", c.User.ID, "gitbay", "public")
419 if err != nil {
420 t.Fatal(err)
421 }
422 repo, err := c.Store.RepoByID(repoID)
423 if err != nil {
424 t.Fatal(err)
425 }
426 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", `{"number":1}`)
427
428 var out bytes.Buffer
429 c.Stdout, c.Stderr = &out, &out
430 if code := runFeed(c, nil); code != 0 {
431 t.Fatalf("exit %d: %s", code, out.String())
432 }
433 if !strings.Contains(out.String(), "cmc opened issue krz/gitbay#1") {
434 t.Errorf("no sentence in output:\n%s", out.String())
435 }
436}
437```
438
439Run: `go test ./internal/control -run TestFeedIsASentence -count=1`
440Expected: FAIL.
441
442Implement: `runFeed`'s plain closure changes from the five-column
443`WHEN`/`ACTOR`/`KIND`/`REPO`/`DATA` table to the same two-column shape,
444built from `FeedLines(events)` (the same `events` slice `runFeed`
445already queried, before `feedOutputs(events)` is called for `ds`):
446
447```go
448 lines := FeedLines(events)
449 return c.emitPage(p, ds, next, func(w io.Writer) {
450 tb := c.table(w, "WHEN", "EVENT")
451 for _, l := range lines {
452 sentence := fmt.Sprintf("%s %s %s%s", l.Actor, l.Verb, l.Repo, l.Ref)
453 if l.Extra != "" {
454 sentence += " " + l.Extra
455 }
456 tb.row(cAge(l.When), cFlex(sentence))
457 }
458 tb.flush()
459 })
460```
461
462`ds` (the `FeedOut` slice) stays exactly as it was: it is what `--json`
463still emits, and `emitPage`'s cursor logic pages `ds`, not `lines` — the
464two slices are always the same length and order since both come from
465the same `events`.
466
467- [ ] **Step 6: Run**
468
469Run: `go test ./internal/control -count=1`
470Expected: PASS. A failure elsewhere in the package on a "recent
471activity"/"WHEN\tACTOR\tKIND" assertion means an existing test asserted
472the old five-column shape; update its expectation to the new sentence
473(the test name will say `TestDashboard...` or `TestFeed...`).
474
475- [ ] **Step 7: Commit**
476
477```bash
478git add internal/control/dashboard.go internal/control/dashboard_test.go
479git commit -m "dashboard, feed: render activity as the web's sentence, not the raw payload" -m "Ref #265"
480```
481
482### Task 1.4: an issue assigned to you no longer repeats in "open issues"
483
484`dashboardIssuesQuery` (open issues you are involved in) and
485`assignedIssuesQuery` (open issues assigned to you) overlap whenever an
486assigned issue also sits in a repository you can otherwise reach — the
487common case — so the same issue prints under both "assigned to you:"
488and "open issues:" on the CLI, and under both lists on the web
489dashboard, which calls the same two store methods
490(`internal/httpd/web.go:206-209`). Exclude assigned issues from the
491"open issues" query; the fix is in the store, so both surfaces get it
492at once.
493
494**Files:**
495- Modify: `internal/store/dashboard.go` (`dashboardIssuesQuery`)
496- Test: `internal/store/dashboard_test.go` (create)
497
498**Interfaces:**
499- Consumes: nothing new.
500- Produces: nothing new (`DashboardIssues` keeps its signature).
501
502- [ ] **Step 1: Write the failing test**
503
504```go
505package store
506
507import "testing"
508
509// An issue assigned to the user is not repeated under DashboardIssues:
510// AssignedIssues already covers it, and a repository the user can
511// otherwise reach (here, one they own) is the common case where the two
512// queries used to overlap (#265).
513func TestDashboardIssuesExcludesAssignedIssues(t *testing.T) {
514 s := open(t)
515 if err := s.MigrateUp(); err != nil {
516 t.Fatal(err)
517 }
518 uid, err := s.CreateUser("cmc", false)
519 if err != nil {
520 t.Fatal(err)
521 }
522 repoID, err := s.CreateRepo("user", uid, "gitbay", "public")
523 if err != nil {
524 t.Fatal(err)
525 }
526 repo, err := s.RepoByID(repoID)
527 if err != nil {
528 t.Fatal(err)
529 }
530 assignedNum, err := s.CreateIssue(repo.ID, uid, "assigned to me", "", "markdown")
531 if err != nil {
532 t.Fatal(err)
533 }
534 if _, err := s.CreateIssue(repo.ID, uid, "not assigned", "", "markdown"); err != nil {
535 t.Fatal(err)
536 }
537 assigned, err := s.IssueByNumber(repo.ID, assignedNum)
538 if err != nil {
539 t.Fatal(err)
540 }
541 if err := s.SetIssueAssignee(assigned.ID, uid, true); err != nil {
542 t.Fatal(err)
543 }
544
545 issues, err := s.DashboardIssues(uid)
546 if err != nil {
547 t.Fatal(err)
548 }
549 if len(issues) != 1 || issues[0].Title != "not assigned" {
550 t.Fatalf("DashboardIssues = %+v, want only the unassigned issue", issues)
551 }
552 assignedList, err := s.AssignedIssues(uid)
553 if err != nil {
554 t.Fatal(err)
555 }
556 if len(assignedList) != 1 || assignedList[0].Title != "assigned to me" {
557 t.Fatalf("AssignedIssues = %+v, want the assigned issue", assignedList)
558 }
559}
560```
561
562- [ ] **Step 2: Run and see it fail**
563
564Run: `go test ./internal/store -run TestDashboardIssuesExcludesAssignedIssues -count=1`
565Expected: FAIL (`DashboardIssues` returns both issues).
566
567- [ ] **Step 3: Implement**
568
569`dashboardIssuesQuery` in `internal/store/dashboard.go` gains one
570`NOT EXISTS` clause:
571
572```go
573const dashboardIssuesQuery = `
574 SELECT COALESCE(u.username, o.name) || '/' || r.name,
575 x.number, x.title, au.username, x.state, x.updated_at
576 FROM issues x
577 JOIN repos r ON r.id = x.repo_id
578 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
579 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
580 JOIN users au ON au.id = x.author_id
581 WHERE x.state = 'open' AND ` + involvedCond + `
582 AND NOT EXISTS (SELECT 1 FROM issue_assignees ia
583 WHERE ia.issue_id = x.id AND ia.user_id = ?1)
584 ORDER BY x.updated_at DESC LIMIT 50`
585```
586
587- [ ] **Step 4: Run the new test, then the package and the query-plan guard**
588
589Run: `go test ./internal/store -count=1`
590Expected: PASS, `TestDashboardQueriesUseIndexes`'s `DashboardIssues` case
591included — a correlated `NOT EXISTS` does not change which index drives
592the `ORDER BY`, so the plan should still show `issues_recent` with no
593`USE TEMP B-TREE FOR ORDER BY`. If it does regress, the `NOT EXISTS`
594subquery needs `issue_assignees`'s existing `(issue_id, user_id)` index
595(check `migrations/` for its name) rather than a new one — this task
596does not add a migration.
597
598- [ ] **Step 5: Run the CLI package too**
599
600Run: `go test ./internal/control -count=1`
601Expected: PASS. `TestDashboardEmptySectionsSayNone` and any other
602dashboard test that seeded an assigned issue and expected it under
603"open issues" needs its expectation updated to match the new,
604non-overlapping behavior.
605
606- [ ] **Step 6: Commit**
607
608```bash
609git add internal/store/dashboard.go internal/store/dashboard_test.go
610git commit -m "dashboard: an assigned issue no longer repeats under open issues" -m "Ref #265"
611```
612
613### Task 1.5: `notifications list`'s empty state names `--all`
614
615An inbox with only read notifications prints the generic `nothing to
616list` on stderr when `notifications list` is run without `--all`,
617without saying unread items are what it shows by default.
618
619**Files:**
620- Modify: `internal/control/notifications.go` (`runNotificationsList`)
621- Test: `internal/control/notifications_test.go`
622
623- [ ] **Step 1: Write the failing test**
624
625```go
626func TestNotificationsListEmptyUnreadSaysHowToSeeRead(t *testing.T) {
627 c, repo, bob := testRepoWithWatcher(t)
628 // Give bob one notice, then mark it read, so his inbox has rows but
629 // no unread ones.
630 c.User = store.User{ID: bob, Username: "bob"}
631 notify(c, []int64{bob}, notice{repo: repo, kind: "issue", subject: "s", action: "a", path: "x"})
632 if code := runNotificationsRead(c, []string{"--all"}); code != protocol.ExitOK {
633 t.Fatalf("mark read: exit %d", code)
634 }
635 var out, errOut bytes.Buffer
636 c.Stdout, c.Stderr = &out, &errOut
637 if code := runNotificationsList(c, nil); code != protocol.ExitOK {
638 t.Fatalf("exit %d: %s", code, errOut.String())
639 }
640 if got := errOut.String(); got != "no unread notifications (--all for read ones)\n" {
641 t.Errorf("stderr = %q", got)
642 }
643 // --all sees it and stays the generic message when that too is empty.
644 out.Reset()
645 errOut.Reset()
646 if code := runNotificationsList(c, []string{"--all"}); code != protocol.ExitOK {
647 t.Fatalf("exit %d: %s", code, errOut.String())
648 }
649 if !strings.Contains(out.String(), "s") {
650 t.Errorf("--all did not show the read notice: %q", out.String())
651 }
652}
653```
654
655(This test needs `notify` and `notice` — the same helpers
656`testRepoWithWatcher`'s package already exercises in
657`notifications_test.go`'s other tests; if their exact names differ,
658`grep -n "^func notify\b\|^type notice\b" internal/control/*.go` and use
659what is actually there.)
660
661- [ ] **Step 2: Run and see it fail**
662
663Run: `go test ./internal/control -run TestNotificationsListEmptyUnreadSaysHowToSeeRead -count=1`
664Expected: FAIL (stderr is `nothing to list`).
665
666- [ ] **Step 3: Implement**
667
668In `runNotificationsList`, after `ds` is built and before the `return
669c.emitPage(...)`:
670
671```go
672 if !c.JSON && !p.active && len(ds) == 0 {
673 msg := "nothing to list"
674 if !all {
675 msg = "no unread notifications (--all for read ones)"
676 }
677 fmt.Fprintln(c.Stderr, msg)
678 return protocol.ExitOK
679 }
680 return c.emitPage(p, ds, next, func(w io.Writer) {
681```
682
683This runs before pagination wraps the result (`p.active`, from
684`--limit`/`--cursor`) and before JSON, both of which already have their
685own well-defined empty shape (`{"items":[],...}` or a bare `[]`) that
686this task leaves alone.
687
688- [ ] **Step 4: Run**
689
690Run: `go test ./internal/control -run TestNotifications -count=1`
691Expected: PASS.
692
693- [ ] **Step 5: Run the package, commit, open the MR**
694
695Run: `go test ./internal/control ./internal/store ./internal/httpd -count=1`
696Expected: PASS.
697
698```bash
699git add internal/control/notifications.go internal/control/notifications_test.go
700git commit -m "notifications list: name --all when the empty inbox is just read items" -m "Closes #265"
701git push -u origin cli-ux-activity
702gitbay mr create --source cli-ux-activity --target main --title "dashboard activity as sentences, no duplicate issues"
703```
704
705Wait for CI, merge with `--strategy ff`, delete the branch both places.
706
707---
708
709# Part 2: help and usage print the form the caller typed (branch `cli-ux-help`, closes #267)
710
711### Task 2.1: the CLI sends the path it typed; usage and help print it
712
713`c.usage()` prints the bare *registered* usage (`usage: keys remove
714<fingerprint>`), with neither the `gitbay` nor the `ssh git@host` prefix
715`c.program()`/`helpVerb` already use for `--help` — and for eighteen
716commands (the ones listed in Architecture above) the registered path is
717not even something a caller can type: `gitbay keys remove <fp>` is
718`unknown command "keys"`, because the real command is `gitbay auth keys
719remove <fp>`. Fix both: give every usage/help line the program prefix,
720mark a leading `<owner/name>` optional at a terminal (the CLI fills it
721in from the clone's origin remote, `cmd/gitbay/ssh.go`'s `withRepo`;
722stock ssh never does), and have the CLI tell the server what it was
723actually typed as, so the server can print that instead of the
724registered path wherever the two differ.
725
726The CLI already tells the server one thing about the calling session
727this way: `--term=<cols>[,color]`, prepended to the command line by
728`runSSHPaged` and stripped off `argv[0]` by `Dispatch` before `Lookup`
729(`internal/control/control.go`). A second, sibling prefix, `--path=<cli
730path>`, carries what cobra resolved the call to
731(`cobra.Command.CommandPath()`, minus the leading `gitbay `). It travels
732as its own `--path=` argument rather than a new field packed into the
733`--term=` value: that value's `cols[,color]` grammar has no room for a
734string containing spaces (a CLI path always does), and a second prefix
735is one more `strings.CutPrefix` in the same loop, not a new mini-parser.
736Only the gitbay CLI ever sends it — stock ssh has no notion of a "path
737it resolved to" that differs from what was typed, because what was typed
738*is* the dispatch path — so `Dispatch` never invents one, and the field
739stays empty for the web and the API exactly like `Term` does.
740
741**Files:**
742- Modify: `internal/control/control.go` (`Ctx.CLIPath`, `Dispatch`, `usage`, `usageWith`)
743- Modify: `internal/control/help.go` (`cliUsage`, `shownAs`, `cmdUsage`, `helpVerb`, `helpNoun`)
744- Modify: `internal/control/control_test.go` (`TestArgumentRefusalsNameTheUsage`, new `TestPathArgument`)
745- Test: `internal/control/help_test.go`
746- Modify: `cmd/gitbay/ssh.go` (`cliPathOf`, `withCLIPath`, new)
747- Modify: `cmd/gitbay/main.go` (`pass`, `runServerHelp`, `runPass`, `group`, `serverHelp`)
748- Test: `cmd/gitbay/term_test.go` (the two new pure helpers)
749- Test: `cmd/gitbay/serverpath_test.go` (create)
750- Test: `e2e/cliusage_test.go` (create)
751
752**Interfaces:**
753- Produces: `Ctx.CLIPath string`; `func cliUsage(usage string) string`;
754 `func (c *Ctx) shownAs(registered, full string) string`; `func (c
755 *Ctx) cmdUsage() string`; `func cliPathOf(cmd *cobra.Command) string`;
756 `func withCLIPath(cliPath string, argv []string) []string`.
757- Consumes (Task 2.2, 2.3): `Ctx.CLIPath`, `shownAs`, `withCLIPath`, `cliPathOf`.
758
759- [ ] **Step 1: Write the failing test for the transport**
760
761```go
762// TestPathArgument: --path= is read only as a leading argument (in
763// either order with --term=), and never over HTTP — mirrors
764// TestTermArgument, the mechanism it rides alongside.
765func TestPathArgument(t *testing.T) {
766 cases := []struct {
767 name string
768 viaAPI bool
769 argv []string
770 want string
771 wantArgv []string
772 }{
773 {"leading", false, []string{"--path=auth keys remove", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}},
774 {"after term", false, []string{"--term=80", "--path=auth keys remove", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}},
775 {"before term", false, []string{"--path=auth keys remove", "--term=80", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}},
776 {"over HTTP", true, []string{"--path=auth keys remove", "keys", "remove", "abc"}, "", []string{"abc"}},
777 }
778 for _, tc := range cases {
779 c := &Ctx{Scope: "git", ViaAPI: tc.viaAPI, Stdout: io.Discard, Stderr: io.Discard}
780 Dispatch(c, tc.argv)
781 if c.CLIPath != tc.want {
782 t.Errorf("%s: CLIPath %q, want %q", tc.name, c.CLIPath, tc.want)
783 }
784 if !slices.Equal(c.Argv, tc.wantArgv) {
785 t.Errorf("%s: Argv %q, want %q", tc.name, c.Argv, tc.wantArgv)
786 }
787 }
788}
789```
790
791- [ ] **Step 2: Run and see it fail**
792
793Run: `go test ./internal/control -run TestPathArgument -count=1`
794Expected: FAIL to compile (`c.CLIPath undefined`).
795
796- [ ] **Step 3: Add the field and route it through `Dispatch`**
797
798In `internal/control/control.go`, `Ctx` gains a field next to `Term`:
799
800```go
801 // CLIPath is the path the gitbay CLI actually resolved this call to
802 // (cobra.Command.CommandPath(), from a leading --path=), when it
803 // differs from the registered path being dispatched (#267) — auth's
804 // several groupings and repo topics list, today. Empty for stock
805 // ssh, the web and the API: nothing but the gitbay CLI sends one.
806 CLIPath string
807```
808
809`Dispatch` strips both leading pseudo-flags in a loop, in whichever
810order the caller sent them, replacing the single `--term=` check:
811
812```go
813 // A leading --term=<v> selects terminal output for this session, the
814 // same as GITBAY_TERM; a leading --path=<v> carries the CLI's own
815 // invoking path when it differs from the one being dispatched
816 // (#267). Both come off before Lookup, in whichever order the
817 // caller sent them: Lookup matches argv against a command's Path,
818 // and either prefix in front would never match one. Over HTTP both
819 // are dropped unread: the web and the API render no terminal and
820 // have no CLI path of their own.
821 for len(argv) > 0 {
822 if v, ok := strings.CutPrefix(argv[0], "--term="); ok {
823 if !c.ViaAPI {
824 c.Term = ParseTerm(v)
825 }
826 argv = argv[1:]
827 continue
828 }
829 if v, ok := strings.CutPrefix(argv[0], "--path="); ok {
830 if !c.ViaAPI {
831 c.CLIPath = v
832 }
833 argv = argv[1:]
834 continue
835 }
836 break
837 }
838 if len(argv) == 0 {
839 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
840 }
841```
842
843- [ ] **Step 4: Run**
844
845Run: `go test ./internal/control -run "TestPathArgument|TestTermArgument" -count=1`
846Expected: PASS.
847
848- [ ] **Step 5: Write the failing test for rendering**
849
850```go
851func TestCmdUsagePrefixesTheProgram(t *testing.T) {
852 c := &Ctx{Cmd: Command{Path: []string{"keys", "remove"}, Usage: "keys remove <fingerprint>"}, Cfg: config.Config{Server: config.Server{SiteURL: "https://forge.test"}}}
853 if got := c.cmdUsage(); got != "ssh git@forge.test keys remove <fingerprint>" {
854 t.Errorf("ssh form: %q", got)
855 }
856 c.Term = Term{Cols: 100}
857 if got := c.cmdUsage(); got != "gitbay keys remove <fingerprint>" {
858 t.Errorf("cli form, no CLIPath sent: %q", got)
859 }
860 c.CLIPath = "auth keys remove"
861 if got := c.cmdUsage(); got != "gitbay auth keys remove <fingerprint>" {
862 t.Errorf("cli form, mismatched registered path: %q", got)
863 }
864
865 c2 := &Ctx{Cmd: Command{Path: []string{"repo", "tree"}, Usage: "repo tree <owner/name> [<path>] [--ref <ref>]"}, Term: Term{Cols: 100}}
866 if got := c2.cmdUsage(); got != "gitbay repo tree [<owner/name>] [<path>] [--ref <ref>]" {
867 t.Errorf("optional owner/name: %q", got)
868 }
869 c2.CLIPath = "repo tree" // matches the registered path: a no-op
870 if got := c2.cmdUsage(); got != "gitbay repo tree [<owner/name>] [<path>] [--ref <ref>]" {
871 t.Errorf("matching CLIPath changes nothing: %q", got)
872 }
873}
874```
875
876- [ ] **Step 6: Run and see it fail**
877
878Run: `go test ./internal/control -run TestCmdUsagePrefixesTheProgram -count=1`
879Expected: FAIL to compile (`c.cmdUsage undefined`).
880
881- [ ] **Step 7: Implement `cliUsage`, `shownAs` and `cmdUsage` in `internal/control/help.go`**
882
883```go
884// cliUsage marks a leading <owner/name> optional in a CLI-rendered usage
885// line: the CLI infers it inside a clone (cmd/gitbay/ssh.go's withRepo),
886// stock ssh never does. Only the first occurrence is marked — a usage
887// line never repeats the placeholder.
888func cliUsage(usage string) string {
889 return strings.Replace(usage, "<owner/name>", "[<owner/name>]", 1)
890}
891
892// shownAs returns how a registered path should print to this caller:
893// the CLI path it sent (Ctx.CLIPath) standing in for the leading
894// portion that corresponds to registered, with full's remainder kept
895// as-is; or full unchanged for stock ssh, the API, or a caller whose
896// CLI path already agrees with the registered one. registered must be
897// a genuine leading substring of full (a command's own registered path
898// always is, against its own Usage or a sibling's full path).
899func (c *Ctx) shownAs(registered, full string) string {
900 if c.CLIPath == "" || c.CLIPath == registered {
901 return full
902 }
903 return c.CLIPath + strings.TrimPrefix(full, registered)
904}
905
906// cmdUsage is the registered usage as this call should see it: the CLI
907// path this session actually typed when it differs from the registered
908// one (#267), the gitbay form otherwise, the ssh form when there is no
909// terminal — with a leading <owner/name> marked optional at a terminal.
910// Every usage message — the --help path and a wrong-argument refusal
911// alike — goes through this, so a caller never sees a command it
912// cannot actually run.
913func (c *Ctx) cmdUsage() string {
914 registered := joinPath(c.Cmd.Path)
915 shape := c.shownAs(registered, c.Cmd.Usage)
916 if c.Term.Cols > 0 {
917 shape = cliUsage(shape)
918 }
919 return c.program() + " " + shape
920}
921```
922
923- [ ] **Step 8: Run**
924
925Run: `go test ./internal/control -run TestCmdUsagePrefixesTheProgram -count=1`
926Expected: PASS.
927
928- [ ] **Step 9: Route `usage`/`usageWith` through it**
929
930In `internal/control/control.go`:
931
932```go
933// usage reports a bad invocation with the command's registered usage,
934// the one source of it.
935func (c *Ctx) usage() int {
936 return c.fail(protocol.ExitUsage, "usage: %s", c.cmdUsage())
937}
938
939// usageWith reports a specific problem with the arguments, then the
940// registered usage, so a person always sees the shape that was expected.
941func (c *Ctx) usageWith(msg string) int {
942 return c.fail(protocol.ExitUsage, "%s\nusage: %s", msg, c.cmdUsage())
943}
944```
945
946- [ ] **Step 10: `helpVerb` gets the same treatment**
947
948`helpVerb` (`internal/control/help.go`) recomputes a "cut at ` [--`"
949shape independently, and lists sibling commands under SEE ALSO by their
950full registered path. Both go through `shownAs` now:
951
952```go
953func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) {
954 fmt.Fprintln(w, cmd.Summary)
955 fmt.Fprintln(w)
956 c.heading(w, "USAGE")
957 // Cutting at the first optional flag drops the rest of the usage
958 // syntax behind "[flags]" — safe only for what is actually optional.
959 // A required flag (repo delete --yes) or an alternative
960 // (notifications read <id>... | --all) has no " [--" to cut at, so
961 // the usage prints whole.
962 registered := joinPath(cmd.Path)
963 shape := c.shownAs(registered, cmd.Usage)
964 if c.Term.Cols > 0 {
965 shape = cliUsage(shape)
966 }
967 if i := strings.Index(shape, " [--"); i >= 0 {
968 shape = shape[:i] + " [flags]"
969 }
970 fmt.Fprintf(w, " %s %s\n", c.program(), shape)
971 fmt.Fprintln(w)
972 c.heading(w, "FLAGS")
973 rows := make([][2]string, 0, len(cmd.Flags)+1)
974 for _, f := range cmd.Flags {
975 name := f.Name
976 if f.Arg != "" {
977 name += " " + f.Arg
978 }
979 desc := f.Desc
980 if f.Default != "" {
981 desc += " (default " + f.Default + ")"
982 }
983 rows = append(rows, [2]string{name, desc})
984 }
985 rows = append(rows, [2]string{"--json", "machine-readable output"})
986 wide := 0
987 for _, r := range rows {
988 wide = max(wide, cells(r[0]))
989 }
990 for _, r := range rows {
991 c.wrapLine(w, " "+pad(r[0], wide)+" ", r[1])
992 }
993 if len(cmd.Examples) > 0 {
994 fmt.Fprintln(w)
995 c.heading(w, "EXAMPLES")
996 for _, ex := range cmd.Examples {
997 c.wrapLine(w, " "+c.program()+" ", ex)
998 }
999 }
1000 if len(below) > 0 {
1001 fmt.Fprintln(w)
1002 c.heading(w, "SEE ALSO")
1003 for _, b := range below {
1004 fmt.Fprintf(w, " %s %s\n", c.program(), c.shownAs(registered, joinPath(b.Path)))
1005 }
1006 }
1007}
1008```
1009
1010(Only the `USAGE` and `SEE ALSO` lines change; `FLAGS`/`EXAMPLES` are
1011reproduced above unchanged, for the diff to apply against the current
1012file — do not re-type them from scratch.)
1013
1014- [ ] **Step 11: `helpNoun` gets the same treatment**
1015
1016`helpNoun` prints `{program} {prefix} <verb> ...` and, per command, the
1017verb relative to `prefix`; the prefix itself needs the same swap (Task
10182.3 also gives it an `override` map, threaded through here empty for
1019every noun but the CLI-only `auth` alias):
1020
1021```go
1022func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command, override map[string]string) {
1023 head := nounSummaries[strings.Fields(prefix)[0]]
1024 fmt.Fprintln(w, head)
1025 fmt.Fprintln(w)
1026 c.heading(w, "USAGE")
1027 display := c.shownAs(prefix, prefix)
1028 fmt.Fprintf(w, " %s %s <verb> ...\n", c.program(), display)
1029 rowText := func(cmd Command) string {
1030 full := joinPath(cmd.Path)
1031 if d, ok := override[full]; ok {
1032 return d
1033 }
1034 return strings.TrimPrefix(full, prefix+" ")
1035 }
1036 wide := 0
1037 for _, cmd := range cmds {
1038 wide = max(wide, cells(rowText(cmd)))
1039 }
1040 for _, section := range []struct {
1041 title string
1042 read bool
1043 }{{"READ", true}, {"WRITE", false}} {
1044 first := true
1045 for _, cmd := range cmds {
1046 if cmd.ReadOnly != section.read {
1047 continue
1048 }
1049 if first {
1050 fmt.Fprintln(w)
1051 c.heading(w, section.title)
1052 first = false
1053 }
1054 fmt.Fprintf(w, " %s %s\n", pad(rowText(cmd), wide), cmd.Summary)
1055 }
1056 }
1057 fmt.Fprintln(w)
1058 fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), display)
1059}
1060```
1061
1062`runHelp`'s one call site becomes `c.helpNoun(w, prefix, matched, nil)`
1063for now; Task 2.3 gives it a real map for the `auth` alias. A `nil`
1064map's zero value behaves like an empty one — `override[full]` on a
1065`nil` map is always `"", false` — so every other noun is unaffected.
1066
1067- [ ] **Step 12: Update the test `usage`/`usageWith` changes**
1068
1069`TestArgumentRefusalsNameTheUsage` in `internal/control/control_test.go`
1070asserts `errOut.String()` contains the bare `"usage: " +
1071strings.Join(argv, " ")`; with no `Cfg.Server.SiteURL` and no `Term` set
1072on its `Ctx`, the message now reads `usage: ssh git@ build show` (an
1073empty host — `hostOf("")` returns `""`). None of these four commands is
1074one of the eighteen with a mismatched CLI path, and the test sends no
1075`--path=`, so `c.CLIPath` stays empty throughout — set a `SiteURL` and
1076assert the plain ssh-prefixed registered form:
1077
1078```go
1079func TestArgumentRefusalsNameTheUsage(t *testing.T) {
1080 for _, argv := range [][]string{{"build", "show"}, {"release", "show"}, {"mr", "resolve"}, {"issue", "show"}} {
1081 var out, errOut bytes.Buffer
1082 c := &Ctx{Scope: "full", Stdout: &out, Stderr: &errOut,
1083 Cfg: config.Config{Server: config.Server{SiteURL: "https://forge.test"}}}
1084 if code := Dispatch(c, argv); code != protocol.ExitUsage {
1085 t.Errorf("%v: exit %d, want %d (%s)", argv, code, protocol.ExitUsage, errOut.String())
1086 continue
1087 }
1088 want := "usage: ssh git@forge.test " + strings.Join(argv, " ")
1089 if !strings.Contains(errOut.String(), want) {
1090 t.Errorf("%v: no usage line: got %q, want to contain %q", argv, errOut.String(), want)
1091 }
1092 }
1093}
1094```
1095
1096(Add `"gitbay.org/gitbay/internal/config"` to the file's imports if it
1097is not already there.)
1098
1099- [ ] **Step 13: Run the package**
1100
1101Run: `go test ./internal/control -count=1`
1102Expected: PASS. Any other test asserting a bare `"usage: <path>..."` with
1103no program prefix needs the same treatment — `grep -rn '"usage: '
1104internal/control/*_test.go` finds them all; a `help`-rendering test
1105asserting a bare `"gitbay keys ..."` line for one of the eighteen
1106commands needs the CLI-prefixed form instead.
1107
1108- [ ] **Step 14: The CLI side — write the failing tests for the two pure helpers**
1109
1110`cmd/gitbay/ssh.go` needs a way to read a cobra command's own path, and
1111a way to fold it onto a server command line, both pure and cheap to
1112unit test the way `termValue`/`pagerArgv`/`pages` already are (`cmd/gitbay/term_test.go`):
1113
1114```go
1115func TestCLIPathOf(t *testing.T) {
1116 root := &cobra.Command{Use: "gitbay"}
1117 auth := &cobra.Command{Use: "auth"}
1118 keys := &cobra.Command{Use: "keys"}
1119 remove := &cobra.Command{Use: "remove"}
1120 keys.AddCommand(remove)
1121 auth.AddCommand(keys)
1122 root.AddCommand(auth)
1123 if got := cliPathOf(remove); got != "auth keys remove" {
1124 t.Errorf("cliPathOf = %q", got)
1125 }
1126}
1127
1128func TestWithCLIPath(t *testing.T) {
1129 if got := withCLIPath("", []string{"keys", "remove", "abc"}); !slices.Equal(got, []string{"keys", "remove", "abc"}) {
1130 t.Errorf("empty cliPath: %v", got)
1131 }
1132 got := withCLIPath("auth keys remove", []string{"keys", "remove", "abc"})
1133 want := []string{"--path=auth keys remove", "keys", "remove", "abc"}
1134 if !slices.Equal(got, want) {
1135 t.Errorf("got %v, want %v", got, want)
1136 }
1137}
1138```
1139
1140Add these to `cmd/gitbay/term_test.go`, alongside `TestTermValue` and
1141`TestPagerArgv`; add `"slices"` and `"github.com/spf13/cobra"` to its
1142imports if not already there.
1143
1144- [ ] **Step 15: Run and see them fail**
1145
1146Run: `go test ./cmd/gitbay -run "TestCLIPathOf|TestWithCLIPath" -count=1`
1147Expected: FAIL to compile (`cliPathOf`/`withCLIPath` undefined).
1148
1149- [ ] **Step 16: Implement the two helpers in `cmd/gitbay/ssh.go`**
1150
1151Add `"github.com/spf13/cobra"` to the file's imports, then:
1152
1153```go
1154// cliPathOf is the path this cobra command was actually reached by,
1155// stripped of the root's own name: "auth keys remove" for a command
1156// nested under auth > keys > remove. It is sent to the server as
1157// --path=, so usage and help can print what the caller can actually
1158// run even where that differs from the registered path being
1159// dispatched (cmd.Annotations[serverPath]) — #267.
1160func cliPathOf(cmd *cobra.Command) string {
1161 return strings.TrimPrefix(cmd.CommandPath(), "gitbay ")
1162}
1163
1164// withCLIPath prepends --path=<cliPath> to a server command line, the
1165// same way runSSHPaged prepends --term=: a leading pseudo-flag Dispatch
1166// strips before Lookup, never confused for a real argument. Empty
1167// cliPath is a no-op — nothing to add for a caller with no cobra tree
1168// of its own to have resolved.
1169func withCLIPath(cliPath string, argv []string) []string {
1170 if cliPath == "" {
1171 return argv
1172 }
1173 return append([]string{"--path=" + cliPath}, argv...)
1174}
1175```
1176
1177- [ ] **Step 17: Run**
1178
1179Run: `go test ./cmd/gitbay -run "TestCLIPathOf|TestWithCLIPath" -count=1`
1180Expected: PASS.
1181
1182- [ ] **Step 18: Thread `cliPath` through `pass`, `runServerHelp`, `runPass`**
1183
1184In `cmd/gitbay/main.go`:
1185
1186```go
1187func pass(use string, o passOpts) *cobra.Command {
1188 return &cobra.Command{
1189 Use: use,
1190 Short: summaries[strings.Join(o.server, " ")],
1191 Annotations: map[string]string{
1192 serverPath: strings.Join(o.server, " "),
1193 stdinMode: o.stdinModeName(),
1194 stdinWhat: o.stdinWhat,
1195 },
1196 DisableFlagParsing: true,
1197 RunE: func(cmd *cobra.Command, args []string) error {
1198 // The registry is the only place flags are written down, so
1199 // --help asks the server rather than reprinting the one-line
1200 // summary cobra holds.
1201 cliPath := cliPathOf(cmd)
1202 for _, a := range args {
1203 if a == "--help" || a == "-h" {
1204 os.Exit(runServerHelp(o, cliPath))
1205 }
1206 }
1207 os.Exit(runPass(o, cliPath, args))
1208 return nil
1209 },
1210 }
1211}
1212```
1213
1214```go
1215// runServerHelp prints the registry's usage for one command.
1216func runServerHelp(o passOpts, cliPath string) int {
1217 t, err := resolveTarget()
1218 if err != nil {
1219 fmt.Fprintln(os.Stderr, "gitbay:", err)
1220 return protocol.ExitFailure
1221 }
1222 return runSSH(t, withCLIPath(cliPath, append([]string{"help"}, o.server...)), strings.NewReader(""))
1223}
1224
1225func runPass(o passOpts, cliPath string, args []string) int {
1226```
1227
1228(`runPass`'s body is otherwise unchanged; only its signature gains
1229`cliPath string` as the second parameter, and its final line becomes:)
1230
1231```go
1232 return runSSHPaged(t, withCLIPath(cliPath, append(o.server, args...)), stdin, pages(o.server, args))
1233```
1234
1235- [ ] **Step 19: Thread `cliPath` through `group`/`serverHelp`**
1236
1237```go
1238func group(use, short string, subs ...*cobra.Command) *cobra.Command {
1239 c := &cobra.Command{Use: use, Short: short}
1240 c.AddCommand(subs...)
1241 // A noun's help is the server's, like a command's: the registry is
1242 // the only place flags are written down, and cobra's subcommand list
1243 // carried none (#130). Offline, or for a noun the server does not
1244 // know by that name, cobra's own tree still prints.
1245 local := c.HelpFunc()
1246 c.SetHelpFunc(func(cmd *cobra.Command, args []string) {
1247 if !serverHelp(use, cliPathOf(cmd)) {
1248 local(cmd, args)
1249 }
1250 })
1251 return c
1252}
1253
1254// serverHelp prints the registry's usage for a prefix and reports whether
1255// it did. cliPath is this invocation's own resolved cobra path (empty for
1256// a noun whose CLI path already matches its registered prefix). At a
1257// terminal it goes through the terminal-aware path, so it gets the same
1258// --term=<cols>[,color] treatment (and layout) as any other command;
1259// piped, it stays a quiet capture, so a network or lookup failure falls
1260// back to cobra's local help without noise.
1261func serverHelp(prefix, cliPath string) bool {
1262 t, err := resolveTarget()
1263 if err != nil {
1264 return false
1265 }
1266 argv := withCLIPath(cliPath, []string{"help", prefix})
1267 if term.IsTerminal(int(os.Stdout.Fd())) {
1268 return runSSH(t, argv, strings.NewReader("")) == 0
1269 }
1270 out, code := sshCapture(t, argv)
1271 if code != 0 || out == "" {
1272 return false
1273 }
1274 fmt.Print(out)
1275 return true
1276}
1277```
1278
1279- [ ] **Step 20: Build**
1280
1281Run: `go build ./... && go vet ./...`
1282Expected: builds clean. `keysAdd.RunE`/`pgpAdd.RunE` in `authCmd()`
1283(`cmd/gitbay/main.go`) are hand-built, not `pass()`-generated, so they
1284do not pick up `cliPath` from this step — Task 2.2 gives them the same
1285treatment where it already rewrites their bodies.
1286
1287- [ ] **Step 21: The cobra tree is the one place the eighteen mismatches
1288 are allowed to be listed — a coverage test, not a hand check**
1289
1290A future command wired with a `serverPath` that does not match its own
1291`CommandPath()` is exactly this defect happening again; nothing should
1292have to remember to re-check it by hand. `TestEveryCommandIsReachable`
1293(`cmd/gitbay/coverage_test.go`) already walks `newRoot()` comparing
1294`Annotations[serverPath]` against the registry — this test walks the
1295same tree comparing it against `cliPathOf`, and pins today's known set
1296so any change to it (a new mismatch, or one of these being fixed to
1297match) shows up as a diff a reviewer has to look at.
1298
1299```go
1300package main
1301
1302import (
1303 "slices"
1304 "strings"
1305 "testing"
1306
1307 "github.com/spf13/cobra"
1308)
1309
1310// TestServerPathMismatches pins the commands whose CLI path differs from
1311// the server path they dispatch (#267) — cmdUsage/help print the CLI
1312// path for exactly these, from cliPathOf, not the registered one. A new
1313// mismatch changes this list; update it deliberately, alongside the
1314// wiki's Parity page if it changes what a stock-ssh caller must type.
1315func TestServerPathMismatches(t *testing.T) {
1316 want := []string{
1317 "auth email add", "auth email list", "auth email primary",
1318 "auth email remove", "auth email verify",
1319 "auth export",
1320 "auth keys add", "auth keys label", "auth keys list", "auth keys remove",
1321 "auth pgp add", "auth pgp list", "auth pgp remove",
1322 "auth token create", "auth token list", "auth token revoke",
1323 "auth whoami",
1324 "repo topics list",
1325 }
1326
1327 var got []string
1328 var walk func(*cobra.Command)
1329 walk = func(c *cobra.Command) {
1330 if p := c.Annotations[serverPath]; p != "" {
1331 if cli := cliPathOf(c); cli != p {
1332 got = append(got, cli)
1333 }
1334 }
1335 for _, sub := range c.Commands() {
1336 walk(sub)
1337 }
1338 }
1339 root := newRoot()
1340 root.InitDefaultHelpCmd()
1341 walk(root)
1342 slices.Sort(got)
1343
1344 if !slices.Equal(got, want) {
1345 t.Errorf("mismatched CLI paths = %v\nwant %v", got, want)
1346 }
1347}
1348```
1349
1350- [ ] **Step 22: Run**
1351
1352Run: `go test ./cmd/gitbay -run TestServerPathMismatches -count=1`
1353Expected: PASS (the eighteen are already there today; this step only
1354adds the guard, it changes no behavior).
1355
1356- [ ] **Step 23: One end-to-end proof, real ssh and the real binary**
1357
1358Every other test here is a unit test against `Ctx`/cobra values built
1359by hand; this is the one e2e test for this task (Global Constraints
1360caps it at one), proving the wiring — `runSSHPaged`'s `--path=` prepend,
1361the server's `--path=` parse, `cmdUsage`'s substitution — actually
1362reaches an instance over real ssh, for the CLI and for stock ssh alike.
1363Model it on `e2e/term_test.go`'s `TestTermEnvSelectsTerminalOutput`.
1364
1365```go
1366package e2e
1367
1368import (
1369 "strings"
1370 "testing"
1371)
1372
1373// The CLI sends its own invoking path so usage and help print a command
1374// that exists — gitbay auth keys remove, never the unregistered gitbay
1375// keys remove (#267). Stock ssh, which never sends one, keeps seeing
1376// the registered path: it is the only one it could ever type.
1377func TestCLIUsagePrintsTheInvokingPath(t *testing.T) {
1378 t.Parallel()
1379 inst := startInstance(t)
1380 key := inst.newKey(t, "alice")
1381 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub",
1382 "--email", "alice@example.test", "--verified")
1383
1384 c := &cli{bin: buildGitbayCLI(t), configDir: t.TempDir(), inst: inst, key: key}
1385 c.must(t, "", "", "remote", "add", "test", "127.0.0.1",
1386 "--port", instPort(inst),
1387 "--ssh-option", "-i", "--ssh-option", key,
1388 "--ssh-option", "-oIdentitiesOnly=yes",
1389 "--ssh-option", "-oStrictHostKeyChecking=no",
1390 "--ssh-option", "-oUserKnownHostsFile="+inst.sshDir+"/kh",
1391 "--ssh-option", "-oBatchMode=yes",
1392 "--default")
1393
1394 // A mismatched command: the CLI path (auth keys remove) differs from
1395 // the registered one (keys remove). No fingerprint given, an actual
1396 // wrong-argument refusal.
1397 _, errOut, code := c.run(t, "", "", "auth", "keys", "remove")
1398 if code == 0 || !strings.Contains(errOut, "usage: gitbay auth keys remove") {
1399 t.Errorf("mismatched command: exit %d, stderr %q", code, errOut)
1400 }
1401 if strings.Contains(errOut, "usage: gitbay keys remove") {
1402 t.Errorf("mismatched command leaked the registered path: %q", errOut)
1403 }
1404
1405 // A matching command: no CLI/registered difference, still the gitbay
1406 // form (it is a terminal-adjacent test binary run, isTTY is false
1407 // here, so this exercises the non-terminal ssh form instead —
1408 // assert on the registered path itself, which is all cmdUsage can
1409 // tell apart in that mode).
1410 _, errOut2, code2 := c.run(t, "", "", "repo", "show")
1411 if code2 == 0 || !strings.Contains(errOut2, "usage: ") || !strings.Contains(errOut2, "repo show") {
1412 t.Errorf("matching command: exit %d, stderr %q", code2, errOut2)
1413 }
1414
1415 // Stock ssh, no CLI involved: the registered path, because it is the
1416 // only one this caller could have typed.
1417 _, errOut3, code3 := inst.ssh(t, key, "", "keys", "remove")
1418 if code3 == 0 || !strings.Contains(errOut3, "usage: ssh git@") || !strings.Contains(errOut3, "keys remove") {
1419 t.Errorf("stock ssh: exit %d, stderr %q", code3, errOut3)
1420 }
1421 if strings.Contains(errOut3, "auth keys remove") {
1422 t.Errorf("stock ssh should never see the CLI-only auth prefix: %q", errOut3)
1423 }
1424}
1425```
1426
1427(`instPort`/`inst.sshDir`/`c.run`/`inst.ssh` are whatever `e2e/cli_test.go`
1428and `e2e/term_test.go` already expose — read both before writing this
1429file and use their actual helper names and signatures rather than the
1430ones guessed here; `TestCLI` in `e2e/cli_test.go` is the fullest existing
1431example of standing up a `cli` value against a live `instance`.)
1432
1433- [ ] **Step 24: Run the one e2e test**
1434
1435Run: `go test ./e2e -run TestCLIUsagePrintsTheInvokingPath -count=1`
1436Expected: PASS.
1437
1438- [ ] **Step 25: Run everything this task touched, commit**
1439
1440Run: `go build ./... && go vet ./... && go test ./internal/control ./cmd/gitbay -count=1`
1441Expected: PASS.
1442
1443```bash
1444git add internal/control/help.go internal/control/control.go internal/control/control_test.go internal/control/help_test.go cmd/gitbay/ssh.go cmd/gitbay/main.go cmd/gitbay/term_test.go cmd/gitbay/serverpath_test.go e2e/cliusage_test.go
1445git commit -m "usage, help: print the CLI's own invoking path where it differs from the registered one, the ssh form otherwise" -m "Ref #267"
1446```
1447
1448### Task 2.2: `--help` check in `keys add` and `pgp add`
1449
1450Every other passthrough command checks for `--help`/`-h` in `pass()`
1451before reading stdin; `keysAdd.RunE` and `pgpAdd.RunE` in
1452`cmd/gitbay/main.go`'s `authCmd()` were given their own `RunE` (to wire
1453stdin directly) and lost that check, so `gitbay auth keys add --help`
1454tries to read a public key from stdin instead of showing help, and
1455blocks or fails depending on what stdin happens to be.
1456
1457**Files:**
1458- Modify: `cmd/gitbay/main.go` (`authCmd`'s `keysAdd.RunE`, `pgpAdd.RunE`)
1459- Test: `cmd/gitbay/main_test.go`
1460
1461- [ ] **Step 1: Write the failing test**
1462
1463```go
1464func TestKeysAddAndPGPAddCheckHelpBeforeStdin(t *testing.T) {
1465 for _, args := range [][]string{{"auth", "keys", "add", "--help"}, {"auth", "pgp", "add", "--help"}} {
1466 root := newRoot()
1467 root.SetArgs(args)
1468 root.SetIn(strings.NewReader("")) // would block/fail if read as the key body
1469 if err := root.Execute(); err != nil {
1470 t.Errorf("%v: %v", args, err)
1471 }
1472 }
1473}
1474```
1475
1476(`cmd/gitbay` runs its `RunE` through `os.Exit`, so this test only
1477proves the command does not attempt to read stdin as a key before
1478exiting — check with `go test ./cmd/gitbay -run
1479TestKeysAddAndPGPAddCheckHelpBeforeStdin -count=1 -v` that it does not
1480hang; if the harness needs the process not to call `os.Exit` at all,
1481grep `main_test.go` for how existing `--help` tests in this package
1482already handle that and follow the same pattern rather than inventing a
1483new one.)
1484
1485- [ ] **Step 2: Run and see it fail (or hang)**
1486
1487Run: `go test ./cmd/gitbay -run TestKeysAddAndPGPAddCheckHelpBeforeStdin -count=1 -timeout 5s`
1488Expected: FAIL or timeout (stdin read attempted).
1489
1490- [ ] **Step 3: Implement**
1491
1492`keysAdd.RunE` and `pgpAdd.RunE` in `cmd/gitbay/main.go` each gain the
1493same loop `pass()` already has, before resolving the target. They also
1494pick up `cliPath` here (Task 2.1 gave `runServerHelp` a second
1495parameter but could not touch these two hand-built `RunE`s, since this
1496task is what rewrites their bodies): `auth keys add` and `auth pgp add`
1497are two of the eighteen commands whose CLI path differs from the
1498registered one, so their own usage refusals need it exactly like every
1499`pass()`-generated command's do.
1500
1501```go
1502 keysAdd.RunE = func(cmd *cobra.Command, args []string) error {
1503 cliPath := cliPathOf(cmd)
1504 for _, a := range args {
1505 if a == "--help" || a == "-h" {
1506 os.Exit(runServerHelp(passOpts{server: []string{"keys", "add"}}, cliPath))
1507 }
1508 }
1509 t, err := resolveTarget()
1510 if err != nil {
1511 return err
1512 }
1513 in, err := stdinPayload(os.Stdin, "an SSH public key", false)
1514 if err != nil {
1515 return err
1516 }
1517 os.Exit(runSSH(t, withCLIPath(cliPath, append([]string{"keys", "add"}, args...)), in))
1518 return nil
1519 }
1520```
1521
1522and, for `pgpAdd`:
1523
1524```go
1525 RunE: func(cmd *cobra.Command, args []string) error {
1526 cliPath := cliPathOf(cmd)
1527 for _, a := range args {
1528 if a == "--help" || a == "-h" {
1529 os.Exit(runServerHelp(passOpts{server: []string{"pgp", "add"}}, cliPath))
1530 }
1531 }
1532 t, err := resolveTarget()
1533 if err != nil {
1534 return err
1535 }
1536 in, err := stdinPayload(os.Stdin, "an armored OpenPGP public key", false)
1537 if err != nil {
1538 return err
1539 }
1540 os.Exit(runSSH(t, withCLIPath(cliPath, append([]string{"pgp", "add"}, args...)), in))
1541 return nil
1542 },
1543```
1544
1545- [ ] **Step 4: Run**
1546
1547Run: `go test ./cmd/gitbay -run TestKeysAddAndPGPAddCheckHelpBeforeStdin -count=1 -timeout 5s`
1548Expected: PASS.
1549
1550- [ ] **Step 5: Build and run the package**
1551
1552Run: `go build ./... && go test ./cmd/gitbay -count=1`
1553Expected: PASS.
1554
1555- [ ] **Step 6: Commit**
1556
1557```bash
1558git add cmd/gitbay/main.go cmd/gitbay/main_test.go
1559git commit -m "auth keys add, pgp add: check --help before reading stdin" -m "Ref #267"
1560```
1561
1562### Task 2.3: `auth --help` renders with the registry layout, in the CLI's own paths
1563
1564`auth` is a CLI-only grouping — no registry command's path starts with
1565`auth`, so `gitbay auth --help` asks the server for help on prefix
1566`"auth"`, gets `ExitNotFound`, and `cmd/gitbay/main.go`'s `group()`
1567falls back to cobra's own subcommand listing, which carries no flags or
1568examples (the reason `group()` exists at all, per its own comment).
1569Give the registry an alias table for CLI-only groupings so `auth`
1570renders the same READ/WRITE, aligned-summary layout every real noun
1571gets — and, since none of the rows it gathers (`keys add`, `account
1572export`, ...) are commands a caller can actually type, each row prints
1573the CLI path it really takes (`auth keys add`, `auth export`), the same
1574substitution Task 2.1 gave a single command's own usage line. Unlike
1575Task 2.1's mismatches, which are one registered path to one CLI path,
1576`auth` gathers several unrelated registered prefixes into one grouping,
1577and one of them (`account export` -> `auth export`) does not even keep
1578the same word count — the alias table has to carry the CLI form
1579alongside each registered prefix explicitly; it cannot be derived by
1580pattern-matching the prefix the way Task 2.1's single-command swap is.
1581
1582**Files:**
1583- Modify: `internal/control/help.go` (`runHelp`, `nounAliases`, `nounSummaries`)
1584- Modify: `cmd/gitbay/main.go` (`authCmd`'s `group("auth", ...)` description)
1585- Test: `internal/control/help_test.go`
1586
1587**Interfaces:**
1588- Produces: `type nounAlias struct { Registered, CLI string }`; `var
1589 nounAliases map[string][]nounAlias` — a CLI-only noun name to the
1590 registered prefixes it gathers, each paired with the CLI path that
1591 reaches it.
1592
1593- [ ] **Step 1: Write the failing tests**
1594
1595Two: the CLI form (a caller that sent `--path=auth`, as `gitbay auth
1596--help` now does per Task 2.1's `group`/`serverHelp` change), and the
1597ssh form (a caller that sent nothing, which cannot run an `auth
1598whatever` command and must not be told to).
1599
1600```go
1601func TestHelpRendersAnAliasedNounWithTheRegistryLayout(t *testing.T) {
1602 var out bytes.Buffer
1603 c := &Ctx{Stdout: &out, Term: Term{Cols: 100}, CLIPath: "auth"}
1604 if code := runHelp(c, []string{"auth"}); code != protocol.ExitOK {
1605 t.Fatalf("exit %d", code)
1606 }
1607 got := out.String()
1608 for _, want := range []string{"auth whoami", "auth keys list", "auth pgp add", "auth token create", "auth export"} {
1609 if !strings.Contains(got, want) {
1610 t.Errorf("missing %q in:\n%s", want, got)
1611 }
1612 }
1613 if strings.Contains(got, "no command matches") {
1614 t.Errorf("auth did not resolve: %s", got)
1615 }
1616}
1617
1618func TestHelpRendersAnAliasedNounInRegisteredFormOverSSH(t *testing.T) {
1619 var out bytes.Buffer
1620 c := &Ctx{Stdout: &out} // no Term, no CLIPath: exactly stock ssh
1621 if code := runHelp(c, []string{"auth"}); code != protocol.ExitOK {
1622 t.Fatalf("exit %d", code)
1623 }
1624 got := out.String()
1625 for _, want := range []string{"whoami", "keys list", "pgp add", "token create", "account export"} {
1626 if !strings.Contains(got, want) {
1627 t.Errorf("missing %q in:\n%s", want, got)
1628 }
1629 }
1630 if strings.Contains(got, "auth keys list") {
1631 t.Errorf("stock ssh should not see the CLI-only auth prefix: %s", got)
1632 }
1633}
1634```
1635
1636- [ ] **Step 2: Run and see them fail**
1637
1638Run: `go test ./internal/control -run TestHelpRendersAnAliasedNoun -count=1`
1639Expected: FAIL (`no command matches "auth"`).
1640
1641- [ ] **Step 3: Implement the alias table and the lookup change**
1642
1643In `internal/control/help.go`, near `nounSummaries`:
1644
1645```go
1646// nounAlias is one bucket of registered commands, reachable under a
1647// CLI-only noun that is not itself a registry path (auth, gathering
1648// several unrelated registry prefixes): Registered is what runHelp
1649// matches against the registry, CLI is the path a gitbay caller
1650// actually types to reach it — not always Registered with the alias's
1651// own name stitched on (account export -> auth export drops a word),
1652// so the two are paired explicitly rather than derived.
1653type nounAlias struct {
1654 Registered string
1655 CLI string
1656}
1657
1658// nounAliases groups a CLI-only noun into the real prefixes it gathers,
1659// so `help auth` renders with the same layout a real noun gets instead
1660// of falling back to whatever a caller does when help fails. A stock
1661// ssh caller — the only one who could ever ask for a bare "auth" and
1662// get nothing back from the registry — sees the Registered forms
1663// unchanged; the CLI, having sent its own path, sees CLI.
1664var nounAliases = map[string][]nounAlias{
1665 "auth": {
1666 {"account export", "auth export"},
1667 {"whoami", "auth whoami"},
1668 {"keys", "auth keys"},
1669 {"email", "auth email"},
1670 {"pgp", "auth pgp"},
1671 {"token", "auth token"},
1672 },
1673}
1674```
1675
1676and add, to `nounSummaries`:
1677
1678```go
1679 "auth": "whoami, SSH and PGP keys, email, API tokens",
1680```
1681
1682In `runHelp`, widen the match to every aliased prefix, and — only for a
1683caller that sent its own `CLIPath` — build the per-row override
1684`helpNoun` (Task 2.1) now accepts:
1685
1686```go
1687func runHelp(c *Ctx, args []string) int {
1688 prefix := joinPath(args)
1689 prefixes := []string{prefix}
1690 override := map[string]string{}
1691 if aliased, ok := nounAliases[prefix]; ok {
1692 prefixes = nil
1693 for _, a := range aliased {
1694 prefixes = append(prefixes, a.Registered)
1695 }
1696 if c.CLIPath != "" {
1697 for _, cmd := range registry {
1698 p := joinPath(cmd.Path)
1699 for _, a := range aliased {
1700 if p == a.Registered || strings.HasPrefix(p, a.Registered+" ") {
1701 override[p] = a.CLI + strings.TrimPrefix(p, a.Registered)
1702 break
1703 }
1704 }
1705 }
1706 }
1707 }
1708 var matched []Command
1709 for _, cmd := range registry {
1710 p := joinPath(cmd.Path)
1711 for _, pfx := range prefixes {
1712 if pfx == "" || p == pfx || strings.HasPrefix(p, pfx+" ") {
1713 matched = append(matched, cmd)
1714 break
1715 }
1716 }
1717 }
1718 if len(matched) == 0 {
1719 return c.fail(protocol.ExitNotFound, "no command matches %q; try: help", prefix)
1720 }
1721 slices.SortFunc(matched, func(a, b Command) int { return strings.Compare(joinPath(a.Path), joinPath(b.Path)) })
1722 entries := make([]helpEntry, len(matched))
1723 for i, cmd := range matched {
1724 entries[i] = helpEntry{Path: joinPath(cmd.Path), Summary: cmd.Summary, Usage: cmd.Usage, Flags: cmd.Flags, Examples: cmd.Examples}
1725 }
1726 return c.emit(entries, func(w io.Writer) {
1727 switch {
1728 case prefix == "":
1729 for _, e := range entries {
1730 summary := e.Summary
1731 if c.Term.Cols > 0 {
1732 if avail := c.Term.Cols - max(cells(e.Path), 24) - 1; avail > 0 {
1733 summary = clip(summary, avail)
1734 }
1735 }
1736 fmt.Fprintf(w, "%-24s %s\n", e.Path, summary)
1737 }
1738 case joinPath(matched[0].Path) == prefix:
1739 c.helpVerb(w, matched[0], matched[1:])
1740 default:
1741 c.helpNoun(w, prefix, matched, override)
1742 }
1743 })
1744}
1745```
1746
1747`matched[0].Path` never equals `"auth"` literally (nothing in the
1748registry is named that), so an aliased noun always takes the
1749`helpNoun` branch. `override` stays an empty (non-nil) map for every
1750ordinary noun — `override[full]` misses for every row, and `helpNoun`
1751falls back to its plain `strings.TrimPrefix` — so this changes nothing
1752for `help repo` or any other real prefix.
1753
1754- [ ] **Step 4: Sync the CLI's own description**
1755
1756`cmd/gitbay/main.go`'s `authCmd()`:
1757
1758```go
1759 return group("auth", "whoami, SSH and PGP keys, email, API tokens",
1760```
1761
1762(`TestGroupsSayWhatTheServerSays` checks this against
1763`nounSummaries["auth"]`, added above.)
1764
1765- [ ] **Step 5: Run**
1766
1767Run: `go test ./internal/control -run TestHelpRendersAnAliasedNoun -count=1`
1768Expected: PASS.
1769
1770- [ ] **Step 6: Run both packages**
1771
1772Run: `go test ./internal/control ./cmd/gitbay -count=1`
1773Expected: PASS.
1774
1775- [ ] **Step 7: Commit**
1776
1777```bash
1778git add internal/control/help.go internal/control/help_test.go cmd/gitbay/main.go
1779git commit -m "help: auth (and any future CLI-only grouping) renders with the registry layout, in the CLI's own paths" -m "Ref #267"
1780```
1781
1782### Task 2.4: verb-phrase summaries
1783
1784Six commands' one-line summaries are bare nouns rather than a phrase
1785saying what the command does: `issue comment`/`mr comment` ("comment"),
1786`issue label`/`mr label` ("labels"), `issue assign` ("assignees"), `mr
1787review` ("review").
1788
1789**Files:**
1790- Modify: `internal/control/issue.go:70`, `:93`, `:102`
1791- Modify: `internal/control/mr.go:146`, `:156`, `:176`
1792- Modify: `cmd/gitbay/summaries_gen.go` (regenerated, not hand-edited)
1793- Test: `cmd/gitbay/summaries_test.go` (existing `TestSummariesAreCurrent` enforces this)
1794
1795- [ ] **Step 1: Change the six `Summary` strings**
1796
1797`internal/control/issue.go:70`: `Summary: "add a comment",`
1798`internal/control/issue.go:93`: `Summary: "add or remove labels",`
1799`internal/control/issue.go:102`: `Summary: "add or remove assignees",`
1800`internal/control/mr.go:146`: `Summary: "add a comment",`
1801`internal/control/mr.go:156`: `Summary: "record a review verdict",`
1802`internal/control/mr.go:176`: `Summary: "add or remove labels",`
1803
1804- [ ] **Step 2: Regenerate `summaries_gen.go`**
1805
1806Run: `go test ./cmd/gitbay -run TestSummariesAreCurrent -update`
1807This rewrites `cmd/gitbay/summaries_gen.go`'s six affected map entries
1808(`"issue comment"`, `"issue label"`, `"issue assign"`, `"mr comment"`,
1809`"mr review"`, `"mr label"`) to the new strings; nothing else in the
1810generated file changes.
1811
1812- [ ] **Step 3: Run**
1813
1814Run: `go test ./internal/control ./cmd/gitbay -count=1`
1815Expected: PASS.
1816
1817- [ ] **Step 4: Commit and open the MR**
1818
1819```bash
1820git add internal/control/issue.go internal/control/mr.go cmd/gitbay/summaries_gen.go
1821git commit -m "summaries: verb phrases instead of bare nouns" -m "Closes #267"
1822git push -u origin cli-ux-help
1823gitbay mr create --source cli-ux-help --target main --title "CLI help and usage print the form the caller typed"
1824```
1825
1826Wait for CI, merge with `--strategy ff`, delete the branch both places.
1827
1828---
1829
1830# Part 3: unregistered key, issue create flags, mr show plurals, repo readme, mirror time (branch `cli-ux-fixes`, closes #268)
1831
1832### Task 3.1: the unregistered-key message names the fingerprint and the real host
1833
1834`runAnonymous` in `internal/sshd/sshd.go:332` tells a connecting
1835stranger to register with a literal `<host>` placeholder and no
1836fingerprint, whether they are truly unknown or someone on a new laptop
1837whose existing account has a different key. Print the fingerprint and
1838the real host, and offer both the web and the ssh path.
1839
1840**Files:**
1841- Modify: `internal/sshd/sshd.go` (`runAnonymous`)
1842- Test: `internal/sshd/sshd_test.go`
1843
1844- [ ] **Step 1: Write the failing test**
1845
1846```go
1847func TestUnregisteredKeyMessageNamesFingerprintAndHost(t *testing.T) {
1848 st, cleanup := newTestStore(t) // reuse whatever helper sshd_test.go's other tests use to open a migrated store
1849 defer cleanup()
1850 srv := &Server{st: st, cfg: config.Config{Server: config.Server{SiteURL: "https://forge.test"}}}
1851 pub, _, err := ed25519.GenerateKey(rand.Reader)
1852 if err != nil {
1853 t.Fatal(err)
1854 }
1855 sshPub, err := ssh.NewPublicKey(pub)
1856 if err != nil {
1857 t.Fatal(err)
1858 }
1859 var out bytes.Buffer
1860 ch := &fakeChannel{stderr: &out} // sshd_test.go's existing fake ssh.Channel, if it has one
1861 code := srv.runAnonymous(ch, base64.StdEncoding.EncodeToString(sshPub.Marshal()), "whoami")
1862 if code != protocol.ExitDenied {
1863 t.Fatalf("exit %d", code)
1864 }
1865 fp := ssh.FingerprintSHA256(sshPub)
1866 for _, want := range []string{fp, "forge.test", "https://forge.test/settings#keys", "ssh git@forge.test register"} {
1867 if !strings.Contains(out.String(), want) {
1868 t.Errorf("message missing %q:\n%s", want, out.String())
1869 }
1870 }
1871}
1872```
1873
1874`newTestStore`/`fakeChannel` are placeholders for whatever
1875`internal/sshd/sshd_test.go` already provides for its other
1876`runAnonymous`-adjacent tests — read the top of that file (`grep -n
1877"^func " internal/sshd/sshd_test.go`) and use its actual helpers rather
1878than the names guessed here.
1879
1880- [ ] **Step 2: Run and see it fail**
1881
1882Run: `go test ./internal/sshd -run TestUnregisteredKeyMessageNamesFingerprintAndHost -count=1`
1883Expected: FAIL (message contains the literal string `<host>`, no fingerprint).
1884
1885- [ ] **Step 3: Implement**
1886
1887```go
1888 if len(argv) == 0 || argv[0] != "register" {
1889 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://"), "/")
1890 fp := ssh.FingerprintSHA256(pub)
1891 flag := map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode]
1892 fmt.Fprintf(ch.Stderr(),
1893 "this key (%s) is not registered on %s.\n"+
1894 "already have an account? add it at https://%s/settings#keys\n"+
1895 "new here? ssh git@%s register --username <name> %s\n",
1896 fp, host, host, host, flag)
1897 return protocol.ExitDenied
1898 }
1899```
1900
1901- [ ] **Step 4: Run**
1902
1903Run: `go test ./internal/sshd -run TestUnregisteredKeyMessageNamesFingerprintAndHost -count=1`
1904Expected: PASS.
1905
1906- [ ] **Step 5: Run the package**
1907
1908Run: `go test ./internal/sshd -count=1`
1909Expected: PASS. A failing e2e-adjacent unit test asserting the old `this
1910key is not registered here` text needs its expectation updated the same
1911way.
1912
1913- [ ] **Step 6: Commit**
1914
1915```bash
1916git add internal/sshd/sshd.go internal/sshd/sshd_test.go
1917git commit -m "sshd: unregistered-key message names the fingerprint and the real host" -m "Ref #268"
1918```
1919
1920### Task 3.2: `issue create` takes `--label`, `--milestone`, `--assignee`
1921
1922`issue create` only sets title, body and format; labels, milestone and
1923assignees each need a separate call afterward, unlike the web form. Add
1924the three flags (label repeatable) and document that `$EDITOR` already
1925opens when neither `--body` nor `--file` is given (`cmd/gitbay/ssh.go`'s
1926`withRepo`/`maybeEditor` machinery already does this via `issueCmd()`'s
1927`editor: "issue"` — this task only adds the missing flags and says so
1928in the registered help).
1929
1930**Files:**
1931- Modify: `internal/control/issue.go` (`init`'s `issue create` registration, `runIssueCreate`)
1932- Test: `internal/control/issue_test.go`
1933
1934**Interfaces:**
1935- Consumes: `parseFlags`/`flagSpec.Multi` (existing), `c.Store.SetIssueLabel`, `c.Store.MilestoneByTitle`, `c.Store.SetIssueMilestone`, `c.Store.UserByUsername`, `c.Store.SetIssueAssignee` (all existing store methods).
1936
1937- [ ] **Step 1: Write the failing test**
1938
1939```go
1940func TestIssueCreateSetsLabelsMilestoneAndAssignee(t *testing.T) {
1941 c := notifTestCtx(t, "alice")
1942 repoID, err := c.Store.CreateRepo("user", c.User.ID, "app", "public")
1943 if err != nil {
1944 t.Fatal(err)
1945 }
1946 repo, err := c.Store.RepoByID(repoID)
1947 if err != nil {
1948 t.Fatal(err)
1949 }
1950 if err := c.Store.SetLabel(repo, "bug", "ff0000"); err != nil {
1951 t.Fatal(err)
1952 }
1953 if _, err := c.Store.CreateMilestone(repo.ID, "m1", ""); err != nil {
1954 t.Fatal(err)
1955 }
1956 if _, err := c.Store.CreateUser("bob", false); err != nil {
1957 t.Fatal(err)
1958 }
1959
1960 if code := runIssueCreate(c, []string{repo.Path(), "--title", "t",
1961 "--label", "bug", "--milestone", "m1", "--assignee", "bob"}); code != 0 {
1962 t.Fatalf("exit %d: %s", code, c.Stderr.(*bytes.Buffer).String())
1963 }
1964 issue, err := c.Store.IssueByNumber(repo.ID, 1)
1965 if err != nil {
1966 t.Fatal(err)
1967 }
1968 if len(issue.Labels) != 1 || issue.Labels[0] != "bug" {
1969 t.Errorf("labels = %v", issue.Labels)
1970 }
1971 if issue.Milestone != "m1" {
1972 t.Errorf("milestone = %q", issue.Milestone)
1973 }
1974 if len(issue.Assignees) != 1 || issue.Assignees[0] != "bob" {
1975 t.Errorf("assignees = %v", issue.Assignees)
1976 }
1977}
1978```
1979
1980(`c.Store.SetLabel`/`CreateMilestone` are placeholders for the real
1981label/milestone creation helpers — `grep -n "func (s \*Store)
1982SetLabel\|func (s \*Store) CreateMilestone" internal/store/*.go` for
1983their actual names and signatures and use those; `issue.Milestone`
1984similarly needs to match whatever field `store.Issue` actually carries
1985for its milestone title, e.g. via `grep -n "Milestone" internal/store/issues.go`.)
1986
1987- [ ] **Step 2: Run and see it fail**
1988
1989Run: `go test ./internal/control -run TestIssueCreateSetsLabelsMilestoneAndAssignee -count=1`
1990Expected: FAIL, exit 2 (`--label` not accepted).
1991
1992- [ ] **Step 3: Register the new flags**
1993
1994```go
1995 register(Command{Path: []string{"issue", "create"},
1996 Summary: "open an issue",
1997 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org] [--label <l>]... [--milestone <title>] [--assignee <user>]...",
1998 Flags: []Flag{
1999 {"--title", "<t>", "the issue's title", ""},
2000 {"--body", "<b>", "the issue's body", ""},
2001 {"--file", "-", "read the body from stdin", ""},
2002 {"--format", "md|org", "the body's markup", "md"},
2003 {"--label", "<l>", "label to add, may repeat", ""},
2004 {"--milestone", "<title>", "milestone to set", ""},
2005 {"--assignee", "<user>", "user to assign, may repeat", ""},
2006 },
2007 Examples: []string{
2008 `issue create krz/gitbay --title "crash on empty repo" --body "steps to reproduce..."`,
2009 "issue create krz/gitbay --title notes --file - < notes.md",
2010 "issue create krz/gitbay --title bug --label bug --label priority --milestone v1 --assignee cmc",
2011 },
2012 ReadsStdin: true, Run: runIssueCreate})
2013```
2014
2015Note in a doc comment above `runIssueCreate`, since the flags list
2016above has no room for prose: `$EDITOR` opens for the body when the CLI
2017is asked for neither `--body` nor `--file` — that behavior is entirely
2018client-side (`cmd/gitbay/main.go`'s `issueCmd()` already sets
2019`editor: "issue"`), this registration only documents it:
2020
2021```go
2022// runIssueCreate opens an issue. The CLI opens $EDITOR for the body
2023// when neither --body nor --file is given (cmd/gitbay's issueCmd,
2024// editor: "issue"); over stock ssh the body must be one of the two.
2025func runIssueCreate(c *Ctx, args []string) int {
2026 f, err := parseFlags(args, flagSpec{
2027 Values: []string{"--format", "--title", "--body", "--file", "--milestone"},
2028 Multi: []string{"--label", "--assignee"},
2029 MaxPos: 1,
2030 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org] [--label <l>]... [--milestone <title>] [--assignee <user>]...",
2031 })
2032 if err != nil {
2033 return c.fail(protocol.ExitUsage, "%v", err)
2034 }
2035```
2036
2037- [ ] **Step 4: Set labels, milestone and assignees after creation**
2038
2039After the existing `n, err := c.Store.CreateIssue(...)` block and its
2040`RecordEvent`/notify calls, before the final `return c.emit(...)`:
2041
2042```go
2043 for _, l := range f.List("--label") {
2044 if err := c.Store.SetIssueLabel(repo, n, l, true); err != nil {
2045 return c.failErr(err)
2046 }
2047 }
2048 if m := f.Value("--milestone"); m != "" {
2049 ms, err := c.Store.MilestoneByTitle(repo, m)
2050 if err != nil {
2051 return milestoneErr(c, repo, m, err)
2052 }
2053 if err := c.Store.SetIssueMilestone(n, ms.ID); err != nil {
2054 return c.fail(protocol.ExitFailure, "%v", err)
2055 }
2056 }
2057 for _, name := range f.List("--assignee") {
2058 u, err := c.Store.UserByUsername(name)
2059 if errors.Is(err, store.ErrNotFound) {
2060 return c.fail(protocol.ExitNotFound, "no such user %q", name)
2061 }
2062 if err != nil {
2063 return c.fail(protocol.ExitFailure, "%v", err)
2064 }
2065 if err := c.Store.SetIssueAssignee(n, u.ID, true); err != nil {
2066 return c.fail(protocol.ExitFailure, "%v", err)
2067 }
2068 }
2069```
2070
2071`SetIssueLabel`'s second parameter in `runIssueLabel` is `issue.ID`, not
2072the issue number — `CreateIssue` returns the number `n`, so fetch the
2073row first if `SetIssueLabel`/`SetIssueMilestone`/`SetIssueAssignee` all
2074key on the database id rather than the number (check each store
2075method's actual first parameter — `grep -n "func (s \*Store)
2076SetIssueLabel\|SetIssueMilestone\|SetIssueAssignee" internal/store/*.go`
2077and adjust to fetch `issue, err := c.Store.IssueByNumber(repo.ID, n)`
2078first if any of them needs `issue.ID` rather than `n`). Add
2079`"errors"` to the file's imports if not already present.
2080
2081- [ ] **Step 5: Run**
2082
2083Run: `go test ./internal/control -run TestIssueCreateSetsLabelsMilestoneAndAssignee -count=1`
2084Expected: PASS.
2085
2086- [ ] **Step 6: Run the package, regenerate the CLI summary if `Usage` changed its flag list**
2087
2088Run: `go test ./internal/control -count=1`
2089Expected: PASS (the `Summary` string is unchanged, so
2090`summaries_gen.go` does not need regenerating — only `Usage`/`Flags`
2091changed, which is not part of that generated file).
2092
2093- [ ] **Step 7: Commit**
2094
2095```bash
2096git add internal/control/issue.go internal/control/issue_test.go
2097git commit -m "issue create: --label, --milestone, --assignee" -m "Ref #268"
2098```
2099
2100### Task 3.3: `mr show` pluralizes its multi-row section headings
2101
2102`mr show`'s commit/check/review sub-tables print a singular label
2103(`commit:`, `check:`) even when they hold several rows.
2104
2105**Files:**
2106- Modify: `internal/control/mr.go` (the three `v.section(...)` calls around lines 793, 802, 811)
2107- Test: `internal/control/mr_test.go`
2108
2109- [ ] **Step 1: Write the failing test**
2110
2111Find `mr show`'s existing plain-output test (`grep -n "func Test.*MRShow"
2112internal/control/mr_test.go`) and add a case with more than one commit,
2113check and review, asserting the plural, counted heading:
2114
2115```go
2116func TestMRShowPluralizesMultiRowSections(t *testing.T) {
2117 // build on whatever fixture the existing MR-show tests in this file
2118 // use to get a repo with an open MR; push two commits onto its
2119 // source branch, set two statuses, and record two reviews before
2120 // calling runMRShow, following that fixture's own setup exactly.
2121 ...
2122 out := ... // runMRShow's plain stdout
2123 for _, want := range []string{"commits (2):", "checks (2):", "reviews (2):"} {
2124 if !strings.Contains(out, want) {
2125 t.Errorf("missing %q in:\n%s", want, out)
2126 }
2127 }
2128}
2129```
2130
2131- [ ] **Step 2: Run and see it fail**
2132
2133Run: `go test ./internal/control -run TestMRShowPluralizesMultiRowSections -count=1`
2134Expected: FAIL (headings read `commit:`, `check:`, `review:`).
2135
2136- [ ] **Step 3: Implement**
2137
2138```go
2139 if len(commits) > 1 {
2140 v.section(fmt.Sprintf("commits (%d)", len(commits)))
2141 tb := c.table(w, "SHA", "SUBJECT")
2142 ...
2143 }
2144
2145 if len(checks) > 1 {
2146 v.section(fmt.Sprintf("checks (%d)", len(checks)))
2147 tb := c.table(w, "CHECK", "STATE", "DURATION", "UPDATED")
2148 ...
2149 }
2150
2151 if len(rs) > 1 {
2152 v.section(fmt.Sprintf("reviews (%d)", len(rs)))
2153 tb := c.table(w, "REVIEWER", "VERDICT", "WHEN")
2154 ...
2155 }
2156```
2157
2158(`v.section` prints `label + ":"` in plain mode already — do not add a
2159trailing colon inside the `fmt.Sprintf` string.)
2160
2161- [ ] **Step 4: Run**
2162
2163Run: `go test ./internal/control -run TestMRShow -count=1`
2164Expected: PASS.
2165
2166- [ ] **Step 5: Run the package**
2167
2168Run: `go test ./internal/control -count=1`
2169Expected: PASS.
2170
2171- [ ] **Step 6: Commit**
2172
2173```bash
2174git add internal/control/mr.go internal/control/mr_test.go
2175git commit -m "mr show: pluralize commits/checks/reviews section headings" -m "Ref #268"
2176```
2177
2178### Task 3.4: `repo readme` prints a repository's README
2179
2180No command prints a repository's README; the web page's own
2181README-picking logic (`pickReadme` in `internal/httpd/web.go`) is not
2182reachable from `internal/control`. Move it into `internal/control`,
2183exported, and add `repo readme <owner/name> [--ref <ref>]` following
2184`repo cat`'s shape.
2185
2186**Files:**
2187- Modify: `internal/control/read.go` (new `repo readme` registration and `runRepoReadme`, model on `runRepoCat`/`runRepoTree`)
2188- Modify: `internal/httpd/web.go` (move `readmeRank`/`pickReadme` out, call site at line 660 updated)
2189- Modify: `cmd/gitbay/main.go` (`repoCmd`, new `pass("readme", ...)`)
2190- Modify: `e2e/readonly_test.go` (`readArgs["repo readme"]`)
2191- Modify: `.gitbay/wiki/Parity.org` (Repositories table)
2192- Test: `internal/control/read_test.go`
2193
2194**Interfaces:**
2195- Produces: `func PickReadme(entries []gitutil.TreeEntry) string` (moved from `internal/httpd`, exported).
2196
2197- [ ] **Step 1: Move `readmeRank`/`pickReadme`**
2198
2199Cut both from `internal/httpd/web.go` (around lines 1185–1210) and paste
2200into `internal/control/read.go`, renaming `pickReadme` to `PickReadme`:
2201
2202```go
2203// readmeRank orders competing README files: richer renderers win.
2204var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
2205
2206// PickReadme returns the best README-ish blob in a tree listing: any
2207// file named "readme" or "readme.<ext>" (case-insensitive), preferring
2208// formats we can render richly.
2209func PickReadme(entries []gitutil.TreeEntry) string {
2210 best, bestRank := "", 1<<30
2211 for _, e := range entries {
2212 if e.Type != "blob" {
2213 continue
2214 }
2215 lower := strings.ToLower(e.Name)
2216 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
2217 continue
2218 }
2219 rank, ok := readmeRank[path.Ext(lower)]
2220 if !ok {
2221 rank = 10 // plaintext fallback
2222 }
2223 if rank < bestRank {
2224 best, bestRank = e.Name, rank
2225 }
2226 }
2227 return best
2228}
2229```
2230
2231In `internal/httpd/web.go`, the call site at line 660 becomes
2232`readmeName := control.PickReadme(entries)`. Remove the unused `"path"`
2233import from `web.go` only if nothing else in the file still uses it
2234(`grep -n '"path"' internal/httpd/web.go` and `grep -n "path\."
2235internal/httpd/web.go` — this file is large and almost certainly uses
2236`path` elsewhere, so this removal is likely a no-op check, not an edit).
2237
2238- [ ] **Step 2: Build to confirm the move alone is clean**
2239
2240Run: `go build ./... && go vet ./...`
2241Expected: no errors.
2242
2243- [ ] **Step 3: Write the failing test for the new command**
2244
2245```go
2246func TestRepoReadmePicksTheRichestFormat(t *testing.T) {
2247 st, repo, uid := newQueueTestRepo(t)
2248 dir := RepoDir(config.Config{}.Server.Root, repo.OwnerName, repo.Name) // adjust to however read_test.go's existing repo-cat tests get a working tree with committed files — reuse that helper rather than re-deriving RepoDir's root
2249 git := gitRunner(t)
2250 git(dir, "init", "--bare") // only if newQueueTestRepo does not already leave a real git repo on disk; check runRepoCat's own test setup and mirror it exactly
2251 ...
2252 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
2253 if code := Dispatch(c, []string{"repo", "readme", repo.Path()}); code != protocol.ExitOK {
2254 t.Fatalf("exit %d: %s", code, errOut)
2255 }
2256 if got := c.Stdout.(*bytes.Buffer).String(); got != "# app\n\nhello\n" {
2257 t.Errorf("readme = %q", got)
2258 }
2259}
2260```
2261
2262`runRepoCat`'s own test in `internal/control/read_test.go` already sets
2263up a real on-disk repository with a committed file — copy that setup
2264exactly (bare repo, a work tree pushed into it, matching
2265`gitTestEnv()`/`gitRunner(t)` from `build_test.go`) rather than
2266reinventing it; commit a `README.md` instead of whatever file that test
2267uses.
2268
2269- [ ] **Step 4: Run and see it fail**
2270
2271Run: `go test ./internal/control -run TestRepoReadmePicksTheRichestFormat -count=1`
2272Expected: FAIL (`unknown command "readme"`).
2273
2274- [ ] **Step 5: Register the command and implement it**
2275
2276In `internal/control/read.go`'s `init()`, after the `repo cat`
2277registration:
2278
2279```go
2280 register(Command{
2281 Path: []string{"repo", "readme"},
2282 Summary: "print a repository's README",
2283 Usage: "repo readme <owner/name> [--ref <ref>]",
2284 Flags: []Flag{
2285 {"--ref", "<ref>", "branch, tag or commit to read", "the default branch"},
2286 },
2287 Examples: []string{"repo readme krz/gitbay"},
2288 ReadOnly: true,
2289 Run: runRepoReadme,
2290 })
2291```
2292
2293```go
2294func runRepoReadme(c *Ctx, args []string) int {
2295 pos, ref, code := readArgs(c, args, c.Cmd.Usage, 1)
2296 if code >= 0 {
2297 return code
2298 }
2299 if len(pos) != 1 {
2300 return c.usage()
2301 }
2302 repo, code := resolveRepo(c, pos[0], policy.CanRead)
2303 if code >= 0 {
2304 return code
2305 }
2306 if ref == "" {
2307 ref = repo.DefaultBranch
2308 }
2309 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
2310 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
2311 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
2312 }
2313 entries, err := gitutil.ListTree(dir, ref, "")
2314 if err != nil {
2315 return c.fail(protocol.ExitNotFound, "no such path in %s at %s", repo.Path(), ref)
2316 }
2317 name := PickReadme(entries)
2318 if name == "" {
2319 return c.fail(protocol.ExitNotFound, "%s has no README at %s", repo.Path(), ref)
2320 }
2321 limit := c.Cfg.Limits.MaxBlobBytes
2322 data, err := gitutil.ReadBlob(dir, ref, name, limit+1)
2323 if err != nil {
2324 return c.fail(protocol.ExitFailure, "%v", err)
2325 }
2326 truncated := int64(len(data)) > limit
2327 if truncated {
2328 data = data[:limit]
2329 }
2330 binary := gitutil.IsBinary(data)
2331 type out struct {
2332 Path string `json:"path"`
2333 Ref string `json:"ref"`
2334 File string `json:"file"`
2335 Size int `json:"size"`
2336 Truncated bool `json:"truncated,omitempty"`
2337 Binary bool `json:"binary,omitempty"`
2338 Content string `json:"content,omitempty"`
2339 Base64 string `json:"base64,omitempty"`
2340 }
2341 d := out{Path: repo.Path(), Ref: ref, File: name, Size: len(data), Truncated: truncated, Binary: binary}
2342 if binary {
2343 d.Base64 = base64.StdEncoding.EncodeToString(data)
2344 } else {
2345 d.Content = string(data)
2346 }
2347 return c.emit(d, func(w io.Writer) {
2348 if binary {
2349 fmt.Fprintf(w, "%s is binary (%d bytes)\n", d.File, d.Size)
2350 return
2351 }
2352 io.WriteString(w, d.Content)
2353 if truncated {
2354 fmt.Fprintln(w, "... truncated")
2355 }
2356 })
2357}
2358```
2359
2360(Match `runRepoCat`'s actual truncation/binary field names and JSON tags
2361exactly — read the rest of its `out` struct at
2362`internal/control/read.go:355` onward and copy its shape rather than
2363inventing a divergent one, so a client handles both commands the same
2364way.)
2365
2366- [ ] **Step 6: Run**
2367
2368Run: `go test ./internal/control -run TestRepoReadmePicksTheRichestFormat -count=1`
2369Expected: PASS.
2370
2371- [ ] **Step 7: Wire the CLI passthrough**
2372
2373`cmd/gitbay/main.go`'s `repoCmd()`, next to `pass("cat", ...)`:
2374
2375```go
2376 pass("readme", passOpts{server: []string{"repo", "readme"}, needsRepo: true}),
2377```
2378
2379- [ ] **Step 8: Add it to the ReadOnly coverage list**
2380
2381`e2e/readonly_test.go`'s `readArgs` map gains, next to `"repo refs"`:
2382
2383```go
2384 "repo readme": {"alice/app"},
2385```
2386
2387(the fixture's `alice/app` already has a committed `README.md`, so this
2388does not need a `notFoundOK` entry.)
2389
2390- [ ] **Step 9: Update Parity**
2391
2392`.gitbay/wiki/Parity.org`'s Repositories table gains a row, next to
2393`| read a file | yes | yes | yes |`:
2394
2395```
2396| render a README | yes | yes | yes |
2397```
2398
2399- [ ] **Step 10: Run the full local suite for touched packages**
2400
2401Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/httpd ./cmd/gitbay -count=1`
2402Expected: PASS.
2403
2404- [ ] **Step 11: Commit**
2405
2406```bash
2407git add internal/control/read.go internal/control/read_test.go internal/httpd/web.go cmd/gitbay/main.go e2e/readonly_test.go .gitbay/wiki/Parity.org
2408git commit -m "repo readme: print a repository's README, the web page's file order" -m "Ref #268"
2409```
2410
2411### Task 3.5: `repo show`'s mirror time drops the milliseconds
2412
2413`repo show`'s mirror sub-table prints `LAST SYNC` with milliseconds
2414(`2026-09-24T15:31:50.839Z`) instead of the second-truncated form every
2415other timestamp in a `view` uses.
2416
2417**Files:**
2418- Modify: `internal/control/repo.go` (`runRepoShow`'s mirror table row, around line 474)
2419- Test: `internal/control/repo_test.go`
2420
2421- [ ] **Step 1: Write the failing test**
2422
2423Find `repo show`'s existing mirror-table test (`grep -n
2424"func Test.*Mirror" internal/control/repo_test.go`), or add one if none
2425exists:
2426
2427```go
2428func TestRepoShowMirrorTimeIsTruncatedToTheSecond(t *testing.T) {
2429 c, repo, _ := newQueueTestRepo(t) // adjust to whatever gives an admin Ctx over a repo with a mirror row in repo_test.go's existing fixtures
2430 if err := c.Store.CreateMirror(repo.ID, "push", "ssh://example.test/x.git", ""); err != nil {
2431 t.Fatal(err)
2432 }
2433 if err := c.Store.MarkMirrorSynced(repo.ID, "ssh://example.test/x.git", "2026-09-24T15:31:50.839Z"); err != nil {
2434 t.Fatal(err)
2435 }
2436 var out bytes.Buffer
2437 c.Stdout, c.User.IsAdmin = &out, true // repo show's mirror section is admin-only in this Ctx
2438 if code := runRepoShow(c, []string{repo.Path()}); code != 0 {
2439 t.Fatalf("exit %d", code)
2440 }
2441 if strings.Contains(out.String(), ".839Z") {
2442 t.Errorf("milliseconds leaked: %s", out.String())
2443 }
2444 if !strings.Contains(out.String(), "2026-09-24T15:31:50Z") {
2445 t.Errorf("no truncated timestamp: %s", out.String())
2446 }
2447}
2448```
2449
2450(`CreateMirror`/`MarkMirrorSynced` are placeholders — `grep -n "func (s
2451\*Store) .*Mirror" internal/store/*.go` for the real names/signatures
2452that get a `ListMirrors` row with a non-empty `LastSync`, and use those;
2453`runRepoShow`'s mirror section additionally requires
2454`policy.CanAdmin(c.User, repo, grant)` to hold for the caller, so the
2455test's `Ctx` needs to be the repo's owner or otherwise admin over it —
2456`newQueueTestRepo`'s `uid` already owns the repo it returns, which
2457satisfies that.)
2458
2459- [ ] **Step 2: Run and see it fail**
2460
2461Run: `go test ./internal/control -run TestRepoShowMirrorTimeIsTruncatedToTheSecond -count=1`
2462Expected: FAIL (`.839Z` present).
2463
2464- [ ] **Step 3: Implement**
2465
2466```go
2467 tb.row(cText(m.Direction), cFlex(m.URL), cText(orDash(c.when(m.LastSync))), cState(status))
2468```
2469
2470(`c.when` is already what every other timestamp in a `view` goes
2471through: RFC3339-to-the-second in plain output, `2006-01-02 15:04 UTC`
2472at a terminal; `orDash` keeps an empty `LastSync` — a mirror that has
2473never synced — printing `-` rather than an empty cell, since `c.when("")`
2474returns `""` unchanged.)
2475
2476- [ ] **Step 4: Run**
2477
2478Run: `go test ./internal/control -run TestRepoShowMirrorTimeIsTruncatedToTheSecond -count=1`
2479Expected: PASS.
2480
2481- [ ] **Step 5: Run the package**
2482
2483Run: `go test ./internal/control -count=1`
2484Expected: PASS.
2485
2486- [ ] **Step 6: Commit, open the MR**
2487
2488```bash
2489git add internal/control/repo.go internal/control/repo_test.go
2490git commit -m "repo show: truncate the mirror's last-sync time to the second" -m "Closes #268"
2491git push -u origin cli-ux-fixes
2492gitbay mr create --source cli-ux-fixes --target main --title "CLI UX review small fixes"
2493```
2494
2495Wait for CI, merge with `--strategy ff`, delete the branch both places.
2496
2497---
2498
2499## Self-review
2500
2501**Spec coverage** (against #265/#267/#268's text, this plan's spec):
2502
2503- #265: sentences for activity (Task 1.1–1.3), no duplicate assigned
2504 issues (Task 1.4), one empty-state wording for `notifications list`
2505 (Task 1.5). The issue's other empty-state line ("Empty sections print
2506 none; empty lists elsewhere print nothing to list on stderr") already
2507 matches current behavior (`internal/control/dashboard.go`'s `section`
2508 helper, `internal/control/control.go`'s `emit`) — no task needed.
2509- #267: CLI sends `--term`/`Ctx.Term` (already present; verified, not
2510 re-implemented) and now also `--path`/`Ctx.CLIPath`, its own invoking
2511 path — the same mechanism, a sibling prefix — used by
2512 `usage()`/`usageWith()`/`helpVerb`/`helpNoun` so the eighteen commands
2513 whose CLI path differs from the registered one print a command that
2514 exists (Task 2.1); `[<owner/name>]` optional from the CLI (Task 2.1);
2515 `--help` check in `keys add`/`pgp add`, now also sending their own
2516 `cliPath` (Task 2.2); `auth` rendered with the registry layout, each
2517 row in the CLI's own path (Task 2.3); verb-phrase summaries (Task 2.4).
2518- #268: unregistered-key message (Task 3.1); `issue create` flags
2519 (Task 3.2); `mr show` plurals (Task 3.3); `repo readme` (Task 3.4);
2520 `repo show` mirror time (Task 3.5).
2521
2522**Placeholder scan:** Tasks 3.3 and 3.4's tests name real assertions but
2523lean on "copy this file's existing fixture setup" rather than spelling
2524out git plumbing calls verbatim, and Task 3.1's test invents
2525`newTestStore`/`fakeChannel` names to be replaced by whatever
2526`internal/sshd/sshd_test.go` actually has. That is intentional, not a
2527placeholder in the sense the skill warns against: the actual assertions
2528(what strings must appear, what exit code, what store rows) are
2529concrete; only the test-scaffolding names are marked as needing a look
2530at each file's neighbors before typing them in, because this plan was
2531written from reading the production code, not the test helper's exact
2532current shape in every file it touches. Anyone executing this plan
2533reads the named test file's other tests first, per each step's own
2534instruction, before writing the step.
2535
2536**Type consistency:** `FeedLine`/`FeedLines`/`WorstStatus` (Task 1.1)
2537are used with the same names in Tasks 1.2 and 1.3. `Ctx.CLIPath`,
2538`cmdUsage`/`cliUsage`/`shownAs` and the CLI-side `cliPathOf`/
2539`withCLIPath` (Task 2.1) are used with the same names and signatures in
2540Task 2.2 (`keysAdd.RunE`/`pgpAdd.RunE` sending their own `cliPath`) and
2541Task 2.3 (`helpNoun`'s `override` map, the alias table's `CLI` field
2542built from the same substitution `shownAs` performs for a single
2543command). `PickReadme` (Task 3.4) is the only name introduced for that
2544logic and is used consistently in its own task.