Commit a0aa68a7ca

a0aa68a7ca6ba90b56f41c065be345caf60a464b

parent: 4ea42433c4

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-10-01 21:11 UTC

control: action-oriented lists and views at a terminal

mr list adds checks, review state (the merge gates' rule) and age;
issue list adds labels, assignee, comments and age. Per-row data comes
from page batches in store: CommitStatusesFor, MRReviewsFor,
MRReviewRequestsFor, IssueLabelsFor, IssueCommentCounts.

dashboard opens with what needs the viewer and caps activity at eight
lines; feed rows split who, what, where and outcome. repo show, repo
log, repo refs, repo tree, release list/show, build show, milestone
list, audit, whoami and keys list gain terminal layouts; creates print
the page URL. A table drops a column empty on every row.

Piped, repo refs lists tags newest version first.

Closes #314

Layout: unified · split

.gitbay/wiki/Users.org +5
@@ -1022,6 +1022,11 @@ then prints:
10221022 =krz/gitbay=, SHAs) in cyan. A marker piped output keeps as its own
10231023 trailing cell (=[archived]=, =primary=) joins the state at a terminal:
10241024 =public, archived=;
1025- lists that say what to do next: =mr list= with checks, review state
1026 and age, =issue list= with labels, assignee, comments and age, a
1027 =dashboard= that opens with what needs you; a column empty on every
1028 row is left out. These columns are terminal-only; piped rows keep
1029 their own;
10251030- diffs coloured (file headers bold, hunks cyan, added green, removed
10261031 red), and =build log= with its step lines bold, the failed one red;
10271032 both made safe like any user text, the log's own colour codes dropped;
CHANGELOG.org +17
@@ -33,6 +33,23 @@ anything beyond "replace the binary and restart" is needed.
3333 longer prints the subject twice. =build log= at a terminal drops the
3434 tools' own colour codes, makes the rest safe, and marks step lines,
3535 the failed step in red. (#313)
36- At a terminal, lists and views say what to do next (#314). =mr list=
37 adds checks, review state (the merge gates' rule; =review requested=
38 in yellow when it is yours) and age; =issue list= adds labels,
39 assignee (yellow when it is you), comments and age, each from one
40 query per page. =dashboard= opens with what needs you and shows eight
41 lines of activity; =feed= splits who, what and where so outcomes carry
42 their colours. =repo show= adds the clone URL, open counts, the latest
43 release and the default branch's checks; =repo log= shows the author's
44 name, age and signature; =repo refs= lists tags newest version first
45 (piped too) and the default branch first; =repo tree= leads with the
46 name; =release list= drops the tag from titles and adds the date;
47 =build show= lists steps with their outcome, the open merge request
48 and the log command; =milestone list= marks overdue and shows
49 progress; =audit= data reads as key=value; =whoami= shows instance,
50 key and scope; =auth keys list= marks this session's key; issue, merge
51 request and release creation print the page's URL. A column empty on
52 every row is left out.
3653
3754* v1.40.1 — 2026-09-29
3855
internal/control/admin.go +1 −1
@@ -596,7 +596,7 @@ func runAdminRunners(c *Ctx, args []string) int {
596596 if r.BuildNumber != 0 {
597597 held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt)
598598 }
599 tb.row(cText(r.Username), cFlex(r.Fingerprint), cAge(r.LastSeen), cText(scope), cText(held))
599 tb.row(cText(r.Username), cText(r.Fingerprint), cAge(r.LastSeen), cFlex(scope), cText(held))
600600 }
601601 tb.flush()
602602 })
internal/control/audit.go +44 −1
@@ -1,7 +1,9 @@
11package control
22
33import (
4 "encoding/json"
45 "io"
6 "slices"
57 "strconv"
68 "strings"
79 "time"
@@ -59,7 +61,11 @@ func runAudit(c *Ctx, args []string) int {
5961 if actor == "" {
6062 actor = "-"
6163 }
62 tb.row(cAge(e.CreatedAt), cText(actor), cText(e.Action), cFlex(e.Data))
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))
6369 }
6470 tb.flush()
6571 })
@@ -84,3 +90,40 @@ func parseSince(v string, now time.Time) (time.Time, bool) {
8490 }
8591 return time.Time{}, false
8692}
93
94// keyValues is an audit entry's JSON data as a terminal reads it:
95// key=value pairs in key order, strings bare, arrays space-separated.
96// Anything that is not a JSON object is returned as it is.
97func keyValues(data string) string {
98 var m map[string]any
99 if json.Unmarshal([]byte(data), &m) != nil {
100 return data
101 }
102 keys := make([]string, 0, len(m))
103 for k := range m {
104 keys = append(keys, k)
105 }
106 slices.Sort(keys)
107 parts := make([]string, len(keys))
108 for i, k := range keys {
109 parts[i] = k + "=" + kvValue(m[k])
110 }
111 return strings.Join(parts, " ")
112}
113
114func kvValue(v any) string {
115 switch v := v.(type) {
116 case string:
117 return v
118 case []any:
119 parts := make([]string, len(v))
120 for i, e := range v {
121 parts[i] = kvValue(e)
122 }
123 return "[" + strings.Join(parts, " ") + "]"
124 case nil:
125 return ""
126 }
127 b, _ := json.Marshal(v)
128 return string(b)
129}
internal/control/build.go +38
@@ -259,6 +259,15 @@ func runBuildShow(c *Ctx, args []string) int {
259259 if d.DurationS > 0 {
260260 duration = (time.Duration(d.DurationS) * time.Second).String()
261261 }
262 // At a terminal: the open merge request the build ran for, and
263 // the command that prints its log.
264 mr, logCmd := "", ""
265 if c.Term.Cols > 0 {
266 if m, ok, err := c.Store.OpenMRBySource(repo.ID, d.Ref); err == nil && ok {
267 mr = fmt.Sprintf("!%d %s", m.Number, m.Title)
268 }
269 logCmd = fmt.Sprintf("gitbay build log %s %d", repo.Path(), d.Number)
270 }
262271 v := c.view(w)
263272 v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status)
264273 v.fields(
@@ -269,11 +278,40 @@ func runBuildShow(c *Ctx, args []string) int {
269278 "duration", duration,
270279 "failed step", failedStep,
271280 "failed", failed,
281 "mr", mr,
282 "log", logCmd,
272283 "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)),
273284 )
285 if c.Term.Cols > 0 && len(d.Steps) > 0 {
286 v.section("steps")
287 tb := c.table(w, "#", "STEP", "STATE")
288 for i, step := range d.Steps {
289 line, _, _ := strings.Cut(step, "\n")
290 tb.row(cNum(int64(i+1)), cFlex(line), cState(stepState(d.Status, d.FailedStep, i+1)))
291 }
292 tb.flush()
293 }
274294 })
275295}
276296
297// stepState is what a finished build says about one of its steps: those
298// before the failed step passed, the failed one failed, the rest never
299// ran. A build still running, or one that failed outside its steps,
300// says nothing per step.
301func stepState(status string, failedStep, n int) string {
302 switch {
303 case status == "success":
304 return "success"
305 case status != "failure" || failedStep == 0:
306 return ""
307 case n < failedStep:
308 return "success"
309 case n == failedStep:
310 return "failure"
311 }
312 return "skipped"
313}
314
277315func runBuildLog(c *Ctx, args []string) int {
278316 f, err := c.parseArgs(args, flagSpec{Bools: []string{"--follow"}, Values: []string{"--step", "--tail"}, MaxPos: 2, Usage: c.Cmd.Usage})
279317 if err != nil {
internal/control/dashboard.go +72 −5
@@ -93,6 +93,50 @@ type DashboardOut struct {
9393 Queues *store.Queues `json:"queues,omitempty"`
9494}
9595
96// dashboardActivity is how many feed lines the dashboard shows at a
97// terminal.
98const dashboardActivity = 8
99
100// needsYou is the dashboard's first line at a terminal: what waits on
101// the viewer, in yellow, or a dim line saying nothing does.
102func (t Term) needsYou(d DashboardOut) string {
103 var parts []string
104 add := func(n int, one, many string) {
105 switch {
106 case n == 1:
107 parts = append(parts, "1 "+one)
108 case n > 1:
109 parts = append(parts, fmt.Sprintf("%d %s", n, many))
110 }
111 }
112 add(len(d.Reviews), "review requested", "reviews requested")
113 add(len(d.Assigned), "assigned issue", "assigned issues")
114 add(failingBuilds(d.Builds), "failing build", "failing builds")
115 add(d.Unread, "unread notification", "unread notifications")
116 if len(parts) == 0 {
117 return t.paint(sgrDim, "Nothing waits on you.")
118 }
119 return t.paint(sgrBold+sgrYellow, "Needs you: "+strings.Join(parts, ", "))
120}
121
122// failingBuilds counts the jobs whose latest build failed: a failure a
123// later build of the same job and ref has already replaced is not one.
124func failingBuilds(builds []DashboardBuild) int {
125 seen := map[string]bool{}
126 n := 0
127 for _, b := range builds {
128 key := b.Repo + "\x00" + b.Job + "\x00" + b.Ref
129 if seen[key] {
130 continue
131 }
132 seen[key] = true
133 if b.Status == "failure" {
134 n++
135 }
136 }
137 return n
138}
139
96140func runDashboard(c *Ctx, args []string) int {
97141 if len(args) != 0 {
98142 return c.usage()
@@ -227,7 +271,10 @@ func runDashboard(c *Ctx, args []string) int {
227271 return rows
228272 }
229273
230 if d.Unread > 0 {
274 if c.Term.Cols > 0 {
275 fmt.Fprintln(w, c.Term.needsYou(d))
276 wrote = true
277 } else if d.Unread > 0 {
231278 fmt.Fprintf(w, "unread notifications: %d\n", d.Unread)
232279 }
233280 itemHeader := []string{"REF", "TITLE", "AUTHOR"}
@@ -258,11 +305,23 @@ func runDashboard(c *Ctx, args []string) int {
258305 section("pinned:", []string{"PATH", "VISIBILITY", "DESCRIPTION"}, pinnedRows)
259306
260307 lines := FeedLines(events)
261 activityRows := make([][]cell, len(lines))
262 for i, l := range lines {
263 activityRows[i] = []cell{cAge(l.When), cFlex(l.Sentence())}
308 if c.Term.Cols > 0 {
309 // The feed has its own command; the dashboard shows the start.
310 rows := make([][]cell, 0, dashboardActivity)
311 for _, l := range lines[:min(len(lines), dashboardActivity)] {
312 rows = append(rows, l.termCells())
313 }
314 section("recent activity:", feedHeader, rows)
315 if len(lines) > dashboardActivity {
316 fmt.Fprintln(w, c.Term.paint(sgrDim, "more: gitbay feed"))
317 }
318 } else {
319 activityRows := make([][]cell, len(lines))
320 for i, l := range lines {
321 activityRows[i] = []cell{cAge(l.When), cFlex(l.Sentence())}
322 }
323 section("recent activity:", []string{"WHEN", "EVENT"}, activityRows)
264324 }
265 section("recent activity:", []string{"WHEN", "EVENT"}, activityRows)
266325
267326 buildRows := make([][]cell, len(d.Builds))
268327 for i, b := range d.Builds {
@@ -378,6 +437,14 @@ func runFeed(c *Ctx, args []string) int {
378437 ds := feedOutputs(events)
379438 lines := FeedLines(events)
380439 return c.emitPage(p, ds, next, func(w io.Writer) {
440 if c.Term.Cols > 0 {
441 tb := c.table(w, feedHeader...)
442 for _, l := range lines {
443 tb.row(l.termCells()...)
444 }
445 tb.flush()
446 return
447 }
381448 tb := c.table(w, "WHEN", "EVENT")
382449 for _, l := range lines {
383450 tb.row(cAge(l.When), cFlex(l.Sentence()))
internal/control/feedline.go +19
@@ -42,6 +42,25 @@ 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
50func (l FeedLine) termCells() []cell {
51 ref := l.Ref
52 if ref != "" && ref[0] != '#' && ref[0] != '!' {
53 ref = " " + ref
54 }
55 detail := cText(l.Extra)
56 verb := l.Verb
57 if l.State != "" {
58 verb = strings.TrimSuffix(verb, " "+l.State)
59 detail = cState(l.State)
60 }
61 return []cell{cAge(l.When), cText(l.Actor), cText(verb), cRef(l.Repo + ref), detail}
62}
63
4564// FeedLines turns stored events into readable lines. An unknown kind
4665// still shows: the feed says what happened even for events added later.
4766// Build events on the same commit, adjacent in the input, fold into one
internal/control/identity.go +29 −2
@@ -72,7 +72,30 @@ func runWhoami(c *Ctx, args []string) int {
7272 }
7373 d := out{Username: c.User.Username, Admin: c.User.IsAdmin, KeyScope: c.Scope}
7474 return c.emit(d, func(w io.Writer) {
75 fmt.Fprintln(w, d.Username)
75 if c.Term.Cols == 0 {
76 fmt.Fprintln(w, d.Username)
77 return
78 }
79 // At a terminal: where, and with what.
80 role := ""
81 if d.Admin {
82 role = "admin"
83 }
84 // The key's label, or the start of its fingerprint: keys list
85 // has the whole of it.
86 via := c.Source
87 if k, err := c.Store.SSHKeyByFingerprint(c.Source); err == nil && k.Label != "" {
88 via = k.Label
89 } else if len(via) > 20 {
90 via = via[:19] + "…"
91 }
92 v := c.view(w)
93 v.title(d.Username, "", role)
94 v.fields(
95 "instance", c.Cfg.Server.SiteURL,
96 "key", via,
97 "scope", d.KeyScope,
98 )
7699 })
77100}
78101
@@ -101,8 +124,12 @@ func runKeysList(c *Ctx, args []string) int {
101124 return c.emit(ds, func(w io.Writer) {
102125 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES")
103126 for _, d := range ds {
127 used := cText(c.usedText(d.LastUsedAt))
128 if c.Term.Cols > 0 && d.Fingerprint == c.Source {
129 used = cMark("this session", sgrGreen)
130 }
104131 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label),
105 cText(c.usedText(d.LastUsedAt)), cText(c.expiresText(d.ExpiresAt, now)))
132 used, cText(c.expiresText(d.ExpiresAt, now)))
106133 }
107134 tb.flush()
108135 })
internal/control/issue.go +33
@@ -282,6 +282,9 @@ func runIssueCreate(c *Ctx, args []string) int {
282282 }
283283 return c.emit(Created{Number: n}, func(w io.Writer) {
284284 fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
285 if c.Term.Cols > 0 {
286 fmt.Fprintln(w, c.siteURL(repo.Path(), "issues", strconv.FormatInt(n, 10)))
287 }
285288 })
286289}
287290
@@ -336,7 +339,37 @@ func runIssueList(c *Ctx, args []string) int {
336339 for _, i := range issues {
337340 ds = append(ds, issueToOut(i, false))
338341 }
342 var comments map[int64]int
343 var labels, assignees map[int64][]string
344 if c.Term.Cols > 0 && !c.JSON {
345 ids := make([]int64, len(issues))
346 for i, is := range issues {
347 ids[i] = is.ID
348 }
349 if comments, err = c.Store.IssueCommentCounts(ids); err != nil {
350 return c.fail(protocol.ExitFailure, "%v", err)
351 }
352 if labels, assignees, err = c.Store.IssueLabelsFor(ids); err != nil {
353 return c.fail(protocol.ExitFailure, "%v", err)
354 }
355 }
339356 return c.emitPage(p, ds, next, func(w io.Writer) {
357 if c.Term.Cols > 0 {
358 // At a terminal: who it waits on and how much it has moved,
359 // in place of who opened it.
360 tb := c.table(w, "#", "STATE", "TITLE", "LABELS", "ASSIGNEE", "COMMENTS", "UPDATED")
361 for i, d := range ds {
362 n := ""
363 if k := comments[issues[i].ID]; k > 0 {
364 n = strconv.Itoa(k)
365 }
366 tb.row(cRef(fmt.Sprintf("#%d", d.Number)), cState(d.State), cFlex(d.Title),
367 cText(labelsMark(labels[issues[i].ID])), assigneesMark(assignees[issues[i].ID], c.User.Username),
368 cText(n), cAge(issues[i].UpdatedAt))
369 }
370 tb.flush()
371 return
372 }
340373 tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR")
341374 for _, d := range ds {
342375 tb.row(cRef(fmt.Sprintf("#%d", d.Number)), cState(d.State), cFlex(d.Title), cText(d.Author))
internal/control/marks.go added +124
@@ -0,0 +1,124 @@
1package control
2
3import (
4 "fmt"
5 "slices"
6 "strings"
7
8 "gitbay.org/gitbay/internal/store"
9)
10
11// checksMark sums a commit's statuses for a list row at a terminal:
12// "2 failed" red, "1 pending" dim, "3/3" green, blank with none.
13func checksMark(sts []store.CommitStatus) cell {
14 failed, pending, passed := 0, 0, 0
15 for _, s := range sts {
16 switch s.State {
17 case "failure", "error":
18 failed++
19 case "pending":
20 pending++
21 default:
22 passed++
23 }
24 }
25 switch {
26 case len(sts) == 0:
27 return cText("")
28 case failed > 0:
29 return cMark(fmt.Sprintf("%d failed", failed), sgrRed)
30 case pending > 0:
31 return cMark(fmt.Sprintf("%d pending", pending), sgrDim)
32 }
33 return cMark(fmt.Sprintf("%d/%d", passed, len(sts)), sgrGreen)
34}
35
36// reviewMark is where review of an open merge request stands, by the
37// rule the merge gates use: each counting reviewer's latest verdict on
38// the current head. Changes requested outranks an approval; a request
39// for the viewer's own review is what waits on them.
40func reviewMark(mr store.MR, reviews []store.MRReview, counts map[string]bool, viewer string) cell {
41 latest := map[string]string{}
42 for _, r := range reviews {
43 if r.Stale || r.Reviewer == mr.Author || !counts[r.Reviewer] {
44 continue
45 }
46 latest[r.Reviewer] = r.Verdict
47 }
48 approved := 0
49 for _, v := range latest {
50 switch v {
51 case "request_changes":
52 return cMark("changes requested", sgrRed)
53 case "approve":
54 approved++
55 }
56 }
57 switch {
58 case approved > 0:
59 return cMark("approved", sgrGreen)
60 case slices.Contains(mr.ReviewRequests, viewer):
61 return cMark("review requested", sgrYellow)
62 case len(mr.ReviewRequests) > 0:
63 return cMark("requested", sgrDim)
64 }
65 return cText("")
66}
67
68// mrMarks is the CHECKS and REVIEW cells for every open merge request
69// on a list page, from one query for statuses and one for reviews.
70func mrMarks(c *Ctx, repo store.Repo, mrs []store.MR) (checks, review map[int64]cell, err error) {
71 checks, review = map[int64]cell{}, map[int64]cell{}
72 var ids []int64
73 var shas []string
74 for _, m := range mrs {
75 if m.State == "open" {
76 ids = append(ids, m.ID)
77 shas = append(shas, m.HeadSHA)
78 }
79 }
80 statuses, err := c.Store.CommitStatusesFor(repo.ID, shas)
81 if err != nil {
82 return nil, nil, err
83 }
84 reviews, err := c.Store.MRReviewsFor(ids)
85 if err != nil {
86 return nil, nil, err
87 }
88 requests, err := c.Store.MRReviewRequestsFor(ids)
89 if err != nil {
90 return nil, nil, err
91 }
92 var all []store.MRReview
93 for _, rs := range reviews {
94 all = append(all, rs...)
95 }
96 counts := ReviewersWhoCount(c.Store, repo, all)
97 for _, m := range mrs {
98 if m.State != "open" {
99 continue
100 }
101 checks[m.ID] = checksMark(statuses[m.HeadSHA])
102 m.ReviewRequests = requests[m.ID]
103 review[m.ID] = reviewMark(m, reviews[m.ID], counts, c.User.Username)
104 }
105 return checks, review, nil
106}
107
108// labelsMark is a list row's labels at a terminal: the first two, then
109// how many more.
110func labelsMark(labels []string) string {
111 if len(labels) <= 2 {
112 return strings.Join(labels, ", ")
113 }
114 return fmt.Sprintf("%s, +%d", strings.Join(labels[:2], ", "), len(labels)-2)
115}
116
117// assigneesMark is a row's assignees, yellow when the viewer is one.
118func assigneesMark(assignees []string, viewer string) cell {
119 s := labelsMark(assignees)
120 if slices.Contains(assignees, viewer) {
121 return cMark(s, sgrYellow)
122 }
123 return cText(s)
124}
internal/control/marks_test.go added +124
@@ -0,0 +1,124 @@
1package control
2
3import (
4 "testing"
5
6 "gitbay.org/gitbay/internal/store"
7)
8
9func TestChecksMark(t *testing.T) {
10 st := func(states ...string) []store.CommitStatus {
11 var out []store.CommitStatus
12 for _, s := range states {
13 out = append(out, store.CommitStatus{State: s})
14 }
15 return out
16 }
17 cases := []struct {
18 in []store.CommitStatus
19 text, sgr string
20 }{
21 {nil, "", ""},
22 {st("success", "success", "skipped"), "3/3", sgrGreen},
23 {st("success", "pending"), "1 pending", sgrDim},
24 {st("success", "failure", "error", "pending"), "2 failed", sgrRed},
25 }
26 for _, tc := range cases {
27 if got := checksMark(tc.in); got.s != tc.text || got.sgr != tc.sgr {
28 t.Errorf("checksMark(%v) = %q %q, want %q %q", tc.in, got.s, got.sgr, tc.text, tc.sgr)
29 }
30 }
31}
32
33func TestReviewMark(t *testing.T) {
34 counts := map[string]bool{"bob": true, "carol": true}
35 mr := store.MR{Author: "alice"}
36 rv := func(who, verdict string, stale bool) store.MRReview {
37 return store.MRReview{Reviewer: who, Verdict: verdict, Stale: stale}
38 }
39 cases := []struct {
40 name string
41 mr store.MR
42 reviews []store.MRReview
43 want string
44 }{
45 {"nothing", mr, nil, ""},
46 {"approved", mr, []store.MRReview{rv("bob", "approve", false)}, "approved"},
47 {"changes outrank", mr, []store.MRReview{rv("bob", "approve", false), rv("carol", "request_changes", false)}, "changes requested"},
48 {"latest verdict wins", mr, []store.MRReview{rv("bob", "request_changes", false), rv("bob", "approve", false)}, "approved"},
49 {"stale ignored", mr, []store.MRReview{rv("bob", "approve", true)}, ""},
50 {"non-writer ignored", mr, []store.MRReview{rv("dave", "approve", false)}, ""},
51 {"author ignored", store.MR{Author: "bob"}, []store.MRReview{rv("bob", "approve", false)}, ""},
52 {"asked of viewer", store.MR{Author: "alice", ReviewRequests: []string{"me"}}, nil, "review requested"},
53 {"asked of others", store.MR{Author: "alice", ReviewRequests: []string{"bob"}}, nil, "requested"},
54 }
55 for _, tc := range cases {
56 if got := reviewMark(tc.mr, tc.reviews, counts, "me"); got.s != tc.want {
57 t.Errorf("%s: reviewMark = %q, want %q", tc.name, got.s, tc.want)
58 }
59 }
60}
61
62func TestLabelsAndAssignees(t *testing.T) {
63 if got := labelsMark([]string{"a", "b", "c", "d"}); got != "a, b, +2" {
64 t.Errorf("labelsMark = %q", got)
65 }
66 if got := assigneesMark([]string{"me"}, "me"); got.sgr != sgrYellow {
67 t.Errorf("viewer's assignment not yellow: %+v", got)
68 }
69 if got := assigneesMark([]string{"bob"}, "me"); got.sgr != "" {
70 t.Errorf("someone else's assignment coloured: %+v", got)
71 }
72}
73
74func TestFailingBuilds(t *testing.T) {
75 builds := []DashboardBuild{
76 {Repo: "a/x", Job: "test", Ref: "main", Status: "success"},
77 {Repo: "a/x", Job: "test", Ref: "main", Status: "failure"}, // replaced by the newer success
78 {Repo: "a/y", Job: "pull", Ref: "main", Status: "failure"},
79 }
80 if got := failingBuilds(builds); got != 1 {
81 t.Errorf("failingBuilds = %d, want 1", got)
82 }
83}
84
85func TestNeedsYou(t *testing.T) {
86 term := Term{Cols: 80}
87 if got := term.needsYou(DashboardOut{}); got != "Nothing waits on you." {
88 t.Errorf("empty = %q", got)
89 }
90 d := DashboardOut{Reviews: make([]DashboardItem, 2), Unread: 1}
91 if got := term.needsYou(d); got != "Needs you: 2 reviews requested, 1 unread notification" {
92 t.Errorf("needsYou = %q", got)
93 }
94}
95
96func TestStepState(t *testing.T) {
97 cases := []struct {
98 status string
99 failed, n int
100 want string
101 }{
102 {"success", 0, 1, "success"},
103 {"running", 0, 1, ""},
104 {"failure", 0, 1, ""},
105 {"failure", 2, 1, "success"},
106 {"failure", 2, 2, "failure"},
107 {"failure", 2, 3, "skipped"},
108 }
109 for _, tc := range cases {
110 if got := stepState(tc.status, tc.failed, tc.n); got != tc.want {
111 t.Errorf("stepState(%q, %d, %d) = %q, want %q", tc.status, tc.failed, tc.n, got, tc.want)
112 }
113 }
114}
115
116func TestKeyValues(t *testing.T) {
117 got := keyValues(`{"source":"SHA256:x","argv":["--untrusted","a b"],"n":3}`)
118 if got != "argv=[--untrusted a b] n=3 source=SHA256:x" {
119 t.Errorf("keyValues = %q", got)
120 }
121 if got := keyValues("not json"); got != "not json" {
122 t.Errorf("non-JSON = %q", got)
123 }
124}
internal/control/milestone.go +23
@@ -135,6 +135,29 @@ func emitMilestones(c *Ctx, ms []store.Milestone) int {
135135 ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OrgID != 0, m.OpenItems, m.ClosedItems})
136136 }
137137 return c.emit(ds, func(w io.Writer) {
138 if c.Term.Cols > 0 {
139 tb := c.table(w, "TITLE", "STATE", "DUE", "PROGRESS", "SCOPE")
140 for _, d := range ds {
141 due := cText("")
142 if t, ok := parseStamp(d.Due + " 00:00:00"); ok {
143 due = cText(relAge(d.Due+" 00:00:00", termNow()))
144 if d.State == "open" && t.Before(termNow()) {
145 due = cMark("overdue "+d.Due, sgrRed)
146 }
147 }
148 progress := ""
149 if total := d.Open + d.Closed; total > 0 {
150 progress = fmt.Sprintf("%d/%d closed (%d%%)", d.Closed, total, d.Closed*100/total)
151 }
152 scope := ""
153 if d.Org {
154 scope = "org"
155 }
156 tb.row(cRef(d.Title), cState(d.State), due, cText(progress), cText(scope))
157 }
158 tb.flush()
159 return
160 }
138161 tb := c.table(w, "TITLE", "STATE", "DUE", "PROGRESS")
139162 for _, d := range ds {
140163 due := d.Due
internal/control/mr.go +37
@@ -532,6 +532,9 @@ func runMRCreate(c *Ctx, args []string) int {
532532 }
533533 return c.emit(out, func(w io.Writer) {
534534 fmt.Fprintf(w, "created %s!%d (%s -> %s)\n", repo.Path(), n, source, target)
535 if c.Term.Cols > 0 {
536 fmt.Fprintln(w, c.siteURL(repo.Path(), "mrs", strconv.FormatInt(n, 10)))
537 }
535538 if out.StackedOn != nil {
536539 fmt.Fprintf(w, "stacked on !%d %s\n", out.StackedOn.Number, out.StackedOn.Title)
537540 }
@@ -676,7 +679,17 @@ func runMRList(c *Ctx, args []string) int {
676679 o.StackedOn, _ = stackOf(c, repo, m)
677680 ds = append(ds, o)
678681 }
682 var checks, review map[int64]cell
683 if c.Term.Cols > 0 && !c.JSON {
684 if checks, review, err = mrMarks(c, repo, mrs); err != nil {
685 return c.fail(protocol.ExitFailure, "%v", err)
686 }
687 }
679688 return c.emitPage(p, ds, next, func(w io.Writer) {
689 if c.Term.Cols > 0 {
690 mrListTerm(c, w, repo, mrs, ds, checks, review)
691 return
692 }
680693 tb := c.table(w, "!", "STATE", "TITLE", "REF")
681694 for _, d := range ds {
682695 state := d.State
@@ -693,6 +706,30 @@ func runMRList(c *Ctx, args []string) int {
693706 })
694707}
695708
709// mrListTerm is mr list at a terminal: where checks and review stand
710// for each open merge request, and when it last changed. The source
711// branch alone names a merge request into the default branch.
712func mrListTerm(c *Ctx, w io.Writer, repo store.Repo, mrs []store.MR, ds []mrOut, checks, review map[int64]cell) {
713 tb := c.table(w, "!", "STATE", "TITLE", "BRANCH", "CHECKS", "REVIEW", "UPDATED")
714 for i, d := range ds {
715 state := d.State
716 if d.Draft {
717 state = "draft"
718 }
719 ref := d.Source
720 if d.TargetRef != repo.DefaultBranch {
721 ref += " -> " + d.TargetRef
722 }
723 if d.StackedOn != nil {
724 ref += fmt.Sprintf(" (on !%d)", d.StackedOn.Number)
725 }
726 m := mrs[i]
727 tb.row(cRef(fmt.Sprintf("!%d", d.Number)), cState(state), cFlex(d.Title), cText(ref),
728 checks[m.ID], review[m.ID], cAge(m.UpdatedAt))
729 }
730 tb.flush()
731}
732
696733// byWhom renders " by <user>", or nothing when the actor is unknown — an
697734// imported merge request carries a time but no local account.
698735func byWhom(user string) string {
internal/control/read.go +29 −2
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "io"
88 "path"
9 "slices"
910 "strconv"
1011 "strings"
1112 "time"
@@ -104,12 +105,24 @@ func runRepoRefs(c *Ctx, args []string) int {
104105 for _, ref := range tags {
105106 d.Tags = append(d.Tags, refOut{Name: ref.Name, SHA: ref.SHA})
106107 }
108 // Tags read newest version first; the JSON keeps git's order.
109 sorted := slices.Clone(tags)
110 gitutil.SortVersions(sorted)
107111 return c.emit(d, func(w io.Writer) {
108112 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 }
109120 for _, ref := range d.Branches {
110 tb.row(cText("branch"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
121 if c.Term.Cols == 0 || ref.Name != repo.DefaultBranch {
122 tb.row(cText("branch"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
123 }
111124 }
112 for _, ref := range d.Tags {
125 for _, ref := range sorted {
113126 tb.row(cText("tag"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
114127 }
115128 tb.flush()
@@ -311,6 +324,20 @@ func runRepoTree(c *Ctx, args []string) int {
311324 d.Entries = append(d.Entries, eo)
312325 }
313326 return c.emit(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 }
314341 tb := c.table(w, "SHA", "SIZE", "NAME")
315342 for _, e := range d.Entries {
316343 name := e.Name
internal/control/release.go +27 −1
@@ -151,6 +151,9 @@ func runReleaseCreate(c *Ctx, args []string) int {
151151 c.Store.RecordEvent(repo.ID, c.User.ID, "release.created", fmt.Sprintf(`{"tag":%q}`, tag))
152152 return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) {
153153 fmt.Fprintf(w, "created release %s on %s\n", tag, repo.Path())
154 if c.Term.Cols > 0 {
155 fmt.Fprintln(w, c.siteURL(repo.Path(), "releases"))
156 }
154157 })
155158}
156159
@@ -280,6 +283,25 @@ func runReleaseList(c *Ctx, args []string) int {
280283 ds = append(ds, releaseToOut(r, false))
281284 }
282285 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(cRef(d.Tag), cFlex(title), cText(assets), cAge(d.CreatedAt))
301 }
302 tb.flush()
303 return
304 }
283305 tb := c.table(w, "TAG", "TITLE", "ASSETS")
284306 for _, d := range ds {
285307 title := d.Title
@@ -313,7 +335,11 @@ func runReleaseShow(c *Ctx, args []string) int {
313335 v.section("assets")
314336 tb := c.table(w, "NAME", "SIZE", "SHA256")
315337 for _, a := range d.Assets {
316 tb.row(cRef(a.Name), cSize(a.Size), cFlex(a.SHA256))
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))
317343 }
318344 tb.flush()
319345 }
internal/control/repo.go +70 −13
@@ -445,6 +445,10 @@ func runRepoShow(c *Ctx, args []string) int {
445445 d.Mirrors = append(d.Mirrors, mirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError})
446446 }
447447 }
448 var glance repoGlance
449 if c.Term.Cols > 0 && !c.JSON {
450 glance = repoAtAGlance(c, repo)
451 }
448452 return c.emit(d, func(w io.Writer) {
449453 bookmarked, archived := "", ""
450454 if d.Bookmarked {
@@ -454,19 +458,41 @@ func runRepoShow(c *Ctx, args []string) int {
454458 archived = "yes"
455459 }
456460 v := c.view(w)
457 v.title(d.Path, d.Description, d.Visibility)
458 v.fields(
459 "default branch", d.DefaultBranch,
460 "website", d.Website,
461 "topics", strings.Join(d.Topics, ", "),
462 "protected", strings.Join(d.ProtectedBranches, ", "),
463 "pages domains", strings.Join(d.Domains, ", "),
464 "fork of", d.ForkOf,
465 "watch", d.Watch,
466 "bookmarked", bookmarked,
467 "archived", archived,
468 "url", c.siteURL(d.Path),
469 )
461 if c.Term.Cols > 0 {
462 v.title(d.Path, "", d.Visibility)
463 v.text(d.Description)
464 v.fields(
465 "clone", glance.clone,
466 "issues", glance.issues,
467 "merge requests", glance.mrs,
468 "release", glance.release,
469 "checks", glance.checks,
470 "default branch", d.DefaultBranch,
471 "website", d.Website,
472 "topics", strings.Join(d.Topics, ", "),
473 "protected", strings.Join(d.ProtectedBranches, ", "),
474 "pages domains", strings.Join(d.Domains, ", "),
475 "fork of", d.ForkOf,
476 "watch", d.Watch,
477 "bookmarked", bookmarked,
478 "archived", archived,
479 "url", c.siteURL(d.Path),
480 )
481 } else {
482 v.title(d.Path, d.Description, d.Visibility)
483 v.fields(
484 "default branch", d.DefaultBranch,
485 "website", d.Website,
486 "topics", strings.Join(d.Topics, ", "),
487 "protected", strings.Join(d.ProtectedBranches, ", "),
488 "pages domains", strings.Join(d.Domains, ", "),
489 "fork of", d.ForkOf,
490 "watch", d.Watch,
491 "bookmarked", bookmarked,
492 "archived", archived,
493 "url", c.siteURL(d.Path),
494 )
495 }
470496 if len(d.Mirrors) > 0 {
471497 v.section("mirror")
472498 tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS")
@@ -485,6 +511,37 @@ func runRepoShow(c *Ctx, args []string) int {
485511 })
486512}
487513
514// repoGlance is what repo show adds at a terminal: how to clone it and
515// what is going on in it.
516type repoGlance struct {
517 clone, issues, mrs, release, checks string
518}
519
520// repoAtAGlance reads the glance fields. Each is left blank when it
521// cannot be read: they are a summary, not the command's result.
522func repoAtAGlance(c *Ctx, repo store.Repo) repoGlance {
523 host := c.Cfg.SiteHost()
524 if c.Cfg.SSH.Port != 22 {
525 host += ":" + strconv.Itoa(c.Cfg.SSH.Port)
526 }
527 g := repoGlance{clone: "ssh://git@" + host + "/" + repo.Path() + ".git"}
528 issues, mrs := c.Store.OpenCounts(repo.ID)
529 g.issues = fmt.Sprintf("%d open", issues)
530 g.mrs = fmt.Sprintf("%d open", mrs)
531 if rs, err := c.Store.ListReleasesPage(repo.ID, 1, "", 0); err == nil && len(rs) > 0 {
532 g.release = rs[0].Tag + ", " + relAge(rs[0].CreatedAt, termNow())
533 }
534 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
535 if tip, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch); err == nil {
536 if sts, err := c.Store.ListCommitStatuses(repo.ID, tip); err == nil {
537 if m := checksMark(sts); m.s != "" {
538 g.checks = m.s + " on " + repo.DefaultBranch
539 }
540 }
541 }
542 return g
543}
544
488545func runRepoTransfer(c *Ctx, args []string) int {
489546 if len(args) != 2 {
490547 return c.usage()
internal/control/sig.go +9
@@ -226,6 +226,15 @@ func runRepoLog(c *Ctx, args []string) int {
226226 ds = append(ds, d)
227227 }
228228 return c.emit(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 }
229238 tb := c.table(w, "SHA", "STATE", "SUBJECT", "AUTHOR")
230239 for _, d := range ds {
231240 tb.row(cRef(fmt.Sprintf("%.10s", d.SHA)), cState(d.Signature.State), cFlex(d.Subject),
internal/control/table.go +45 −7
@@ -2,6 +2,7 @@ package control
22
33import (
44 "io"
5 "slices"
56 "strconv"
67 "strings"
78)
@@ -23,15 +24,20 @@ const (
2324type cell struct {
2425 kind cellKind
2526 s string
27 sgr string // colour for a kindText cell whose meaning is not its word
2628}
2729
28func cRef(s string) cell { return cell{kindRef, s} }
29func cState(s string) cell { return cell{kindState, s} }
30func cText(s string) cell { return cell{kindText, s} }
31func cFlex(s string) cell { return cell{kindFlex, s} }
32func cAge(ts string) cell { return cell{kindAge, ts} }
33func cNum(n int64) cell { return cell{kindNum, strconv.FormatInt(n, 10)} }
34func cSize(n int64) cell { return cell{kindSize, strconv.FormatInt(n, 10)} }
30func cRef(s string) cell { return cell{kind: kindRef, s: s} }
31func cState(s string) cell { return cell{kind: kindState, s: s} }
32func cText(s string) cell { return cell{kind: kindText, s: s} }
33func cFlex(s string) cell { return cell{kind: kindFlex, s: s} }
34func cAge(ts string) cell { return cell{kind: kindAge, s: ts} }
35func cNum(n int64) cell { return cell{kind: kindNum, s: strconv.FormatInt(n, 10)} }
36func cSize(n int64) cell { return cell{kind: kindSize, s: strconv.FormatInt(n, 10)} }
37
38// cMark is text coloured for what it says about the row rather than for
39// its word: "2 failed" red, "review requested" yellow.
40func cMark(s, sgr string) cell { return cell{kind: kindText, s: s, sgr: sgr} }
3541
3642// table is a list command's rows. Plain, each row is written as it
3743// comes, tab-separated with no header. At a terminal rows are held
@@ -92,6 +98,7 @@ func (t *table) flush() {
9298 if t.term.Cols == 0 || len(t.rows) == 0 {
9399 return
94100 }
101 t.dropEmpty()
95102 // The column count is never smaller than the longest row: a row with
96103 // more cells than the header has still gets every cell rendered, the
97104 // header just shows blank above the ones it doesn't name.
@@ -132,6 +139,35 @@ func (t *table) flush() {
132139 io.WriteString(t.w, b.String())
133140}
134141
142// dropEmpty removes a column that is blank on every row, header and
143// all: an issue list where nothing is labelled has no LABELS column.
144func (t *table) dropEmpty() {
145 n := len(t.header)
146 for _, r := range t.rows {
147 n = max(n, len(r))
148 }
149 for i := n - 1; i >= 0; i-- {
150 empty := true
151 for _, r := range t.rows {
152 if i < len(r) && r[i].s != "" {
153 empty = false
154 break
155 }
156 }
157 if !empty {
158 continue
159 }
160 if i < len(t.header) {
161 t.header = slices.Delete(slices.Clone(t.header), i, i+1)
162 }
163 for j, r := range t.rows {
164 if i < len(r) {
165 t.rows[j] = slices.Delete(r, i, i+1)
166 }
167 }
168 }
169}
170
135171// capSparse narrows a flexible column that is blank on most rows to a
136172// third of the terminal, so a few long values do not push every other
137173// row's later columns to the right edge.
@@ -223,6 +259,8 @@ func (t *table) joinRow(r []cell, line []string, widths []int) string {
223259 s = t.term.paintState(s)
224260 case kindRef:
225261 s = t.term.paint(sgrCyan, s)
262 default:
263 s = t.term.paint(r[i].sgr, s)
226264 }
227265 }
228266 b.WriteString(s + padding)
internal/control/table_test.go +18
@@ -171,3 +171,21 @@ func TestTableSizeCells(t *testing.T) {
171171 t.Errorf("term = %q", term.String())
172172 }
173173}
174
175func TestTableDropsEmptyColumns(t *testing.T) {
176 var b bytes.Buffer
177 tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "#", "LABELS", "TITLE")
178 tb.row(cRef("#1"), cText(""), cFlex("one"))
179 tb.row(cRef("#2"), cText(""), cFlex("two"))
180 tb.flush()
181 if b.String() != "# TITLE\n#1 one\n#2 two\n" {
182 t.Errorf("terminal = %q", b.String())
183 }
184 var plain bytes.Buffer
185 tb = (&Ctx{}).table(&plain, "#", "LABELS", "TITLE")
186 tb.row(cRef("#1"), cText(""), cFlex("one"))
187 tb.flush()
188 if plain.String() != "#1\t\tone\n" {
189 t.Errorf("plain = %q", plain.String())
190 }
191}
internal/control/term.go +1 −1
@@ -102,7 +102,7 @@ func stateColor(s string) string {
102102 case "failed", "failure", "error", "changes requested", "private",
103103 "bad_signature", "signed_email_mismatch", "signed_key_expired", "signed_key_revoked":
104104 return sgrRed
105 case "closed", "draft", "pending", "canceled", "cancelled", "archived", "disabled",
105 case "closed", "draft", "pending", "canceled", "cancelled", "archived", "disabled", "skipped",
106106 "unsigned", "signed_unknown_key":
107107 return sgrDim
108108 case "unverified":
internal/store/issues.go +81
@@ -3,6 +3,7 @@ package store
33import (
44 "database/sql"
55 "errors"
6 "fmt"
67 "strings"
78)
89
@@ -22,6 +23,37 @@ type Issue struct {
2223 Assignees []string
2324}
2425
26// IssueCommentCounts counts, per issue, the comments people wrote —
27// system comments do not count — for every issue on a list page in one
28// query.
29func (s *Store) IssueCommentCounts(issueIDs []int64) (map[int64]int, error) {
30 out := map[int64]int{}
31 if len(issueIDs) == 0 {
32 return out, nil
33 }
34 args := make([]any, len(issueIDs))
35 for i, id := range issueIDs {
36 args[i] = id
37 }
38 rows, err := s.DB.Query(`
39 SELECT issue_id, COUNT(*) FROM issue_comments
40 WHERE kind <> 'system' AND issue_id IN (?`+strings.Repeat(",?", len(issueIDs)-1)+`)
41 GROUP BY issue_id`, args...)
42 if err != nil {
43 return nil, err
44 }
45 defer rows.Close()
46 for rows.Next() {
47 var id int64
48 var n int
49 if err := rows.Scan(&id, &n); err != nil {
50 return nil, err
51 }
52 out[id] = n
53 }
54 return out, rows.Err()
55}
56
2557type IssueComment struct {
2658 ID int64
2759 Author string
@@ -80,6 +112,55 @@ func (s *Store) IssueByNumber(repoID, number int64) (Issue, error) {
80112 return i, err
81113}
82114
115// IssueLabelsFor is the labels and assignees of every issue on a list
116// page, two queries for the page rather than two per issue.
117func (s *Store) IssueLabelsFor(issueIDs []int64) (labels, assignees map[int64][]string, err error) {
118 if labels, err = s.stringsFor(issueIDs, `
119 SELECT il.issue_id, l.name FROM issue_labels il JOIN labels l ON l.id = il.label_id
120 WHERE il.issue_id IN (%s) ORDER BY l.name`); err != nil {
121 return nil, nil, err
122 }
123 assignees, err = s.stringsFor(issueIDs, `
124 SELECT ia.issue_id, u.username FROM issue_assignees ia JOIN users u ON u.id = ia.user_id
125 WHERE ia.issue_id IN (%s) ORDER BY u.username`)
126 return labels, assignees, err
127}
128
129// MRReviewRequestsFor is ReviewRequests for every merge request on a
130// list page in one query.
131func (s *Store) MRReviewRequestsFor(mrIDs []int64) (map[int64][]string, error) {
132 return s.stringsFor(mrIDs, `
133 SELECT rr.mr_id, u.username FROM mr_review_requests rr JOIN users u ON u.id = rr.user_id
134 WHERE rr.mr_id IN (%s) ORDER BY u.username`)
135}
136
137// stringsFor runs a query selecting (id, value) rows, its %s replaced by
138// one placeholder per id, and groups the values by id in row order.
139func (s *Store) stringsFor(ids []int64, query string) (map[int64][]string, error) {
140 out := map[int64][]string{}
141 if len(ids) == 0 {
142 return out, nil
143 }
144 args := make([]any, len(ids))
145 for i, id := range ids {
146 args[i] = id
147 }
148 rows, err := s.DB.Query(fmt.Sprintf(query, "?"+strings.Repeat(",?", len(ids)-1)), args...)
149 if err != nil {
150 return nil, err
151 }
152 defer rows.Close()
153 for rows.Next() {
154 var id int64
155 var v string
156 if err := rows.Scan(&id, &v); err != nil {
157 return nil, err
158 }
159 out[id] = append(out[id], v)
160 }
161 return out, rows.Err()
162}
163
83164func (s *Store) issueStrings(issueID int64, query string) ([]string, error) {
84165 rows, err := s.DB.Query(query, issueID)
85166 if err != nil {
internal/store/mrs.go +32
@@ -604,6 +604,38 @@ func (s *Store) ListMRReviews(mrID int64) ([]MRReview, error) {
604604 return out, rows.Err()
605605}
606606
607// MRReviewsFor is ListMRReviews for every merge request on a list page
608// in one query, keyed by merge request id.
609func (s *Store) MRReviewsFor(mrIDs []int64) (map[int64][]MRReview, error) {
610 out := map[int64][]MRReview{}
611 if len(mrIDs) == 0 {
612 return out, nil
613 }
614 args := make([]any, len(mrIDs))
615 for i, id := range mrIDs {
616 args[i] = id
617 }
618 rows, err := s.DB.Query(`
619 SELECT r.mr_id, u.username, r.verdict, r.head_sha, r.stale, r.created_at
620 FROM mr_reviews r JOIN users u ON u.id = r.reviewer_id
621 WHERE r.mr_id IN (?`+strings.Repeat(",?", len(mrIDs)-1)+`) ORDER BY r.id`, args...)
622 if err != nil {
623 return nil, err
624 }
625 defer rows.Close()
626 for rows.Next() {
627 var id int64
628 var r MRReview
629 var stale int
630 if err := rows.Scan(&id, &r.Reviewer, &r.Verdict, &r.HeadSHA, &stale, &r.CreatedAt); err != nil {
631 return nil, err
632 }
633 r.Stale = stale != 0
634 out[id] = append(out[id], r)
635 }
636 return out, rows.Err()
637}
638
607639// PrimaryVerifiedEmail returns the user's primary email if verified, else "".
608640func (s *Store) PrimaryVerifiedEmail(userID int64) (string, error) {
609641 var addr string
internal/store/mrs_test.go +94
@@ -234,3 +234,97 @@ func TestMRCommentCounts(t *testing.T) {
234234 t.Fatalf("MRCommentCounts(nil) = %v, %v", empty, err)
235235 }
236236}
237
238// The list batches return what the per-item calls return, keyed by item.
239func TestListPageBatches(t *testing.T) {
240 s, repoID, uid := mrFixture(t)
241 mr1, err := s.MRByNumber(repoID, 1)
242 if err != nil {
243 t.Fatal(err)
244 }
245 if err := s.AddMRReview(mr1.ID, uid, "approve", "abc123"); err != nil {
246 t.Fatal(err)
247 }
248 reviews, err := s.MRReviewsFor([]int64{mr1.ID, mr1.ID + 99})
249 if err != nil {
250 t.Fatal(err)
251 }
252 if got := reviews[mr1.ID]; len(got) != 1 || got[0].Reviewer != "cmc" || got[0].Verdict != "approve" {
253 t.Errorf("reviews = %+v", got)
254 }
255 if len(reviews[mr1.ID+99]) != 0 {
256 t.Errorf("reviews for a missing MR: %+v", reviews[mr1.ID+99])
257 }
258
259 if err := s.SetCommitStatus(repoID, "abc123", "ci/test", "failure", "", "", uid); err != nil {
260 t.Fatal(err)
261 }
262 if err := s.SetCommitStatus(repoID, "abc123", "ci/build", "success", "", "", uid); err != nil {
263 t.Fatal(err)
264 }
265 statuses, err := s.CommitStatusesFor(repoID, []string{"abc123", "def456"})
266 if err != nil {
267 t.Fatal(err)
268 }
269 if got := statuses["abc123"]; len(got) != 2 || got[0].Context != "ci/build" || got[1].State != "failure" {
270 t.Errorf("statuses = %+v", got)
271 }
272 if len(statuses["def456"]) != 0 {
273 t.Errorf("statuses for a commit with none: %+v", statuses["def456"])
274 }
275
276 n, err := s.CreateIssue(repoID, uid, "t", "", "md")
277 if err != nil {
278 t.Fatal(err)
279 }
280 issue, err := s.IssueByNumber(repoID, n)
281 if err != nil {
282 t.Fatal(err)
283 }
284 if err := s.AddIssueComment(issue.ID, uid, "hi", "md"); err != nil {
285 t.Fatal(err)
286 }
287 if err := s.AddIssueSystemComment(issue.ID, uid, "closed"); err != nil {
288 t.Fatal(err)
289 }
290 counts, err := s.IssueCommentCounts([]int64{issue.ID})
291 if err != nil {
292 t.Fatal(err)
293 }
294 if counts[issue.ID] != 1 {
295 t.Errorf("comment count = %d, want 1", counts[issue.ID])
296 }
297}
298
299func TestLabelAndRequestBatches(t *testing.T) {
300 s, repoID, uid := mrFixture(t)
301 mr, err := s.MRByNumber(repoID, 1)
302 if err != nil {
303 t.Fatal(err)
304 }
305 if err := s.SetMRReviewRequest(mr.ID, uid, true); err != nil {
306 t.Fatal(err)
307 }
308 reqs, err := s.MRReviewRequestsFor([]int64{mr.ID})
309 if err != nil {
310 t.Fatal(err)
311 }
312 if got := reqs[mr.ID]; len(got) != 1 || got[0] != "cmc" {
313 t.Errorf("review requests = %v", got)
314 }
315 n, err := s.CreateIssue(repoID, uid, "t", "", "md")
316 if err != nil {
317 t.Fatal(err)
318 }
319 issue, err := s.IssueByNumber(repoID, n)
320 if err != nil {
321 t.Fatal(err)
322 }
323 labels, assignees, err := s.IssueLabelsFor([]int64{issue.ID})
324 if err != nil {
325 t.Fatal(err)
326 }
327 if len(labels[issue.ID]) != 0 || len(assignees[issue.ID]) != 0 {
328 t.Errorf("fresh issue: labels %v, assignees %v", labels[issue.ID], assignees[issue.ID])
329 }
330}
internal/store/statuses.go +31
@@ -53,6 +53,37 @@ func (s *Store) ListCommitStatuses(repoID int64, sha string) ([]CommitStatus, er
5353 return out, rows.Err()
5454}
5555
56// CommitStatusesFor is ListCommitStatuses for every commit on a list
57// page in one query, keyed by commit.
58func (s *Store) CommitStatusesFor(repoID int64, shas []string) (map[string][]CommitStatus, error) {
59 out := map[string][]CommitStatus{}
60 if len(shas) == 0 {
61 return out, nil
62 }
63 args := []any{repoID}
64 for _, sha := range shas {
65 args = append(args, sha)
66 }
67 rows, err := s.DB.Query(`
68 SELECT cs.commit_sha, cs.context, cs.state, cs.description, cs.target_url, COALESCE(u.username, ''), cs.updated_at
69 FROM commit_statuses cs LEFT JOIN users u ON u.id = cs.creator_id
70 WHERE cs.repo_id = ? AND cs.commit_sha IN (?`+strings.Repeat(",?", len(shas)-1)+`)
71 ORDER BY cs.context`, args...)
72 if err != nil {
73 return nil, err
74 }
75 defer rows.Close()
76 for rows.Next() {
77 var sha string
78 var c CommitStatus
79 if err := rows.Scan(&sha, &c.Context, &c.State, &c.Description, &c.TargetURL, &c.Creator, &c.UpdatedAt); err != nil {
80 return nil, err
81 }
82 out[sha] = append(out[sha], c)
83 }
84 return out, rows.Err()
85}
86
5687// RepoHasStatuses reports whether anything has ever reported a status in
5788// this repository. It is how require_checks tells a repository whose
5889// checks come from outside — `status set`, with no .gitbay/ci.yml — from