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 {
54 if err != nil { 54 if err != nil {
55 return c.fail(protocol.ExitFailure, "%v", err) 55 return c.fail(protocol.ExitFailure, "%v", err)
56 } 56 }
57 return c.emit(entries, func(w io.Writer) { 57 return c.emitView(entries, func(w io.Writer) {
58 tb := c.table(w, "WHEN", "ACTOR", "ACTION", "DATA") 58 tb := c.table(w, "WHEN", "ACTOR", "ACTION", "DATA")
59 for _, e := range entries { 59 for _, e := range entries {
60 actor := e.Actor 60 actor := e.Actor
61 if actor == "" { 61 if actor == "" {
62 actor = "-" 62 actor = "-"
63 } 63 }
64 data := e.Data 64 tb.row(cAge(e.CreatedAt), cText(actor), cText(e.Action), cFlex(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))
69 } 65 }
70 tb.flush() 66 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"}})
71 }) 77 })
72} 78}
73 79
internal/control/explore.go +15 −1
@@ -85,12 +85,26 @@ func runExplore(c *Ctx, args []string) int {
85 } 85 }
86 } 86 }
87 ds, next := trimPage(p, ds, "explore", func(o out) string { return o.Path }) 87 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) {
89 tb := c.table(w, "PATH", "DESCRIPTION") 89 tb := c.table(w, "PATH", "DESCRIPTION")
90 for _, d := range ds { 90 for _, d := range ds {
91 tb.row(cRef(d.Path), cFlex(d.Description)) 91 tb.row(cRef(d.Path), cFlex(d.Description))
92 } 92 }
93 tb.flush() 93 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
94 }) 108 })
95} 109}
96 110
internal/control/release.go +39 −27
@@ -282,26 +282,7 @@ func runReleaseList(c *Ctx, args []string) int {
282 for _, r := range rels { 282 for _, r := range rels {
283 ds = append(ds, releaseToOut(r, false)) 283 ds = append(ds, releaseToOut(r, false))
284 } 284 }
285 return c.emitPage(p, ds, next, func(w io.Writer) { 285 return c.emitPageView(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 }
305 tb := c.table(w, "TAG", "TITLE", "ASSETS") 286 tb := c.table(w, "TAG", "TITLE", "ASSETS")
306 for _, d := range ds { 287 for _, d := range ds {
307 title := d.Title 288 title := d.Title
@@ -311,16 +292,37 @@ func runReleaseList(c *Ctx, args []string) int {
311 tb.row(cRef(d.Tag), cFlex(title), cText(fmt.Sprintf("%d asset(s)", len(d.Assets)))) 292 tb.row(cRef(d.Tag), cFlex(title), cText(fmt.Sprintf("%d asset(s)", len(d.Assets))))
312 } 293 }
313 tb.flush() 294 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
314 }) 316 })
315} 317}
316 318
317func runReleaseShow(c *Ctx, args []string) int { 319func runReleaseShow(c *Ctx, args []string) int {
318 _, rel, code := releaseRef(c, args, policy.CanRead) 320 repo, rel, code := releaseRef(c, args, policy.CanRead)
319 if code >= 0 { 321 if code >= 0 {
320 return code 322 return code
321 } 323 }
322 d := releaseToOut(rel, true) 324 d := releaseToOut(rel, true)
323 return c.emit(d, func(w io.Writer) { 325 return c.emitView(d, func(w io.Writer) {
324 title := d.Title 326 title := d.Title
325 if title == d.Tag { 327 if title == d.Tag {
326 title = "" 328 title = ""
@@ -335,14 +337,24 @@ func runReleaseShow(c *Ctx, args []string) int {
335 v.section("assets") 337 v.section("assets")
336 tb := c.table(w, "NAME", "SIZE", "SHA256") 338 tb := c.table(w, "NAME", "SIZE", "SHA256")
337 for _, a := range d.Assets { 339 for _, a := range d.Assets {
338 sum := a.SHA256 340 tb.row(cRef(a.Name), cSize(a.Size), cFlex(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))
343 } 341 }
344 tb.flush() 342 tb.flush()
345 } 343 }
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
346 }) 358 })
347} 359}
348 360
internal/control/snippet.go +32 −2
@@ -7,6 +7,7 @@ import (
7 "fmt" 7 "fmt"
8 "io" 8 "io"
9 "strconv" 9 "strconv"
10 "strings"
10 "unicode/utf8" 11 "unicode/utf8"
11 12
12 "gitbay.org/gitbay/internal/policy" 13 "gitbay.org/gitbay/internal/policy"
@@ -222,7 +223,7 @@ func runSnippetShow(c *Ctx, args []string) int {
222 return c.fail(protocol.ExitFailure, "%v", err) 223 return c.fail(protocol.ExitFailure, "%v", err)
223 } 224 }
224 sn.Files = files 225 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) {
226 v := c.view(w) 227 v := c.view(w)
227 v.title(sn.PublicID, sn.Description, sn.Visibility) 228 v.title(sn.PublicID, sn.Description, sn.Visibility)
228 v.fields( 229 v.fields(
@@ -238,6 +239,21 @@ func runSnippetShow(c *Ctx, args []string) int {
238 } 239 }
239 tb.flush() 240 tb.flush()
240 } 241 }
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
241 }) 257 })
242} 258}
243 259
@@ -270,7 +286,7 @@ func runSnippetList(c *Ctx, args []string) int {
270 for _, sn := range rows { 286 for _, sn := range rows {
271 items = append(items, snippetOut(c, sn)) 287 items = append(items, snippetOut(c, sn))
272 } 288 }
273 return c.emitPage(p, items, next, func(w io.Writer) { 289 return c.emitPageView(p, items, next, func(w io.Writer) {
274 tb := c.table(w, "ID", "VISIBILITY", "FILES", "DESCRIPTION") 290 tb := c.table(w, "ID", "VISIBILITY", "FILES", "DESCRIPTION")
275 for _, sn := range rows { 291 for _, sn := range rows {
276 names := "" 292 names := ""
@@ -283,6 +299,20 @@ func runSnippetList(c *Ctx, args []string) int {
283 tb.row(cRef(sn.PublicID), cState(sn.Visibility), cText(names), cFlex(sn.Description)) 299 tb.row(cRef(sn.PublicID), cState(sn.Visibility), cText(names), cFlex(sn.Description))
284 } 300 }
285 tb.flush() 301 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
286 }) 316 })
287} 317}
288 318
internal/control/stage3repo_test.go +4
@@ -30,6 +30,8 @@ func dispatchIn(t *testing.T, st *store.Store, u store.User, root, stdin string,
30 // Fixtures write a lot as user 1; the write limiter is global to 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. 31 // the package, so leave its budget to the tests that exercise it.
32 c.Cfg.Limits.WriteRate = -1 32 c.Cfg.Limits.WriteRate = -1
33 c.Cfg.Limits.MaxSnippetBytes = 1 << 20
34 c.Cfg.Limits.MaxBlobBytes = 1 << 20
33 if code := Dispatch(c, argv); code != protocol.ExitOK { 35 if code := Dispatch(c, argv); code != protocol.ExitOK {
34 t.Fatalf("%v: exit %d: %s", argv, code, errOut) 36 t.Fatalf("%v: exit %d: %s", argv, code, errOut)
35 } 37 }
@@ -52,6 +54,8 @@ func atTerminalIn(t *testing.T, st *store.Store, u store.User, root string, argv
52 // Fixtures write a lot as user 1; the write limiter is global to 54 // 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. 55 // the package, so leave its budget to the tests that exercise it.
54 c.Cfg.Limits.WriteRate = -1 56 c.Cfg.Limits.WriteRate = -1
57 c.Cfg.Limits.MaxSnippetBytes = 1 << 20
58 c.Cfg.Limits.MaxBlobBytes = 1 << 20
55 if code := Dispatch(c, argv); code != protocol.ExitOK { 59 if code := Dispatch(c, argv); code != protocol.ExitOK {
56 t.Fatalf("%v: exit %d: %s", argv, code, errOut) 60 t.Fatalf("%v: exit %d: %s", argv, code, errOut)
57 } 61 }
internal/control/stage3work_test.go +65
@@ -35,10 +35,13 @@ func workFixture(t *testing.T) (*store.Store, store.Repo, store.User, string, st
35 } 35 }
36 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte("jobs:\n test:\n steps:\n - go test ./...\n"), 0o644) 36 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte("jobs:\n test:\n steps:\n - go test ./...\n"), 0o644)
37 os.WriteFile(filepath.Join(src, ".gitbay", "issue-template-bug.md"), []byte("## Steps\n"), 0o644) 37 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)
38 git(root, "init", "-q", "-b", "main", "src") 40 git(root, "init", "-q", "-b", "main", "src")
39 git(src, "add", ".") 41 git(src, "add", ".")
40 git(src, "commit", "-q", "-m", "first") 42 git(src, "commit", "-q", "-m", "first")
41 sha := git(src, "rev-parse", "HEAD") 43 sha := git(src, "rev-parse", "HEAD")
44 git(src, "tag", "v1.0.0")
42 dir := RepoDir(root, repo.OwnerName, repo.Name) 45 dir := RepoDir(root, repo.OwnerName, repo.Name)
43 os.MkdirAll(filepath.Dir(dir), 0o755) 46 os.MkdirAll(filepath.Dir(dir), 0o755)
44 git(root, "clone", "-q", "--bare", src, dir) 47 git(root, "clone", "-q", "--bare", src, dir)
@@ -101,3 +104,65 @@ func TestWorkScreensA(t *testing.T) {
101 } 104 }
102 } 105 }
103} 106}
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
2ext/test success all green 2ext/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 {
98 if d.Pages == nil { 98 if d.Pages == nil {
99 d.Pages = []string{} 99 d.Pages = []string{}
100 } 100 }
101 return c.emit(d, func(w io.Writer) { 101 return c.emitView(d, func(w io.Writer) {
102 tb := c.table(w, "PAGE") 102 tb := c.table(w, "PAGE")
103 for _, p := range d.Pages { 103 for _, p := range d.Pages {
104 tb.row(cRef(p)) 104 tb.row(cRef(p))
105 } 105 }
106 tb.flush() 106 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
107 }) 121 })
108} 122}
109 123
@@ -170,27 +184,20 @@ func runWikiShow(c *Ctx, args []string) int {
170 if ext == ".org" { 184 if ext == ".org" {
171 format = "org" 185 format = "org"
172 } 186 }
173 return c.emit(d, func(w io.Writer) { 187 return c.emitView(d, func(w io.Writer) {
174 // Plain: the page source verbatim, same as any other piped 188 // Plain: the page source verbatim, same as any other piped
175 // file read. The title/fields/body layout is terminal-only. 189 // file read.
176 if c.Term.Cols == 0 { 190 fmt.Fprint(w, d.Content)
177 fmt.Fprint(w, d.Content) 191 }, func() screen {
178 return 192 s := screen{fields: []field{
179 } 193 {"Page", []cell{cLink(page, c.siteURL(repo.Path(), "wiki", page)), cMeta(repo.Path(), d.File)}},
180 v := c.view(w) 194 }, actions: []action{{"Read", []string{"wiki", "list", repo.Path()}}}}
181 v.title(repo.Path(), page, "")
182 binaryNote := ""
183 if binary { 195 if binary {
184 binaryNote = fmt.Sprintf("%d bytes, binary", d.Size) 196 s.fields = append(s.fields, field{"Binary", []cell{cSize(int64(d.Size))}})
185 } 197 } else {
186 v.fields( 198 s.body, s.format = d.Content, format
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)
193 } 199 }
200 return s
194 }) 201 })
195 } 202 }
196 return c.fail(protocol.ExitNotFound, "no wiki page %q in %s", page, repo.Path()) 203 return c.fail(protocol.ExitNotFound, "no wiki page %q in %s", page, repo.Path())