Commit 0d53b9e08e

0d53b9e08e442c6cf35335856ddf2dc3a900939f

parent: 94f804ba1e

Verified · cmc

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

control: build show as a screen

Ref #318

Layout: unified · split

internal/control/build.go +42 −21
@@ -244,7 +244,13 @@ func runBuildShow(c *Ctx, args []string) int {
244244 }
245245 d := buildToOut(b)
246246 json.Unmarshal([]byte(b.Steps), &d.Steps)
247 return c.emit(d, func(w io.Writer) {
247 var mr *store.MR
248 if c.Term.Cols > 0 && !c.JSON {
249 if m, ok, err := c.Store.OpenMRBySource(repo.ID, d.Ref); err == nil && ok {
250 mr = &m
251 }
252 }
253 return c.emitView(d, func(w io.Writer) {
248254 failedStep, failed := "", ""
249255 if d.FailedStep > 0 && d.FailedStep <= len(d.Steps) {
250256 step, _, _ := strings.Cut(d.Steps[d.FailedStep-1], "\n")
@@ -259,15 +265,6 @@ func runBuildShow(c *Ctx, args []string) int {
259265 if d.DurationS > 0 {
260266 duration = (time.Duration(d.DurationS) * time.Second).String()
261267 }
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 }
271268 v := c.view(w)
272269 v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status)
273270 v.fields(
@@ -278,20 +275,44 @@ func runBuildShow(c *Ctx, args []string) int {
278275 "duration", duration,
279276 "failed step", failedStep,
280277 "failed", failed,
281 "mr", mr,
282 "log", logCmd,
283278 "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)),
284279 )
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()
280 }, func() screen { return buildShowScreen(c, repo, d, mr) })
281}
282
283// buildShowScreen is build show at a terminal: the build's outcome, what
284// it ran on, each step's outcome, and the command for its log.
285func buildShowScreen(c *Ctx, repo store.Repo, d BuildOut, mr *store.MR) screen {
286 n := strconv.FormatInt(d.Number, 10)
287 path := repo.Path()
288 var s screen
289 s.fields = append(s.fields,
290 field{"Build", []cell{cLink(n, c.siteURL(path, "builds", n)), cText(d.Job)}},
291 field{"State", []cell{cGlyph(d.Status), cState(d.Status)}},
292 field{"Commit", []cell{cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Subject)}},
293 field{"Ref", []cell{cText(d.Ref)}},
294 )
295 if mr != nil {
296 s.fields = append(s.fields, field{"MR", []cell{cRef(fmt.Sprintf("!%d", mr.Number)), cText(mr.Title)}})
297 }
298 if d.DurationS > 0 {
299 s.fields = append(s.fields, field{"Duration", []cell{cText(c.Term.dur(d.DurationS))}})
300 }
301 if !c.Term.Links {
302 s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "builds", n))}})
303 }
304 steps := section{title: "Steps", n: len(d.Steps)}
305 for i, step := range d.Steps {
306 line, _, _ := strings.Cut(step, "\n")
307 meta := ""
308 if i+1 == d.FailedStep {
309 meta = d.FailedReason
293310 }
294 })
311 steps.rows = append(steps.rows, rowOf(cGlyph(stepState(d.Status, d.FailedStep, i+1)), cFlex(line), cMeta(meta)))
312 }
313 s.sections = []section{steps}
314 s.actions = []action{{"Read", []string{"build", "log", path, n}}}
315 return s
295316}
296317
297318// stepState is what a finished build says about one of its steps: those
internal/control/buildscreen_test.go added +69
@@ -0,0 +1,69 @@
1package control
2
3import (
4 "strconv"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/protocol"
9 "gitbay.org/gitbay/internal/store"
10)
11
12func TestBuildShowPlainPinned(t *testing.T) {
13 st, repo, uid := newQueueTestRepo(t)
14 id, err := st.CreateBuild(repo.ID, "test", "a136534ff9a136534ff9a136534ff9a136534ff9", "main", `["go vet ./...","go test ./..."]`, "", "", true)
15 if err != nil {
16 t.Fatal(err)
17 }
18 if _, ok, err := st.ClaimBuild([]int64{repo.ID}, false); err != nil || !ok {
19 t.Fatalf("claim: %v %v", ok, err)
20 }
21 if err := st.SetBuildFailure(id, 2, "exit 1"); err != nil {
22 t.Fatal(err)
23 }
24 if err := st.FinishBuild(id, "failure"); err != nil {
25 t.Fatal(err)
26 }
27 b, err := st.BuildByID(id)
28 if err != nil {
29 t.Fatal(err)
30 }
31 c, out, errOut := mrTestCtx(st, store.User{ID: uid, Username: "alice"})
32 if code := Dispatch(c, []string{"build", "show", repo.Path(), strconv.FormatInt(b.Number, 10)}); code != protocol.ExitOK {
33 t.Fatalf("exit %d: %s", code, errOut)
34 }
35 pinPlain(t, "build-show", out.String())
36}
37
38func TestBuildShowScreenFailed(t *testing.T) {
39 c := screenCtx(100, false)
40 d := BuildOut{Number: 83, Job: "daily-pull", Status: "failure", SHA: "355b04066b", Ref: "main",
41 Steps: []string{"setup", "pull", "publish"}, FailedStep: 2, FailedReason: "exit 1", DurationS: 74}
42 s := buildShowScreen(c, store.Repo{OwnerName: "krz", Name: "omaha-metro-blotter"}, d, nil)
43 steps := s.sections[0]
44 if steps.title != "Steps" || steps.n != 3 {
45 t.Fatalf("steps = %+v", steps)
46 }
47 for i, want := range []string{"✓", "✗", "○"} {
48 if g := steps.rows[i].cells[0]; g.s != want {
49 t.Errorf("step %d glyph = %q, want %q", i+1, g.s, want)
50 }
51 }
52 if len(s.actions) != 1 || s.actions[0].argv[1] != "log" {
53 t.Errorf("actions = %+v", s.actions)
54 }
55 out := renderString(c, s)
56 if !strings.Contains(out, "✗ pull exit 1\n") {
57 t.Errorf("failed step line:\n%s", out)
58 }
59 checkActions(t, s)
60}
61
62func TestBuildShowScreenNamesItsMR(t *testing.T) {
63 c := screenCtx(100, false)
64 d := BuildOut{Number: 1781, Job: "test", Status: "running", SHA: "ee77220399", Ref: "cli-views-1"}
65 out := renderString(c, buildShowScreen(c, store.Repo{OwnerName: "krz", Name: "gitbay"}, d, &store.MR{Number: 552, Title: "screen renderer"}))
66 if !strings.Contains(out, "MR: !552 screen renderer\n") || !strings.Contains(out, "State: ◐ running\n") {
67 t.Errorf("render:\n%s", out)
68 }
69}
internal/control/testdata/plain/build-show.txt added +8
@@ -0,0 +1,8 @@
1#1 test failure
2
3 sha a136534ff9
4 ref main
5 queued <time>
6 finished <time>
7 failed step 2/2 go test ./... (exit 1)
8 url /alice/app/builds/1