Commit 92c7105063

92c71050630719a10b55647d339552da253f036d

parent: ffb6f3090c

Verified · cmc

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

control: saved queries, search, feed and other work lists as screens

query run (shared with issue list and mr list --query/--q, titled
Results there), query list and show, search (a section per kind),
feed, mr revisions, issue templates, build jobs and status list.

Ref #319

Layout: unified · split

internal/control/build.go +18 −1
@@ -456,7 +456,7 @@ func runBuildJobs(c *Ctx, args []string) int {
456456 for _, j := range jobs {
457457 out = append(out, JobOut{Name: j.Name, Schedule: j.Schedule, Tags: j.Tags})
458458 }
459 return c.emit(out, func(w io.Writer) {
459 return c.emitView(out, func(w io.Writer) {
460460 tb := c.table(w, "NAME", "WHEN")
461461 for _, j := range out {
462462 when := "on push"
@@ -469,6 +469,23 @@ func runBuildJobs(c *Ctx, args []string) int {
469469 tb.row(cRef(j.Name), cText(when))
470470 }
471471 tb.flush()
472 }, func() screen {
473 rows := make([]row, len(out))
474 for i, j := range out {
475 when := "on push"
476 switch {
477 case j.Schedule != "":
478 when = "schedule " + j.Schedule
479 case j.Tags != "":
480 when = "tags " + j.Tags
481 }
482 rows[i] = rowOf(cRef(j.Name), cMeta(when))
483 }
484 s := listScreen("Jobs", rows)
485 if len(out) > 0 {
486 s.actions = []action{{"Run", []string{"build", "trigger", repo.Path(), out[0].Name}}}
487 }
488 return s
472489 })
473490}
474491
internal/control/dashboard.go +7 −9
@@ -357,20 +357,18 @@ func runFeed(c *Ctx, args []string) int {
357357 })
358358 ds := feedOutputs(events)
359359 lines := FeedLines(events)
360 return c.emitPage(p, ds, next, func(w io.Writer) {
361 if c.Term.Cols > 0 {
362 tb := c.table(w, feedHeader...)
363 for _, l := range lines {
364 tb.row(l.termCells(c)...)
365 }
366 tb.flush()
367 return
368 }
360 return c.emitPageView(p, ds, next, func(w io.Writer) {
369361 tb := c.table(w, "WHEN", "EVENT")
370362 for _, l := range lines {
371363 tb.row(cAge(l.When), cFlex(l.Sentence()))
372364 }
373365 tb.flush()
366 }, func() screen {
367 rows := make([]row, len(lines))
368 for i, l := range lines {
369 rows[i] = rowOf(l.termCells(c)...)
370 }
371 return listScreen("Activity", rows)
374372 })
375373}
376374
internal/control/feedline.go +3 −5
@@ -42,11 +42,9 @@ func (l FeedLine) Sentence() string {
4242 return s
4343}
4444
45// feedHeader and termCells are a feed line as a terminal table shows
46// it: who, what, where, and how a build came out, so the outcome and the
47// reference can carry their colours.
48var feedHeader = []string{"WHEN", "WHO", "EVENT", "WHERE", "DETAIL"}
49
45// termCells is a feed line as a screen row shows it: when, who, what,
46// where, and how a build came out, so the outcome and the reference can
47// carry their colours.
5048func (l FeedLine) termCells(c *Ctx) []cell {
5149 ref := l.Ref
5250 if ref != "" && ref[0] != '#' && ref[0] != '!' {
internal/control/milestone.go +9 −1
@@ -293,12 +293,20 @@ func runIssueTemplates(c *Ctx, args []string) int {
293293 }
294294 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
295295 ts := IssueTemplates(dir, repo.DefaultBranch)
296 return c.emit(ts, func(w io.Writer) {
296 return c.emitView(ts, func(w io.Writer) {
297297 tb := c.table(w, "NAME")
298298 for _, t := range ts {
299299 tb.row(cRef(t.Name))
300300 }
301301 tb.flush()
302 }, func() screen {
303 rows := make([]row, len(ts))
304 for i, tpl := range ts {
305 rows[i] = rowOf(cRef(tpl.Name))
306 }
307 return listScreen("Issue templates", rows,
308 action{"New", []string{"issue", "create", repo.Path(), "--title", "<title>"}},
309 )
302310 })
303311}
304312
internal/control/mr.go +17 −1
@@ -2089,7 +2089,7 @@ func runMRRevisions(c *Ctx, args []string) int {
20892089 if err != nil {
20902090 return c.fail(protocol.ExitFailure, "%v", err)
20912091 }
2092 return c.emit(revs, func(w io.Writer) {
2092 return c.emitView(revs, func(w io.Writer) {
20932093 tb := c.table(w, "REV", "SHA", "WHEN")
20942094 for _, r := range revs {
20952095 mark := " "
@@ -2103,6 +2103,22 @@ func runMRRevisions(c *Ctx, args []string) int {
21032103 fmt.Fprintf(c.Stderr, "only one revision; %s!%d has not been pushed to since it was opened\n",
21042104 repo.Path(), mr.Number)
21052105 }
2106 }, func() screen {
2107 rows := make([]row, len(revs))
2108 for i, r := range revs {
2109 lead := cell{kind: kindGlyph}
2110 if r.Current {
2111 lead = cell{kind: kindGlyph, s: "●"}
2112 }
2113 rows[i] = rowOf(cRef(fmt.Sprintf("v%d", r.N)), lead, cRef(fmt.Sprintf("%.10s", r.SHA)), cAge(r.CreatedAt))
2114 }
2115 if len(revs) < 2 {
2116 fmt.Fprintf(c.Stderr, "only one revision; %s!%d has not been pushed to since it was opened\n",
2117 repo.Path(), mr.Number)
2118 }
2119 return listScreen("Revisions", rows,
2120 action{"Compare", []string{"mr", "range-diff", repo.Path(), strconv.FormatInt(mr.Number, 10)}},
2121 )
21062122 })
21072123}
21082124
internal/control/query.go +55 −6
@@ -193,7 +193,7 @@ func savedQuery(c *Ctx, name string) (store.SavedQuery, ItemQuery, int) {
193193
194194// runItemQuery lists what q matches in the tables issues and mrs allow,
195195// one page at a time. The output is always the paged shape.
196func runItemQuery(c *Ctx, q ItemQuery, issues, mrs bool, p page) int {
196func runItemQuery(c *Ctx, q ItemQuery, issues, mrs bool, p page, title string) int {
197197 var after *store.ItemCursor
198198 if p.key != "" {
199199 var err error
@@ -215,7 +215,7 @@ func runItemQuery(c *Ctx, q ItemQuery, issues, mrs bool, p page) int {
215215 next = encodeItemCursor(items[len(items)-1].Cursor())
216216 }
217217 ds := queryItems(items)
218 return c.emitPage(p, ds, next, func(w io.Writer) {
218 return c.emitPageView(p, ds, next, func(w io.Writer) {
219219 tb := c.table(w, "REF", "STATE", "TITLE", "AUTHOR")
220220 for _, d := range ds {
221221 state := d.State
@@ -225,6 +225,25 @@ func runItemQuery(c *Ctx, q ItemQuery, issues, mrs bool, p page) int {
225225 tb.row(cRef(d.Ref()), cState(state), cFlex(d.Title), cText(d.Author))
226226 }
227227 tb.flush()
228 }, func() screen {
229 rows := make([]row, len(ds))
230 for i, d := range ds {
231 state := d.State
232 if d.Draft {
233 state = "draft"
234 }
235 page := "issues"
236 if d.Kind == "mr" {
237 page = "mrs"
238 }
239 rows[i] = rowOf(cLink(d.Ref(), c.siteURL(d.Repo, page, strconv.FormatInt(d.Number, 10))), cGlyph(state), cFlex(d.Title),
240 cMeta(d.Author, d.Milestone, relAge(d.UpdatedAt, termNow())))
241 }
242 s := listScreen(title, rows)
243 if len(ds) > 0 {
244 s.actions = []action{{"Read", []string{ds[0].Kind, "show", ds[0].Repo, strconv.FormatInt(ds[0].Number, 10)}}}
245 }
246 return s
228247 })
229248}
230249
@@ -273,7 +292,7 @@ func listByQuery(c *Ctx, fl flags, kind string, p page) int {
273292 if kind == "mr" && !mrs {
274293 return c.usageWith("that query matches only issues; use issue list or query run")
275294 }
276 return runItemQuery(c, q, kind == "issue", kind == "mr", p)
295 return runItemQuery(c, q, kind == "issue", kind == "mr", p, "Results")
277296}
278297
279298func savedQueryOut(sq store.SavedQuery) SavedQueryOut {
@@ -335,7 +354,7 @@ func runQueryList(c *Ctx, args []string) int {
335354 for _, sq := range saved {
336355 ds = append(ds, savedQueryOut(sq))
337356 }
338 return c.emit(ds, func(w io.Writer) {
357 return c.emitView(ds, func(w io.Writer) {
339358 tb := c.table(w, "NAME", "PINNED", "QUERY")
340359 for _, d := range ds {
341360 pinned := ""
@@ -345,6 +364,23 @@ func runQueryList(c *Ctx, args []string) int {
345364 tb.row(cRef(d.Name), cText(pinned), cFlex(d.Query))
346365 }
347366 tb.flush()
367 }, func() screen {
368 rows := make([]row, len(ds))
369 for i, d := range ds {
370 pinned := ""
371 if d.Pinned {
372 pinned = "pinned"
373 }
374 rows[i] = rowOf(cRef(d.Name), cFlex(d.Query), cMeta(pinned))
375 }
376 s := listScreen("Saved queries", rows)
377 if len(ds) > 0 {
378 s.actions = []action{
379 {"Queries", []string{"query", "run", ds[0].Name}},
380 {"Queries", []string{"query", "pin", ds[0].Name}},
381 }
382 }
383 return s
348384 })
349385}
350386
@@ -362,7 +398,7 @@ func runQueryShow(c *Ctx, args []string) int {
362398 }
363399 d := savedQueryOut(sq)
364400 d.Count = &n
365 return c.emit(d, func(w io.Writer) {
401 return c.emitView(d, func(w io.Writer) {
366402 v := c.view(w)
367403 v.title(d.Name, "", "")
368404 pinned := "no"
@@ -370,6 +406,19 @@ func runQueryShow(c *Ctx, args []string) int {
370406 pinned = "yes"
371407 }
372408 v.fields("query", d.Query, "matches", strconv.Itoa(n), "pinned", pinned)
409 }, func() screen {
410 pin, pinned := "pin", "no"
411 if d.Pinned {
412 pin, pinned = "unpin", "yes"
413 }
414 return screen{fields: []field{
415 {"Query", []cell{cRef(d.Name), cText(d.Query)}},
416 {"Matches", []cell{cText(strconv.Itoa(n))}},
417 {"Pinned", []cell{cText(pinned)}},
418 }, actions: []action{
419 {"Queries", []string{"query", "run", d.Name}},
420 {"Queries", []string{"query", pin, d.Name}},
421 }}
373422 })
374423}
375424
@@ -385,7 +434,7 @@ func runQueryRun(c *Ctx, args []string) int {
385434 if code >= 0 {
386435 return code
387436 }
388 return runItemQuery(c, q, true, true, p)
437 return runItemQuery(c, q, true, true, p, rest[0])
389438}
390439
391440func runQueryRemove(c *Ctx, args []string) int {
internal/control/search.go +30 −6
@@ -105,7 +105,9 @@ func runSearch(c *Ctx, args []string) int {
105105 if err != nil {
106106 return c.fail(protocol.ExitFailure, "%v", err)
107107 }
108 return c.emit(results, func(w io.Writer) { writeSearchTable(c, w, results) })
108 return c.emitView(results, func(w io.Writer) { writeSearchTable(c, w, results) }, func() screen {
109 return searchScreen(c, f.Pos[0], results)
110 })
109111}
110112
111113// writeSearchTable renders search results: a repo hit has no ref number or
@@ -118,11 +120,7 @@ func writeSearchTable(c *Ctx, w io.Writer, results []SearchResult) {
118120 for _, r := range results {
119121 switch r.Kind {
120122 case "repo":
121 if c.Term.Cols > 0 {
122 tb.row(cText("repo"), cLink(r.Repo, c.siteURL(r.Repo)), cState(""), cFlex(r.Title))
123 } else {
124 tb.row(cText("repo"), cRef(r.Repo), cFlex(r.Title))
125 }
123 tb.row(cText("repo"), cRef(r.Repo), cFlex(r.Title))
126124 default:
127125 page := "issues"
128126 if r.Kind == "mr" {
@@ -134,6 +132,32 @@ func writeSearchTable(c *Ctx, w io.Writer, results []SearchResult) {
134132 tb.flush()
135133}
136134
135// searchScreen is search at a terminal: a section per kind of result.
136func searchScreen(c *Ctx, query string, results []SearchResult) screen {
137 repos := section{title: "Repositories"}
138 issues := section{title: "Issues"}
139 mrs := section{title: "Merge requests"}
140 for _, r := range results {
141 switch r.Kind {
142 case "repo":
143 repos.n++
144 repos.rows = append(repos.rows, rowOf(cLink(r.Repo, c.siteURL(r.Repo)), cFlex(r.Title)))
145 default:
146 sec, page := &issues, "issues"
147 if r.Kind == "mr" {
148 sec, page = &mrs, "mrs"
149 }
150 sec.n++
151 ref := cLink(fmt.Sprintf("%s%s%d", r.Repo, SearchMarker(r.Kind), r.Number), c.siteURL(r.Repo, page, strconv.FormatInt(r.Number, 10)))
152 sec.rows = append(sec.rows, rowOf(ref, cGlyph(r.State), cFlex(r.Title), cMeta(r.Author)))
153 }
154 }
155 return screen{sections: []section{repos, issues, mrs}, actions: []action{
156 {"Narrow", []string{"search", query, "--kind", "issue"}},
157 {"Narrow", []string{"search", query, "--kind", "mr"}},
158 }}
159}
160
137161// SearchMarker is the sigil a result's number carries, shared with the web
138162// so a hit reads the same in both places.
139163func SearchMarker(kind string) string {
internal/control/search_test.go −27
@@ -2,36 +2,9 @@ package control
22
33import (
44 "bytes"
5 "strings"
65 "testing"
76)
87
9// A repo hit has no state, an issue/mr hit does. At a terminal both
10// titles must start in the same column, not have the repo's title slide
11// left into the state column.
12func TestSearchTableAlignsTitleAtTerminal(t *testing.T) {
13 results := []SearchResult{
14 {Kind: "repo", Repo: "alice/webapp", Title: "a web application"},
15 {Kind: "issue", Repo: "alice/webapp", Number: 4, Title: "memory leak", State: "open"},
16 }
17 var b bytes.Buffer
18 writeSearchTable(&Ctx{Term: Term{Cols: 100}}, &b, results)
19
20 lines := strings.Split(strings.TrimRight(b.String(), "\n"), "\n")
21 if len(lines) != 2 {
22 t.Fatalf("want 2 rows, got %d lines:\n%s", len(lines), b.String())
23 }
24 repoRow, issueRow := lines[0], lines[1]
25 repoTitleAt := strings.Index(repoRow, "a web application")
26 issueTitleAt := strings.Index(issueRow, "memory leak")
27 if repoTitleAt < 0 || issueTitleAt < 0 {
28 t.Fatalf("columns not found:\n%s", b.String())
29 }
30 if repoTitleAt != issueTitleAt {
31 t.Errorf("titles not aligned: repo row at %d, issue row at %d\n%s", repoTitleAt, issueTitleAt, b.String())
32 }
33}
34
358// Plain output has no header to align to, so a repo row stays 3 cells —
369// bytes must not change from before the terminal fix.
3710func TestSearchTablePlainRepoRowIsThreeCells(t *testing.T) {
internal/control/stage3work_test.go added +103
@@ -0,0 +1,103 @@
1package control
2
3import (
4 "os"
5 "os/exec"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "gitbay.org/gitbay/internal/store"
11)
12
13// workFixture is a repository on disk with a CI job and an issue
14// template, an issue, a merge request, a commit status and a saved
15// query, all as alice. It returns the head sha.
16func workFixture(t *testing.T) (*store.Store, store.Repo, store.User, string, string) {
17 t.Helper()
18 st, repo, uid := newQueueTestRepo(t)
19 u := store.User{ID: uid, Username: "alice"}
20 root := t.TempDir()
21 env := append(gitTestEnv(), "GIT_AUTHOR_DATE=2026-09-01T10:00:00Z", "GIT_COMMITTER_DATE=2026-09-01T10:00:00Z")
22 git := func(dir string, args ...string) string {
23 t.Helper()
24 cmd := exec.Command("git", args...)
25 cmd.Dir, cmd.Env = dir, env
26 out, err := cmd.CombinedOutput()
27 if err != nil {
28 t.Fatalf("git %v: %v\n%s", args, err, out)
29 }
30 return strings.TrimSpace(string(out))
31 }
32 src := filepath.Join(root, "src")
33 if err := os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755); err != nil {
34 t.Fatal(err)
35 }
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)
38 git(root, "init", "-q", "-b", "main", "src")
39 git(src, "add", ".")
40 git(src, "commit", "-q", "-m", "first")
41 sha := git(src, "rev-parse", "HEAD")
42 dir := RepoDir(root, repo.OwnerName, repo.Name)
43 os.MkdirAll(filepath.Dir(dir), 0o755)
44 git(root, "clone", "-q", "--bare", src, dir)
45
46 p := repo.Path()
47 dispatchIn(t, st, u, root, "", "issue", "create", p, "--title", "Crash on start", "--body", "It crashes.")
48 if _, err := st.CreateMR(repo.ID, uid, repo.ID, "fix", "main", "Fix crash", "", sha, "md", false); err != nil {
49 t.Fatal(err)
50 }
51 dispatchIn(t, st, u, root, "", "status", "set", p, sha, "--context", "ext/test", "--state", "success", "--description", "all green")
52 dispatchIn(t, st, u, root, "", "query", "save", "open", "is:open")
53 return st, repo, u, root, sha
54}
55
56func TestWorkPlainPinnedA(t *testing.T) {
57 st, repo, u, root, sha := workFixture(t)
58 p := repo.Path()
59 for name, argv := range map[string][]string{
60 "query-run": {"query", "run", "open"},
61 "issue-list-q": {"issue", "list", "--q", "repo:alice/app is:open"},
62 "query-list": {"query", "list"},
63 "query-show": {"query", "show", "open"},
64 "search": {"search", "crash"},
65 "feed": {"feed"},
66 "mr-revisions": {"mr", "revisions", p, "1"},
67 "issue-templates": {"issue", "templates", p},
68 "build-jobs": {"build", "jobs", p},
69 "status-list": {"status", "list", p, sha},
70 } {
71 pinPlain(t, name, dispatchIn(t, st, u, root, "", argv...))
72 }
73}
74
75func TestWorkScreensA(t *testing.T) {
76 st, repo, u, root, sha := workFixture(t)
77 p := repo.Path()
78 for _, tc := range []struct {
79 argv []string
80 want []string
81 }{
82 {[]string{"query", "run", "open"}, []string{"open (2)\n", "alice/app#1", "Crash on start", "alice/app!1"}},
83 {[]string{"issue", "list", "--q", "repo:alice/app is:open"}, []string{"Results (1)\n", "Crash on start"}},
84 {[]string{"query", "list"}, []string{"Saved queries (1)\n", "open is:open"}},
85 {[]string{"query", "show", "open"}, []string{"Query:", "is:open", "Matches:", "2"}},
86 {[]string{"search", "crash"}, []string{"Issues (1)\n", "Merge requests (1)\n", "Crash on start"}},
87 {[]string{"feed"}, []string{"Activity ("}},
88 {[]string{"mr", "revisions", p, "1"}, []string{"Revisions (1)\n", "v1"}},
89 {[]string{"issue", "templates", p}, []string{"Issue templates (1)\nissue-template-bug.md"}},
90 {[]string{"build", "jobs", p}, []string{"Jobs (1)\ntest on push\n"}},
91 {[]string{"status", "list", p, sha}, []string{"Commit:", "Combined:", "✓", "Statuses (1)\n", "ext/test all green"}},
92 } {
93 out := atTerminalIn(t, st, u, root, tc.argv...)
94 for _, w := range tc.want {
95 if !strings.Contains(out, w) {
96 t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out)
97 }
98 }
99 if tc.argv[0] != "feed" {
100 checkLegend(t, out)
101 }
102 }
103}
internal/control/status.go +13 −1
@@ -137,13 +137,25 @@ func runStatusList(c *Ctx, args []string) int {
137137 Combined string `json:"combined"`
138138 Statuses []out `json:"statuses"`
139139 }{full, combinedOf(statuses), ds}
140 return c.emit(d, func(w io.Writer) {
140 return c.emitView(d, func(w io.Writer) {
141141 tb := c.table(w, "CONTEXT", "STATE", "DESCRIPTION")
142142 tb.row(cText("combined"), cState(orNone(d.Combined)), cText(fmt.Sprintf("%.10s", d.SHA)))
143143 for _, x := range ds {
144144 tb.row(cText(x.Context), cState(x.State), cFlex(x.Description))
145145 }
146146 tb.flush()
147 }, func() screen {
148 combined := []cell{cGlyph(d.Combined), cState(orNone(d.Combined))}
149 statuses := section{title: "Statuses", n: len(ds)}
150 for _, x := range ds {
151 statuses.rows = append(statuses.rows, rowOf(cGlyph(x.State), cText(x.Context), cFlex(x.Description), cMeta(x.Creator)))
152 }
153 return screen{fields: []field{
154 {"Commit", []cell{cRef(fmt.Sprintf("%.10s", d.SHA))}},
155 {"Combined", combined},
156 }, sections: []section{statuses}, actions: []action{
157 {"Report", []string{"status", "set", repo.Path(), d.SHA, "--context", "<context>", "--state", "success"}},
158 }}
147159 })
148160}
149161
internal/control/testdata/plain/build-jobs.txt added +1
@@ -0,0 +1 @@
1test on push
internal/control/testdata/plain/feed.txt added +2
@@ -0,0 +1,2 @@
1<time> alice status alice/app
2<time> alice opened issue alice/app#1
internal/control/testdata/plain/issue-list-q.txt added +1
@@ -0,0 +1 @@
1alice/app#1 open Crash on start alice
internal/control/testdata/plain/issue-templates.txt added +1
@@ -0,0 +1 @@
1issue-template-bug.md
internal/control/testdata/plain/mr-revisions.txt added +1
@@ -0,0 +1 @@
1* v1 2bc3205597 <time>
internal/control/testdata/plain/query-list.txt added +1
@@ -0,0 +1 @@
1open is:open
internal/control/testdata/plain/query-run.txt added +2
@@ -0,0 +1,2 @@
1alice/app!1 open Fix crash alice
2alice/app#1 open Crash on start alice
internal/control/testdata/plain/query-show.txt added +5
@@ -0,0 +1,5 @@
1open
2
3 query is:open
4 matches 2
5 pinned no
internal/control/testdata/plain/search.txt added +2
@@ -0,0 +1,2 @@
1issue alice/app#1 open Crash on start
2mr alice/app!1 open Fix crash
internal/control/testdata/plain/status-list.txt added +2
@@ -0,0 +1,2 @@
1combined success 2bc3205597
2ext/test success all green