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 {
244 } 244 }
245 d := buildToOut(b) 245 d := buildToOut(b)
246 json.Unmarshal([]byte(b.Steps), &d.Steps) 246 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) {
248 failedStep, failed := "", "" 254 failedStep, failed := "", ""
249 if d.FailedStep > 0 && d.FailedStep <= len(d.Steps) { 255 if d.FailedStep > 0 && d.FailedStep <= len(d.Steps) {
250 step, _, _ := strings.Cut(d.Steps[d.FailedStep-1], "\n") 256 step, _, _ := strings.Cut(d.Steps[d.FailedStep-1], "\n")
@@ -259,15 +265,6 @@ func runBuildShow(c *Ctx, args []string) int {
259 if d.DurationS > 0 { 265 if d.DurationS > 0 {
260 duration = (time.Duration(d.DurationS) * time.Second).String() 266 duration = (time.Duration(d.DurationS) * time.Second).String()
261 } 267 }
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 }
271 v := c.view(w) 268 v := c.view(w)
272 v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status) 269 v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status)
273 v.fields( 270 v.fields(
@@ -278,20 +275,44 @@ func runBuildShow(c *Ctx, args []string) int {
278 "duration", duration, 275 "duration", duration,
279 "failed step", failedStep, 276 "failed step", failedStep,
280 "failed", failed, 277 "failed", failed,
281 "mr", mr,
282 "log", logCmd,
283 "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)), 278 "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)),
284 ) 279 )
285 if c.Term.Cols > 0 && len(d.Steps) > 0 { 280 }, func() screen { return buildShowScreen(c, repo, d, mr) })
286 v.section("steps") 281}
287 tb := c.table(w, "#", "STEP", "STATE") 282
288 for i, step := range d.Steps { 283// buildShowScreen is build show at a terminal: the build's outcome, what
289 line, _, _ := strings.Cut(step, "\n") 284// it ran on, each step's outcome, and the command for its log.
290 tb.row(cNum(int64(i+1)), cFlex(line), cState(stepState(d.Status, d.FailedStep, i+1))) 285func buildShowScreen(c *Ctx, repo store.Repo, d BuildOut, mr *store.MR) screen {
291 } 286 n := strconv.FormatInt(d.Number, 10)
292 tb.flush() 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
293 } 310 }
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
295} 316}
296 317
297// stepState is what a finished build says about one of its steps: those 318// 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