Commit 5e5b5210b9

5e5b5210b9cce50da2aa03708af89d771b38f3ed

parent: 92c7105063

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 04:32 UTC

control: releases, snippets, wiki, explore and audit as screens

release list and show, snippet list and show, wiki list and show (the
piped page source is unchanged), explore and audit. Test helpers set
the snippet and blob limits an empty config leaves at zero.

Ref #319

Layout: unified · split

internal/control/audit.go +12 −6
@@ -54,20 +54,26 @@ func runAudit(c *Ctx, args []string) int {
5454 if err != nil {
5555 return c.fail(protocol.ExitFailure, "%v", err)
5656 }
57 return c.emit(entries, func(w io.Writer) {
57 return c.emitView(entries, func(w io.Writer) {
5858 tb := c.table(w, "WHEN", "ACTOR", "ACTION", "DATA")
5959 for _, e := range entries {
6060 actor := e.Actor
6161 if actor == "" {
6262 actor = "-"
6363 }
64 data := e.Data
65 if c.Term.Cols > 0 {
66 data = keyValues(data)
67 }
68 tb.row(cAge(e.CreatedAt), cText(actor), cText(e.Action), cFlex(data))
64 tb.row(cAge(e.CreatedAt), cText(actor), cText(e.Action), cFlex(e.Data))
6965 }
7066 tb.flush()
67 }, func() screen {
68 rows := make([]row, len(entries))
69 for i, e := range entries {
70 actor := e.Actor
71 if actor == "" {
72 actor = "-"
73 }
74 rows[i] = rowOf(cAge(e.CreatedAt), cText(actor), cText(e.Action), cFlex(keyValues(e.Data)))
75 }
76 return listScreen("Audit", rows, action{"Filter", []string{"audit", "--since", "24h"}})
7177 })
7278}
7379
internal/control/explore.go +15 −1
@@ -85,12 +85,26 @@ func runExplore(c *Ctx, args []string) int {
8585 }
8686 }
8787 ds, next := trimPage(p, ds, "explore", func(o out) string { return o.Path })
88 return c.emitPage(p, ds, next, func(w io.Writer) {
88 return c.emitPageView(p, ds, next, func(w io.Writer) {
8989 tb := c.table(w, "PATH", "DESCRIPTION")
9090 for _, d := range ds {
9191 tb.row(cRef(d.Path), cFlex(d.Description))
9292 }
9393 tb.flush()
94 }, func() screen {
95 rows := make([]row, len(ds))
96 for i, d := range ds {
97 archived := ""
98 if d.Archived {
99 archived = "archived"
100 }
101 rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cFlex(d.Description), cMeta(strings.Join(d.Topics, ", "), archived))
102 }
103 s := listScreen("Explore", rows)
104 if len(ds) > 0 {
105 s.actions = []action{{"Read", []string{"repo", "show", ds[0].Path}}}
106 }
107 return s
94108 })
95109}
96110
internal/control/release.go +39 −27
@@ -282,26 +282,7 @@ func runReleaseList(c *Ctx, args []string) int {
282282 for _, r := range rels {
283283 ds = append(ds, releaseToOut(r, false))
284284 }
285 return c.emitPage(p, ds, next, func(w io.Writer) {
286 if c.Term.Cols > 0 {
287 tb := c.table(w, "TAG", "TITLE", "ASSETS", "RELEASED")
288 for _, d := range ds {
289 // "v1.2.0 — the forge speaks first" reads as its
290 // subtitle beside the tag.
291 title := strings.TrimPrefix(strings.TrimPrefix(d.Title, d.Tag), " — ")
292 assets := ""
293 switch n := len(d.Assets); n {
294 case 0:
295 case 1:
296 assets = "1 asset"
297 default:
298 assets = fmt.Sprintf("%d assets", n)
299 }
300 tb.row(cLink(d.Tag, c.siteURL(repo.Path(), "releases")), cFlex(title), cText(assets), cAge(d.CreatedAt))
301 }
302 tb.flush()
303 return
304 }
285 return c.emitPageView(p, ds, next, func(w io.Writer) {
305286 tb := c.table(w, "TAG", "TITLE", "ASSETS")
306287 for _, d := range ds {
307288 title := d.Title
@@ -311,16 +292,37 @@ func runReleaseList(c *Ctx, args []string) int {
311292 tb.row(cRef(d.Tag), cFlex(title), cText(fmt.Sprintf("%d asset(s)", len(d.Assets))))
312293 }
313294 tb.flush()
295 }, func() screen {
296 rows := make([]row, len(ds))
297 for i, d := range ds {
298 // "v1.2.0 — the forge speaks first" reads as its subtitle
299 // beside the tag.
300 title := strings.TrimPrefix(strings.TrimPrefix(d.Title, d.Tag), " — ")
301 assets := ""
302 switch n := len(d.Assets); n {
303 case 0:
304 case 1:
305 assets = "1 asset"
306 default:
307 assets = fmt.Sprintf("%d assets", n)
308 }
309 rows[i] = rowOf(cLink(d.Tag, c.siteURL(repo.Path(), "releases")), cFlex(title), cMeta(assets, relAge(d.CreatedAt, termNow())))
310 }
311 s := listScreen("Releases", rows)
312 if len(ds) > 0 {
313 s.actions = []action{{"Read", []string{"release", "show", repo.Path(), ds[0].Tag}}}
314 }
315 return s
314316 })
315317}
316318
317319func runReleaseShow(c *Ctx, args []string) int {
318 _, rel, code := releaseRef(c, args, policy.CanRead)
320 repo, rel, code := releaseRef(c, args, policy.CanRead)
319321 if code >= 0 {
320322 return code
321323 }
322324 d := releaseToOut(rel, true)
323 return c.emit(d, func(w io.Writer) {
325 return c.emitView(d, func(w io.Writer) {
324326 title := d.Title
325327 if title == d.Tag {
326328 title = ""
@@ -335,14 +337,24 @@ func runReleaseShow(c *Ctx, args []string) int {
335337 v.section("assets")
336338 tb := c.table(w, "NAME", "SIZE", "SHA256")
337339 for _, a := range d.Assets {
338 sum := a.SHA256
339 if c.Term.Cols > 0 {
340 sum = sum[:min(12, len(sum))]
341 }
342 tb.row(cRef(a.Name), cSize(a.Size), cFlex(sum))
340 tb.row(cRef(a.Name), cSize(a.Size), cFlex(a.SHA256))
343341 }
344342 tb.flush()
345343 }
344 }, func() screen {
345 assets := section{title: "Assets", n: len(d.Assets)}
346 for _, a := range d.Assets {
347 assets.rows = append(assets.rows, rowOf(cRef(a.Name), cSize(a.Size), cMeta(a.SHA256[:min(12, len(a.SHA256))])))
348 }
349 s := screen{body: d.Notes, format: d.NotesFormat, sections: []section{assets}, fields: []field{
350 {"Release", []cell{cLink(d.Tag, c.siteURL(repo.Path(), "releases")), cText(d.Title)}},
351 {"Author", []cell{cText(d.Author), cAge(d.CreatedAt)}},
352 }}
353 if len(d.Assets) > 0 {
354 s.actions = append(s.actions, action{"Get", []string{"release", "asset", "get", repo.Path(), d.Tag, d.Assets[0].Name}})
355 }
356 s.actions = append(s.actions, action{"Edit", []string{"release", "edit", repo.Path(), d.Tag, "--title", "<title>"}})
357 return s
346358 })
347359}
348360
internal/control/snippet.go +32 −2
@@ -7,6 +7,7 @@ import (
77 "fmt"
88 "io"
99 "strconv"
10 "strings"
1011 "unicode/utf8"
1112
1213 "gitbay.org/gitbay/internal/policy"
@@ -222,7 +223,7 @@ func runSnippetShow(c *Ctx, args []string) int {
222223 return c.fail(protocol.ExitFailure, "%v", err)
223224 }
224225 sn.Files = files
225 return c.emit(snippetOut(c, sn), func(w io.Writer) {
226 return c.emitView(snippetOut(c, sn), func(w io.Writer) {
226227 v := c.view(w)
227228 v.title(sn.PublicID, sn.Description, sn.Visibility)
228229 v.fields(
@@ -238,6 +239,21 @@ func runSnippetShow(c *Ctx, args []string) int {
238239 }
239240 tb.flush()
240241 }
242 }, func() screen {
243 fs := section{title: "Files", n: len(files)}
244 for _, f := range files {
245 fs.rows = append(fs.rows, rowOf(cRef(f.Name), cSize(int64(f.Size))))
246 }
247 s := screen{sections: []section{fs}, fields: []field{
248 {"Snippet", []cell{cLink(sn.PublicID, snippetURL(c, sn)), cText(sn.Description)}},
249 {"Owner", []cell{cText(sn.OwnerName), cState(sn.Visibility)}},
250 {"Updated", []cell{cAge(sn.UpdatedAt)}},
251 }}
252 if len(files) > 0 {
253 s.actions = append(s.actions, action{"Get", []string{"snippet", "file", "get", sn.PublicID, files[0].Name}})
254 }
255 s.actions = append(s.actions, action{"Edit", []string{"snippet", "edit", sn.PublicID, "--description", "<text>"}})
256 return s
241257 })
242258}
243259
@@ -270,7 +286,7 @@ func runSnippetList(c *Ctx, args []string) int {
270286 for _, sn := range rows {
271287 items = append(items, snippetOut(c, sn))
272288 }
273 return c.emitPage(p, items, next, func(w io.Writer) {
289 return c.emitPageView(p, items, next, func(w io.Writer) {
274290 tb := c.table(w, "ID", "VISIBILITY", "FILES", "DESCRIPTION")
275291 for _, sn := range rows {
276292 names := ""
@@ -283,6 +299,20 @@ func runSnippetList(c *Ctx, args []string) int {
283299 tb.row(cRef(sn.PublicID), cState(sn.Visibility), cText(names), cFlex(sn.Description))
284300 }
285301 tb.flush()
302 }, func() screen {
303 rs := make([]row, len(rows))
304 for i, sn := range rows {
305 names := make([]string, len(sn.Files))
306 for j, f := range sn.Files {
307 names[j] = f.Name
308 }
309 rs[i] = rowOf(cRef(sn.PublicID), cState(sn.Visibility), cFlex(sn.Description), cMeta(strings.Join(names, ", "), relAge(sn.UpdatedAt, termNow())))
310 }
311 s := listScreen("Snippets", rs)
312 if len(rows) > 0 {
313 s.actions = []action{{"Read", []string{"snippet", "show", rows[0].PublicID}}}
314 }
315 return s
286316 })
287317}
288318
internal/control/stage3repo_test.go +4
@@ -30,6 +30,8 @@ func dispatchIn(t *testing.T, st *store.Store, u store.User, root, stdin string,
3030 // Fixtures write a lot as user 1; the write limiter is global to
3131 // the package, so leave its budget to the tests that exercise it.
3232 c.Cfg.Limits.WriteRate = -1
33 c.Cfg.Limits.MaxSnippetBytes = 1 << 20
34 c.Cfg.Limits.MaxBlobBytes = 1 << 20
3335 if code := Dispatch(c, argv); code != protocol.ExitOK {
3436 t.Fatalf("%v: exit %d: %s", argv, code, errOut)
3537 }
@@ -52,6 +54,8 @@ func atTerminalIn(t *testing.T, st *store.Store, u store.User, root string, argv
5254 // Fixtures write a lot as user 1; the write limiter is global to
5355 // the package, so leave its budget to the tests that exercise it.
5456 c.Cfg.Limits.WriteRate = -1
57 c.Cfg.Limits.MaxSnippetBytes = 1 << 20
58 c.Cfg.Limits.MaxBlobBytes = 1 << 20
5559 if code := Dispatch(c, argv); code != protocol.ExitOK {
5660 t.Fatalf("%v: exit %d: %s", argv, code, errOut)
5761 }
internal/control/stage3work_test.go +65
@@ -35,10 +35,13 @@ func workFixture(t *testing.T) (*store.Store, store.Repo, store.User, string, st
3535 }
3636 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte("jobs:\n test:\n steps:\n - go test ./...\n"), 0o644)
3737 os.WriteFile(filepath.Join(src, ".gitbay", "issue-template-bug.md"), []byte("## Steps\n"), 0o644)
38 os.MkdirAll(filepath.Join(src, ".gitbay", "wiki"), 0o755)
39 os.WriteFile(filepath.Join(src, ".gitbay", "wiki", "Home.md"), []byte("# Welcome\n\nStart here.\n"), 0o644)
3840 git(root, "init", "-q", "-b", "main", "src")
3941 git(src, "add", ".")
4042 git(src, "commit", "-q", "-m", "first")
4143 sha := git(src, "rev-parse", "HEAD")
44 git(src, "tag", "v1.0.0")
4245 dir := RepoDir(root, repo.OwnerName, repo.Name)
4346 os.MkdirAll(filepath.Dir(dir), 0o755)
4447 git(root, "clone", "-q", "--bare", src, dir)
@@ -101,3 +104,65 @@ func TestWorkScreensA(t *testing.T) {
101104 }
102105 }
103106}
107
108func TestWorkPlainPinnedB(t *testing.T) {
109 st, repo, u, root, _ := workFixture(t)
110 p := repo.Path()
111 dispatchIn(t, st, u, root, "", "release", "create", p, "v1.0.0", "--title", "v1.0.0 — first", "--notes", "First release.")
112 dispatchIn(t, st, u, root, "package main\n", "snippet", "create", "main.go", "--description", "Hello")
113 snippets := dispatchIn(t, st, u, root, "", "snippet", "list")
114 id, _, _ := strings.Cut(snippets, "\t")
115 for name, argv := range map[string][]string{
116 "release-list": {"release", "list", p},
117 "release-show": {"release", "show", p, "v1.0.0"},
118 "snippet-list": {"snippet", "list"},
119 "snippet-show": {"snippet", "show", id},
120 "wiki-list": {"wiki", "list", p},
121 "wiki-show": {"wiki", "show", p, "Home"},
122 "explore": {"explore"},
123 } {
124 got := dispatchIn(t, st, u, root, "", argv...)
125 pinPlain(t, name, strings.ReplaceAll(got, id, "<id>"))
126 }
127}
128
129func TestWorkScreensB(t *testing.T) {
130 st, repo, u, root, _ := workFixture(t)
131 p := repo.Path()
132 dispatchIn(t, st, u, root, "", "release", "create", p, "v1.0.0", "--title", "v1.0.0 — first", "--notes", "First release.")
133 dispatchIn(t, st, u, root, "package main\n", "snippet", "create", "main.go", "--description", "Hello")
134 snippets := dispatchIn(t, st, u, root, "", "snippet", "list")
135 id, _, _ := strings.Cut(snippets, "\t")
136 for _, tc := range []struct {
137 argv []string
138 want []string
139 }{
140 {[]string{"release", "list", p}, []string{"Releases (1)\n", "v1.0.0 first"}},
141 {[]string{"release", "show", p, "v1.0.0"}, []string{"Release:", "v1.0.0 v1.0.0 — first", "First release."}},
142 {[]string{"snippet", "list"}, []string{"Snippets (1)\n", "Hello", "main.go"}},
143 {[]string{"snippet", "show", id}, []string{"Snippet:", "Hello", "Files (1)\nmain.go"}},
144 {[]string{"wiki", "list", p}, []string{"Wiki (1)\nHome", "home"}},
145 {[]string{"wiki", "show", p, "Home"}, []string{"Page:", "Home", "Welcome", "Start here."}},
146 {[]string{"explore"}, []string{"Explore (1)\nalice/app"}},
147 } {
148 out := atTerminalIn(t, st, u, root, tc.argv...)
149 for _, w := range tc.want {
150 if !strings.Contains(out, w) {
151 t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out)
152 }
153 }
154 checkLegend(t, out)
155 }
156}
157
158func TestAuditScreen(t *testing.T) {
159 st, _, uid := newQueueTestRepo(t)
160 admin := store.User{ID: uid, Username: "alice", IsAdmin: true}
161 dispatchAs(t, st, admin, "", "admin", "user", "create", "bob")
162 pinPlain(t, "audit", dispatchAs(t, st, admin, "", "audit"))
163 out := atTerminal(t, st, admin, "audit")
164 if !strings.Contains(out, "Audit (") || !strings.Contains(out, "alice") {
165 t.Errorf("audit:\n%s", out)
166 }
167 checkLegend(t, out)
168}
internal/control/testdata/plain/audit.txt added +2
@@ -0,0 +1,2 @@
1<time> alice cmd admin user create {"argv":["bob"],"source":""}
2<time> alice admin user.created {"user":"bob"}
internal/control/testdata/plain/explore.txt added +1
@@ -0,0 +1 @@
1alice/app
internal/control/testdata/plain/mr-revisions.txt +1 −1
@@ -1 +1 @@
1* v1 2bc3205597 <time>
1* v1 a59131b356 <time>
internal/control/testdata/plain/release-list.txt added +1
@@ -0,0 +1 @@
1v1.0.0 v1.0.0 — first 0 asset(s)
internal/control/testdata/plain/release-show.txt added +5
@@ -0,0 +1,5 @@
1v1.0.0 v1.0.0 — first
2
3 author alice, <time>
4
5 First release.
internal/control/testdata/plain/snippet-list.txt added +1
@@ -0,0 +1 @@
1<id> unlisted main.go Hello
internal/control/testdata/plain/snippet-show.txt added +8
@@ -0,0 +1,8 @@
1<id> Hello unlisted
2
3 author alice
4 updated <time>
5 url /alice/-/snippets/<id>
6
7files:
8main.go 13 bytes
internal/control/testdata/plain/status-list.txt +1 −1
@@ -1,2 +1,2 @@
1combined success 2bc3205597
1combined success a59131b356
22ext/test success all green
internal/control/testdata/plain/wiki-list.txt added +1
@@ -0,0 +1 @@
1Home
internal/control/testdata/plain/wiki-show.txt added +3
@@ -0,0 +1,3 @@
1# Welcome
2
3Start here.
internal/control/wiki.go +26 −19
@@ -98,12 +98,26 @@ func runWikiList(c *Ctx, args []string) int {
9898 if d.Pages == nil {
9999 d.Pages = []string{}
100100 }
101 return c.emit(d, func(w io.Writer) {
101 return c.emitView(d, func(w io.Writer) {
102102 tb := c.table(w, "PAGE")
103103 for _, p := range d.Pages {
104104 tb.row(cRef(p))
105105 }
106106 tb.flush()
107 }, func() screen {
108 rows := make([]row, len(d.Pages))
109 for i, pg := range d.Pages {
110 home := ""
111 if pg == d.Home {
112 home = "home"
113 }
114 rows[i] = rowOf(cLink(pg, c.siteURL(repo.Path(), "wiki", pg)), cMeta(home))
115 }
116 s := listScreen("Wiki", rows)
117 if d.Home != "" {
118 s.actions = []action{{"Read", []string{"wiki", "show", repo.Path(), d.Home}}}
119 }
120 return s
107121 })
108122}
109123
@@ -170,27 +184,20 @@ func runWikiShow(c *Ctx, args []string) int {
170184 if ext == ".org" {
171185 format = "org"
172186 }
173 return c.emit(d, func(w io.Writer) {
187 return c.emitView(d, func(w io.Writer) {
174188 // Plain: the page source verbatim, same as any other piped
175 // file read. The title/fields/body layout is terminal-only.
176 if c.Term.Cols == 0 {
177 fmt.Fprint(w, d.Content)
178 return
179 }
180 v := c.view(w)
181 v.title(repo.Path(), page, "")
182 binaryNote := ""
189 // file read.
190 fmt.Fprint(w, d.Content)
191 }, func() screen {
192 s := screen{fields: []field{
193 {"Page", []cell{cLink(page, c.siteURL(repo.Path(), "wiki", page)), cMeta(repo.Path(), d.File)}},
194 }, actions: []action{{"Read", []string{"wiki", "list", repo.Path()}}}}
183195 if binary {
184 binaryNote = fmt.Sprintf("%d bytes, binary", d.Size)
185 }
186 v.fields(
187 "file", d.File,
188 "binary", binaryNote,
189 "url", c.siteURL(repo.Path(), "wiki", page),
190 )
191 if !binary {
192 v.body(d.Content, format)
196 s.fields = append(s.fields, field{"Binary", []cell{cSize(int64(d.Size))}})
197 } else {
198 s.body, s.format = d.Content, format
193199 }
200 return s
194201 })
195202 }
196203 return c.fail(protocol.ExitNotFound, "no wiki page %q in %s", page, repo.Path())