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