Commit b46c9682ce

b46c9682ce74628fdba9381386a028a17b19ed16

parent: cfda5a3562

Verified · cmc

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

control: listScreen; repository lists as screens

repo topics (and topics add/remove), secret list, deploy-key list,
domain list, access list, mirror list, runner list, bookmarks, search
and list.

Ref #319

Layout: unified · split

internal/control/build.go +10 −1
@@ -564,12 +564,21 @@ func runSecretList(c *Ctx, args []string) int {
564564 if err != nil {
565565 return c.fail(protocol.ExitFailure, "%v", err)
566566 }
567 return c.emit(names, func(w io.Writer) {
567 return c.emitView(names, func(w io.Writer) {
568568 tb := c.table(w, "NAME")
569569 for _, n := range names {
570570 tb.row(cRef(n))
571571 }
572572 tb.flush()
573 }, func() screen {
574 rows := make([]row, len(names))
575 for i, n := range names {
576 rows[i] = rowOf(cRef(n))
577 }
578 return listScreen("Build secrets", rows,
579 action{"Secrets", []string{"repo", "secret", "set", repo.Path(), "<NAME>"}},
580 action{"Secrets", []string{"repo", "secret", "remove", repo.Path(), "<NAME>"}},
581 )
573582 })
574583}
575584
internal/control/deploykey.go +11 −1
@@ -119,13 +119,23 @@ func runDeployKeyList(c *Ctx, args []string) int {
119119 ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label, k.LastUsedAt, k.ExpiresAt})
120120 }
121121 now := time.Now()
122 return c.emit(ds, func(w io.Writer) {
122 return c.emitView(ds, func(w io.Writer) {
123123 tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL", "USED", "EXPIRES")
124124 for _, d := range ds {
125125 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label),
126126 cText(c.usedText(d.LastUsedAt)), cText(c.expiresText(d.ExpiresAt, now)))
127127 }
128128 tb.flush()
129 }, func() screen {
130 rows := make([]row, len(ds))
131 for i, d := range ds {
132 rows[i] = rowOf(cRef(d.Fingerprint), cState(d.Mode), cText(d.Label),
133 cMeta(d.Algo, "used "+c.usedText(d.LastUsedAt), c.expiresText(d.ExpiresAt, now)))
134 }
135 return listScreen("Deploy keys", rows,
136 action{"Keys", []string{"repo", "deploy-key", "add", repo.Path()}},
137 action{"Keys", []string{"repo", "deploy-key", "remove", repo.Path(), "<fingerprint>"}},
138 )
129139 })
130140}
131141
internal/control/mirrorcmd.go +21 −1
@@ -122,7 +122,7 @@ func runMirrorList(c *Ctx, args []string) int {
122122 // The token never leaves the server, in any encoding.
123123 ds = append(ds, out{m.ID, m.Direction, m.URL, m.Username, m.Dirty, m.LastSync, m.LastError})
124124 }
125 return c.emit(ds, func(w io.Writer) {
125 return c.emitView(ds, func(w io.Writer) {
126126 tb := c.table(w, "ID", "DIRECTION", "URL", "LAST", "STATUS")
127127 for _, d := range ds {
128128 status := "ok"
@@ -135,6 +135,26 @@ func runMirrorList(c *Ctx, args []string) int {
135135 tb.row(cRef(fmt.Sprintf("%d", d.ID)), cText(d.Direction), cText(d.URL), cText("last "+orDash(d.LastSync)), cState(status))
136136 }
137137 tb.flush()
138 }, func() screen {
139 rows := make([]row, len(ds))
140 for i, d := range ds {
141 state := "ok"
142 if d.Pending {
143 state = "pending"
144 }
145 if d.LastError != "" {
146 state = "error"
147 }
148 synced := "never synced"
149 if d.LastSync != "" {
150 synced = "synced " + relAge(d.LastSync, termNow())
151 }
152 rows[i] = rowOf(cRef(strconv.FormatInt(d.ID, 10)), cGlyph(state), cFlex(d.URL), cMeta(d.Direction, synced), cMark(d.LastError, sgrRed))
153 }
154 return listScreen("Mirrors", rows,
155 action{"Mirrors", []string{"repo", "mirror", "sync", repo.Path()}},
156 action{"Mirrors", []string{"repo", "mirror", "remove", repo.Path(), "<id>"}},
157 )
138158 })
139159}
140160
internal/control/pagescmd.go +14 −1
@@ -220,11 +220,24 @@ func runDomainList(c *Ctx, args []string) int {
220220 }
221221 list = append(list, out{d.Domain, state, d.VerifiedAt})
222222 }
223 return c.emit(list, func(w io.Writer) {
223 return c.emitView(list, func(w io.Writer) {
224224 tb := c.table(w, "DOMAIN", "STATE")
225225 for _, d := range list {
226226 tb.row(cRef(d.Domain), cState(d.State))
227227 }
228228 tb.flush()
229 }, func() screen {
230 rows := make([]row, len(list))
231 for i, d := range list {
232 state := d.State
233 if state == "expired" {
234 state = "failed"
235 }
236 rows[i] = rowOf(cGlyph(state), cRef(d.Domain), cState(d.State))
237 }
238 return listScreen("Pages domains", rows,
239 action{"Domains", []string{"repo", "domain", "add", repo.Path(), "<domain>"}},
240 action{"Domains", []string{"repo", "domain", "verify", repo.Path(), "<domain>"}},
241 )
229242 })
230243}
internal/control/repo.go +70 −6
@@ -360,7 +360,7 @@ func runRepoList(c *Ctx, args []string) int {
360360 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
361361 ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived})
362362 }
363 return c.emitPage(p, ds, next, func(w io.Writer) {
363 return c.emitPageView(p, ds, next, func(w io.Writer) {
364364 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
365365 for _, d := range ds {
366366 cells := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description)}
@@ -370,6 +370,20 @@ func runRepoList(c *Ctx, args []string) int {
370370 tb.row(cells...)
371371 }
372372 tb.flush()
373 }, func() screen {
374 rows := make([]row, len(ds))
375 for i, d := range ds {
376 state := d.Visibility
377 if d.Archived {
378 state += ", archived"
379 }
380 rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cState(state), cFlex(d.Description))
381 }
382 s := listScreen("Repositories", rows)
383 if len(ds) > 0 {
384 s.actions = []action{{"Read", []string{"repo", "show", ds[0].Path}}}
385 }
386 return s
373387 })
374388}
375389
@@ -771,12 +785,21 @@ func runAccessList(c *Ctx, args []string) int {
771785 for _, e := range entries {
772786 ds = append(ds, out{e.Username, e.Role, e.Source})
773787 }
774 return c.emit(ds, func(w io.Writer) {
788 return c.emitView(ds, func(w io.Writer) {
775789 tb := c.table(w, "USER", "ROLE", "SOURCE")
776790 for _, d := range ds {
777791 tb.row(cRef(d.User), cState(d.Role), cText("via "+d.Source))
778792 }
779793 tb.flush()
794 }, func() screen {
795 rows := make([]row, len(ds))
796 for i, d := range ds {
797 rows[i] = rowOf(cRef(d.User), cState(d.Role), cMeta("via "+d.Source))
798 }
799 return listScreen("Access", rows,
800 action{"Access", []string{"repo", "access", "grant", repo.Path(), "<user>", "write"}},
801 action{"Access", []string{"repo", "access", "revoke", repo.Path(), "<user>"}},
802 )
780803 })
781804}
782805
@@ -960,6 +983,19 @@ func archiveRepo(c *Ctx, repo store.Repo, archived bool) int {
960983 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, repo.Path()) })
961984}
962985
986// topicsScreen is a repository's topics at a terminal, after a read or
987// an edit.
988func topicsScreen(repo store.Repo, topics []string) screen {
989 rows := make([]row, len(topics))
990 for i, t := range topics {
991 rows[i] = rowOf(cRef(t))
992 }
993 return listScreen("Topics", rows,
994 action{"Edit", []string{"repo", "topics", "add", repo.Path(), "<topic>"}},
995 action{"Edit", []string{"repo", "topics", "remove", repo.Path(), "<topic>"}},
996 )
997}
998
963999func runTopicsList(c *Ctx, args []string) int {
9641000 if len(args) != 1 {
9651001 return c.usage()
@@ -972,12 +1008,14 @@ func runTopicsList(c *Ctx, args []string) int {
9721008 if err != nil {
9731009 return c.fail(protocol.ExitFailure, "%v", err)
9741010 }
975 return c.emit(topics, func(w io.Writer) {
1011 return c.emitView(topics, func(w io.Writer) {
9761012 tb := c.table(w, "TOPIC")
9771013 for _, t := range topics {
9781014 tb.row(cRef(t))
9791015 }
9801016 tb.flush()
1017 }, func() screen {
1018 return topicsScreen(repo, topics)
9811019 })
9821020}
9831021
@@ -1031,12 +1069,14 @@ func editTopics(c *Ctx, args []string, add bool) int {
10311069 if err != nil {
10321070 return c.fail(protocol.ExitFailure, "%v", err)
10331071 }
1034 return c.emit(now, func(w io.Writer) {
1072 return c.emitView(now, func(w io.Writer) {
10351073 tb := c.table(w, "TOPIC")
10361074 for _, t := range now {
10371075 tb.row(cRef(t))
10381076 }
10391077 tb.flush()
1078 }, func() screen {
1079 return topicsScreen(repo, now)
10401080 })
10411081}
10421082
@@ -1079,12 +1119,22 @@ func runRepoSearch(c *Ctx, args []string) int {
10791119 }
10801120 ds = append(ds, out{r.Path(), r.Visibility, desc, topics})
10811121 }
1082 return c.emit(ds, func(w io.Writer) {
1122 return c.emitView(ds, func(w io.Writer) {
10831123 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
10841124 for _, d := range ds {
10851125 tb.row(cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description))
10861126 }
10871127 tb.flush()
1128 }, func() screen {
1129 rows := make([]row, len(ds))
1130 for i, d := range ds {
1131 rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description), cMeta(strings.Join(d.Topics, ", ")))
1132 }
1133 s := listScreen(fmt.Sprintf("Repositories matching %q", args[0]), rows)
1134 if len(ds) > 0 {
1135 s.actions = []action{{"Read", []string{"repo", "show", ds[0].Path}}}
1136 }
1137 return s
10881138 })
10891139}
10901140
@@ -1249,12 +1299,26 @@ func runRepoBookmarks(c *Ctx, args []string) int {
12491299 Bookmarks: c.Store.BookmarkCount(r.ID),
12501300 })
12511301 }
1252 return c.emit(out, func(w io.Writer) {
1302 return c.emitView(out, func(w io.Writer) {
12531303 tb := c.table(w, "PATH", "COUNT", "DESCRIPTION")
12541304 for _, b := range out {
12551305 tb.row(cRef(b.Path), cNum(int64(b.Bookmarks)), cFlex(b.Description))
12561306 }
12571307 tb.flush()
1308 }, func() screen {
1309 rows := make([]row, len(out))
1310 for i, b := range out {
1311 n := fmt.Sprintf("%d bookmarks", b.Bookmarks)
1312 if b.Bookmarks == 1 {
1313 n = "1 bookmark"
1314 }
1315 rows[i] = rowOf(cLink(b.Path, c.siteURL(b.Path)), cState(b.Visibility), cFlex(b.Description), cMeta(n))
1316 }
1317 s := listScreen("Bookmarks", rows)
1318 if len(out) > 0 {
1319 s.actions = []action{{"Read", []string{"repo", "show", out[0].Path}}}
1320 }
1321 return s
12581322 })
12591323}
12601324
internal/control/runnerrepo.go +17 −1
@@ -114,7 +114,7 @@ func runRepoRunnerList(c *Ctx, args []string) int {
114114 if runners == nil {
115115 runners = []store.RepoRunner{}
116116 }
117 return c.emit(runners, func(w io.Writer) {
117 return c.emitView(runners, func(w io.Writer) {
118118 tb := c.table(w, "FINGERPRINT", "ALGO", "USER", "SEEN", "HELD")
119119 for _, r := range runners {
120120 seen := r.LastSeen
@@ -128,6 +128,22 @@ func runRepoRunnerList(c *Ctx, args []string) int {
128128 tb.row(cRef(r.Fingerprint), cText(r.Algo), cText(r.Username), cAge(seen), cText(held))
129129 }
130130 tb.flush()
131 }, func() screen {
132 rows := make([]row, len(runners))
133 for i, r := range runners {
134 lead, held := cGlyph(""), ""
135 if r.BuildNumber != 0 {
136 lead, held = cGlyph("running"), fmt.Sprintf("building %s #%d %s", r.BuildRepo, r.BuildNumber, r.BuildJob)
137 }
138 seen := "never seen"
139 if r.LastSeen != "" {
140 seen = "seen " + relAge(r.LastSeen, termNow())
141 }
142 rows[i] = rowOf(cRef(r.Fingerprint), lead, cFlex(r.Username), cMeta(r.Algo, seen, held))
143 }
144 return listScreen("Runners", rows,
145 action{"Runners", []string{"repo", "runner", "remove", repo.Path(), "<fingerprint>"}},
146 )
131147 })
132148}
133149
internal/control/screen.go +6
@@ -57,6 +57,12 @@ type action struct {
5757 argv []string
5858}
5959
60// listScreen is a list command's terminal screen: one section of rows
61// and the commands that apply.
62func listScreen(title string, rows []row, actions ...action) screen {
63 return screen{sections: []section{{title: title, n: len(rows), rows: rows}}, actions: actions}
64}
65
6066// render draws s: the parts in order, one blank line between parts and
6167// between sections.
6268func (c *Ctx) render(w io.Writer, s screen) {
internal/control/stage3repo_test.go added +149
@@ -0,0 +1,149 @@
1package control
2
3import (
4 "bytes"
5 "crypto/ed25519"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11 "golang.org/x/crypto/ssh"
12)
13
14// dispatchAs runs argv as u with stdin, piped, and returns stdout; a
15// non-zero exit fails t.
16func dispatchAs(t *testing.T, st *store.Store, u store.User, stdin string, argv ...string) string {
17 t.Helper()
18 out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
19 c := &Ctx{User: u, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Stdin: strings.NewReader(stdin)}
20 if code := Dispatch(c, argv); code != protocol.ExitOK {
21 t.Fatalf("%v: exit %d: %s", argv, code, errOut)
22 }
23 return out.String()
24}
25
26// atTerminal runs argv as u at a 100-column terminal without colour
27// and returns stdout; a non-zero exit fails t.
28func atTerminal(t *testing.T, st *store.Store, u store.User, argv ...string) string {
29 t.Helper()
30 out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
31 c := &Ctx{User: u, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Stdin: strings.NewReader(""), Term: Term{Cols: 100}}
32 if code := Dispatch(c, argv); code != protocol.ExitOK {
33 t.Fatalf("%v: exit %d: %s", argv, code, errOut)
34 }
35 return out.String()
36}
37
38// checkLegend fails t for any suggested command in a rendered screen
39// (a line starting "gitbay ", or after "+n more ") that the registry
40// would not dispatch with the flags it names.
41func checkLegend(t *testing.T, out string) {
42 t.Helper()
43 var s screen
44 for _, line := range strings.Split(out, "\n") {
45 if i := strings.Index(line, "more gitbay "); i >= 0 {
46 line = line[i+len("more "):]
47 }
48 for _, cmd := range strings.Split(line, " ") {
49 cmd = strings.TrimSpace(cmd)
50 if !strings.HasPrefix(cmd, "gitbay ") {
51 continue
52 }
53 argv, err := protocol.Tokenize(strings.TrimPrefix(cmd, "gitbay "))
54 if err != nil {
55 t.Errorf("%q: %v", cmd, err)
56 continue
57 }
58 s.actions = append(s.actions, action{"", argv})
59 }
60 }
61 if len(s.actions) == 0 {
62 t.Errorf("no suggested commands in:\n%s", out)
63 }
64 checkActions(t, s)
65}
66
67// newPubKey is an ed25519 public key in authorized_keys form, the same
68// on every run so its fingerprint can be pinned.
69func newPubKey(t *testing.T) string {
70 t.Helper()
71 pub := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, ed25519.SeedSize)).Public().(ed25519.PublicKey)
72 sp, err := ssh.NewPublicKey(pub)
73 if err != nil {
74 t.Fatal(err)
75 }
76 return string(ssh.MarshalAuthorizedKey(sp))
77}
78
79// repoListsFixture is a repository with something in every list task 1
80// migrates.
81func repoListsFixture(t *testing.T) (*store.Store, store.Repo, store.User) {
82 t.Helper()
83 st, repo, uid := newQueueTestRepo(t)
84 owner := store.User{ID: uid, Username: "alice", IsAdmin: true}
85 p := repo.Path()
86 dispatchAs(t, st, owner, "", "repo", "topics", "add", p, "cli", "forge")
87 dispatchAs(t, st, owner, "s3cret\n", "repo", "secret", "set", p, "DEPLOY_TOKEN")
88 dispatchAs(t, st, owner, testRunnerPub, "repo", "deploy-key", "add", p)
89 dispatchAs(t, st, owner, "", "repo", "domain", "add", p, "docs.example.test")
90 dispatchAs(t, st, owner, "", "repo", "bookmark", p)
91 // mirror add resolves the host, so the fixture goes to the store.
92 if _, err := st.AddMirror(repo.ID, "push", "https://mirror.example.test/app.git", "", "tok"); err != nil {
93 t.Fatal(err)
94 }
95 dispatchAs(t, st, owner, newPubKey(t), "repo", "runner", "add", p)
96 return st, repo, owner
97}
98
99func TestRepoListsPlainPinned(t *testing.T) {
100 st, repo, owner := repoListsFixture(t)
101 p := repo.Path()
102 for name, argv := range map[string][]string{
103 "repo-topics": {"repo", "topics", p},
104 "repo-secret-list": {"repo", "secret", "list", p},
105 "repo-deploy-key-list": {"repo", "deploy-key", "list", p},
106 "repo-domain-list": {"repo", "domain", "list", p},
107 "repo-access-list": {"repo", "access", "list", p},
108 "repo-bookmarks": {"repo", "bookmarks"},
109 "repo-search": {"repo", "search", "app"},
110 "repo-list": {"repo", "list"},
111 "repo-mirror-list": {"repo", "mirror", "list", p},
112 "repo-runner-list": {"repo", "runner", "list", p},
113 } {
114 pinPlain(t, name, dispatchAs(t, st, owner, "", argv...))
115 }
116 pinPlain(t, "repo-topics-add", dispatchAs(t, st, owner, "", "repo", "topics", "add", p, "git"))
117}
118
119func TestRepoListScreens(t *testing.T) {
120 st, repo, owner := repoListsFixture(t)
121 p := repo.Path()
122 for _, tc := range []struct {
123 argv []string
124 want []string
125 }{
126 {[]string{"repo", "topics", p}, []string{"Topics (2)\ncli\nforge\n"}},
127 {[]string{"repo", "topics", "add", p, "git"}, []string{"Topics (3)\n"}},
128 {[]string{"repo", "secret", "list", p}, []string{"Build secrets (1)\nDEPLOY_TOKEN\n"}},
129 {[]string{"repo", "deploy-key", "list", p}, []string{"Deploy keys (1)\n", " ro runner@test ssh-ed25519 · used never"}},
130 {[]string{"repo", "domain", "list", p}, []string{"Pages domains (1)\n", "◐ docs.example.test pending"}},
131 {[]string{"repo", "access", "list", p}, []string{"Access (1)\nalice admin via owner\n"}},
132 {[]string{"repo", "mirror", "list", p}, []string{"Mirrors (1)\n1 ◐ https://mirror.example.test/app.git push · never synced"}},
133 {[]string{"repo", "runner", "list", p}, []string{"Runners (1)\n", " alice ssh-ed25519 · never seen"}},
134 {[]string{"repo", "bookmarks"}, []string{"Bookmarks (1)\nalice/app public 1 bookmark\n"}},
135 {[]string{"repo", "search", "app"}, []string{"Repositories matching \"app\" (1)\nalice/app public cli, forge"}},
136 {[]string{"repo", "list"}, []string{"Repositories (1)\nalice/app public\n"}},
137 } {
138 out := atTerminal(t, st, owner, tc.argv...)
139 for _, w := range tc.want {
140 if !strings.Contains(out, w) {
141 t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out)
142 }
143 }
144 if strings.Contains(out, "s3cret") || strings.Contains(out, "tok\n") {
145 t.Errorf("%v: secret on screen:\n%s", tc.argv, out)
146 }
147 checkLegend(t, out)
148 }
149}
internal/control/testdata/plain/repo-access-list.txt added +1
@@ -0,0 +1 @@
1alice admin via owner
internal/control/testdata/plain/repo-bookmarks.txt added +1
@@ -0,0 +1 @@
1alice/app 1
internal/control/testdata/plain/repo-deploy-key-list.txt added +1
@@ -0,0 +1 @@
1SHA256:re738AN1/nwapcQTqXzvkuuJNj+U6StCED66ZUJNCdE ssh-ed25519 ro runner@test never never
internal/control/testdata/plain/repo-domain-list.txt added +1
@@ -0,0 +1 @@
1docs.example.test pending
internal/control/testdata/plain/repo-list.txt added +1
@@ -0,0 +1 @@
1alice/app public
internal/control/testdata/plain/repo-mirror-list.txt added +1
@@ -0,0 +1 @@
11 push https://mirror.example.test/app.git last - pending
internal/control/testdata/plain/repo-runner-list.txt added +1
@@ -0,0 +1 @@
1SHA256:z/fSv0Z0RZS+Lccbc6ZoOWwt/fbj1VFJGSBKQbo4icE ssh-ed25519 alice never idle
internal/control/testdata/plain/repo-search.txt added +1
@@ -0,0 +1 @@
1alice/app public
internal/control/testdata/plain/repo-secret-list.txt added +1
@@ -0,0 +1 @@
1DEPLOY_TOKEN
internal/control/testdata/plain/repo-topics-add.txt added +3
@@ -0,0 +1,3 @@
1cli
2forge
3git
internal/control/testdata/plain/repo-topics.txt added +2
@@ -0,0 +1,2 @@
1cli
2forge