Commit e6b51c178e

e6b51c178e7a050a64363803b59f3f218164322d

parent: 286e0c3891

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 07:56 UTC

build show: failed step and duration; build log --step, --tail

Ref #266

Layout: unified · split

CHANGELOG.org +3
@@ -142,6 +142,9 @@ for the eighteen commands whose CLI path differs from the registry's
142142 =deploy/runner-podman-setup.sh= (it installs nftables) before =make
143143 deploy-runner=. Deploy gitbayd, then the runner, after validating on
144144 a scratch repository per the CI page. (#260)
145- =build show= names a failed build's step and duration; =build log
146 --step <n>|failed --tail <lines>= reads one step's output or the
147 last lines of the stored log. (#266)
145148
146149* v1.36.0 — 2026-09-23
147150
internal/control/build.go +72 −4
@@ -40,11 +40,13 @@ func init() {
4040 ReadOnly: true, Run: runBuildShow})
4141 register(Command{Path: []string{"build", "log"},
4242 Summary: "print a build's log, or follow it until the build ends",
43 Usage: "build log <owner/name> <n> [--follow]",
43 Usage: "build log <owner/name> <n> [--follow] [--step <step>|failed] [--tail <lines>]",
4444 Flags: []Flag{
4545 {"--follow", "", "stream the log until the build ends", ""},
46 {"--step", "<step>|failed", "only one step's output: 0 for the setup, a step number, or the one that failed", ""},
47 {"--tail", "<lines>", "only the last lines", ""},
4648 },
47 Examples: []string{"build log krz/gitbay 431 --follow"},
49 Examples: []string{"build log krz/gitbay 431 --follow", "build log krz/gitbay 431 --step failed --tail 40"},
4850 ReadOnly: true, Run: runBuildLog})
4951
5052 register(Command{Path: []string{"build", "jobs"},
@@ -123,11 +125,22 @@ type BuildOut struct {
123125 // names what it ran on rather than only its sha (#241). It is empty
124126 // when the commit is no longer in the repository.
125127 Subject string `json:"subject,omitempty"`
128 // FailedStep is the 1-based step a failed build stopped at, 0 when
129 // none; FailedReason says how ("exit 1") (#266).
130 FailedStep int `json:"failed_step,omitempty"`
131 FailedReason string `json:"failed_reason,omitempty"`
132 // DurationS is how long the build ran, once it has a start and a
133 // finish.
134 DurationS int64 `json:"duration_s,omitempty"`
135 // Steps are the job's commands; build show only.
136 Steps []string `json:"steps,omitempty"`
126137}
127138
128139func buildToOut(b store.Build) BuildOut {
129140 return BuildOut{Number: b.Number, Job: b.Job, Status: b.Status, SHA: b.SHA,
130 Ref: b.Ref, CreatedAt: b.CreatedAt, FinishedAt: b.FinishedAt}
141 Ref: b.Ref, CreatedAt: b.CreatedAt, FinishedAt: b.FinishedAt,
142 FailedStep: b.FailedStep, FailedReason: b.FailedReason,
143 DurationS: int64(b.Elapsed() / time.Second)}
131144}
132145
133146func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) {
@@ -229,7 +242,22 @@ func runBuildShow(c *Ctx, args []string) int {
229242 return code
230243 }
231244 d := buildToOut(b)
245 json.Unmarshal([]byte(b.Steps), &d.Steps)
232246 return c.emit(d, func(w io.Writer) {
247 failedStep, failed := "", ""
248 if d.FailedStep > 0 && d.FailedStep <= len(d.Steps) {
249 step, _, _ := strings.Cut(d.Steps[d.FailedStep-1], "\n")
250 failedStep = fmt.Sprintf("%d/%d %s", d.FailedStep, len(d.Steps), step)
251 if d.FailedReason != "" {
252 failedStep += " (" + d.FailedReason + ")"
253 }
254 } else {
255 failed = d.FailedReason
256 }
257 duration := ""
258 if d.DurationS > 0 {
259 duration = (time.Duration(d.DurationS) * time.Second).String()
260 }
233261 v := c.view(w)
234262 v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status)
235263 v.fields(
@@ -237,13 +265,16 @@ func runBuildShow(c *Ctx, args []string) int {
237265 "ref", d.Ref,
238266 "queued", c.when(d.CreatedAt),
239267 "finished", c.when(d.FinishedAt),
268 "duration", duration,
269 "failed step", failedStep,
270 "failed", failed,
240271 "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)),
241272 )
242273 })
243274}
244275
245276func runBuildLog(c *Ctx, args []string) int {
246 f, err := c.parseArgs(args, flagSpec{Bools: []string{"--follow"}, MaxPos: 2, Usage: c.Cmd.Usage})
277 f, err := c.parseArgs(args, flagSpec{Bools: []string{"--follow"}, Values: []string{"--step", "--tail"}, MaxPos: 2, Usage: c.Cmd.Usage})
247278 if err != nil {
248279 return c.fail(protocol.ExitUsage, "%v", err)
249280 }
@@ -252,12 +283,49 @@ func runBuildLog(c *Ctx, args []string) int {
252283 return code
253284 }
254285 if f.Has("--follow") {
286 if f.Has("--step") || f.Has("--tail") {
287 return c.fail(protocol.ExitUsage, "--step and --tail read the stored log; drop --follow")
288 }
255289 return followBuildLog(c, repo, b)
256290 }
291 tail := 0
292 if f.Has("--tail") {
293 if tail, err = strconv.Atoi(f.Value("--tail")); err != nil || tail < 1 {
294 return c.fail(protocol.ExitUsage, "--tail takes a number of lines, 1 or more")
295 }
296 }
257297 log, err := c.Store.BuildLog(b.ID)
258298 if err != nil {
259299 return c.fail(protocol.ExitFailure, "%v", err)
260300 }
301 if f.Has("--step") {
302 var steps []string
303 json.Unmarshal([]byte(b.Steps), &steps)
304 sections := SplitBuildLog(string(log), steps)
305 at := -1
306 if want := f.Value("--step"); want == "failed" {
307 if at = FailedSection(sections, b.Status, b.FailedStep); at < 0 {
308 return c.fail(protocol.ExitNotFound, "build %d did not fail", b.Number)
309 }
310 } else {
311 n, err := strconv.Atoi(want)
312 if err != nil || n < 0 || n > len(steps) {
313 return c.fail(protocol.ExitUsage, "--step takes 0 (the setup) to %d, or failed", len(steps))
314 }
315 for i, s := range sections {
316 if s.N == n {
317 at = i
318 }
319 }
320 if at < 0 {
321 return c.fail(protocol.ExitNotFound, "build %d has no output for step %d", b.Number, n)
322 }
323 }
324 log = []byte(sections[at].Text)
325 }
326 if tail > 0 {
327 log = tailLines(log, tail)
328 }
261329 c.Stdout.Write(log)
262330 return protocol.ExitOK
263331}
internal/control/buildlog.go added +91
@@ -0,0 +1,91 @@
1package control
2
3import "strings"
4
5// LogSection is one part of a build log: the setup before the first
6// step (N 0), or one step and its output.
7type LogSection struct {
8 N int // 0 for the setup, else the 1-based step
9 Step string // the step's command; "" for the setup
10 Text string
11}
12
13// SplitBuildLog cuts a log at the "$ <step>" line the runner writes
14// before each step, matching the build's steps in order and only at a
15// line start. Output before the first step is the setup section, left
16// out when empty. A step with no line in the log — the build stopped
17// before it — has no section, and neither has any step after it.
18func SplitBuildLog(log string, steps []string) []LogSection {
19 var out []LogSection
20 cur := LogSection{}
21 start := 0
22 for i, step := range steps {
23 marker := "$ " + step + "\n"
24 at := findLine(log, marker, start)
25 if at < 0 {
26 break
27 }
28 cur.Text = log[start:at]
29 if cur.N > 0 || cur.Text != "" {
30 out = append(out, cur)
31 }
32 cur = LogSection{N: i + 1, Step: step}
33 start = at + len(marker)
34 }
35 cur.Text = log[start:]
36 if cur.N > 0 || cur.Text != "" {
37 out = append(out, cur)
38 }
39 return out
40}
41
42// findLine is the index of line in log at or after from where it starts
43// a line, or -1.
44func findLine(log, line string, from int) int {
45 for i := from; i <= len(log)-len(line); {
46 j := strings.Index(log[i:], line)
47 if j < 0 {
48 return -1
49 }
50 at := i + j
51 if at == 0 || log[at-1] == '\n' {
52 return at
53 }
54 i = at + 1
55 }
56 return -1
57}
58
59// FailedSection is the index of the section a failed build stopped in:
60// the step the runner named, or the last section when it named none (an
61// older runner, or a failure the runner could not tie to a step). -1
62// when the build did not fail or its log is empty.
63func FailedSection(sections []LogSection, status string, failedStep int) int {
64 if status != "failure" || len(sections) == 0 {
65 return -1
66 }
67 for i, s := range sections {
68 if failedStep > 0 && s.N == failedStep {
69 return i
70 }
71 }
72 return len(sections) - 1
73}
74
75// tailLines is the last n lines of b; a final newline ends the last line
76// rather than starting another.
77func tailLines(b []byte, n int) []byte {
78 end := len(b)
79 if end > 0 && b[end-1] == '\n' {
80 end--
81 }
82 for i := end - 1; i >= 0; i-- {
83 if b[i] == '\n' {
84 n--
85 if n == 0 {
86 return b[i+1:]
87 }
88 }
89 }
90 return b
91}
internal/control/buildlog_test.go added +131
@@ -0,0 +1,131 @@
1package control
2
3import (
4 "bytes"
5 "fmt"
6 "reflect"
7 "regexp"
8 "strings"
9 "testing"
10
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15func TestSplitBuildLog(t *testing.T) {
16 log := "$ git clone ssh://x/a.git (abc)\n" +
17 "$ go build ./...\n" +
18 "built\n" +
19 "$ go test ./...\n" +
20 "--- FAIL: TestX\n" +
21 "step 2/2 failed: exit 1\n"
22 got := SplitBuildLog(log, []string{"go build ./...", "go test ./..."})
23 want := []LogSection{
24 {N: 0, Text: "$ git clone ssh://x/a.git (abc)\n"},
25 {N: 1, Step: "go build ./...", Text: "built\n"},
26 {N: 2, Step: "go test ./...", Text: "--- FAIL: TestX\nstep 2/2 failed: exit 1\n"},
27 }
28 if !reflect.DeepEqual(got, want) {
29 t.Fatalf("got %+v\nwant %+v", got, want)
30 }
31}
32
33// A step's line inside other output, not at a line start, does not cut;
34// a build that stopped before a step has no section for it; an empty
35// setup is left out.
36func TestSplitBuildLogStopsAtMissingStep(t *testing.T) {
37 got := SplitBuildLog("$ make\nrunning: $ make test\nerror\n", []string{"make", "make test"})
38 want := []LogSection{{N: 1, Step: "make", Text: "running: $ make test\nerror\n"}}
39 if !reflect.DeepEqual(got, want) {
40 t.Fatalf("got %+v\nwant %+v", got, want)
41 }
42}
43
44func TestTailLines(t *testing.T) {
45 for _, tc := range []struct {
46 in string
47 n int
48 want string
49 }{
50 {"a\nb\nc\n", 2, "b\nc\n"},
51 {"a\nb\nc\n", 5, "a\nb\nc\n"},
52 {"a\nb", 1, "b"},
53 } {
54 if got := string(tailLines([]byte(tc.in), tc.n)); got != tc.want {
55 t.Errorf("tailLines(%q, %d) = %q, want %q", tc.in, tc.n, got, tc.want)
56 }
57 }
58}
59
60// failedBuild is a finished failure whose second of two steps failed,
61// having run 10m56s.
62func failedBuild(t *testing.T) (*store.Store, store.Repo, int64, int64) {
63 t.Helper()
64 st, repo, uid := newQueueTestRepo(t)
65 n, err := st.CreateBuild(repo.ID, "unit", "abc", "main", `["go build ./...","go test ./..."]`, "", "", true)
66 if err != nil {
67 t.Fatal(err)
68 }
69 b, ok, err := st.ClaimBuild([]int64{repo.ID}, false)
70 if err != nil || !ok {
71 t.Fatalf("claim: ok=%v err=%v", ok, err)
72 }
73 st.AppendBuildLog(b.ID, []byte("$ git clone x (abc)\n$ go build ./...\nok\n$ go test ./...\none\n--- FAIL: TestX\nstep 2/2 failed: exit 1\n"))
74 if err := st.SetBuildFailure(b.ID, 2, "exit 1"); err != nil {
75 t.Fatal(err)
76 }
77 if err := st.FinishBuild(b.ID, "failure"); err != nil {
78 t.Fatal(err)
79 }
80 if _, err := st.DB.Exec(`UPDATE builds SET started_at = '2026-09-27T10:00:00Z', finished_at = '2026-09-27T10:10:56Z' WHERE id = ?`, b.ID); err != nil {
81 t.Fatal(err)
82 }
83 return st, repo, uid, n
84}
85
86func TestBuildLogStepAndTail(t *testing.T) {
87 st, repo, uid, n := failedBuild(t)
88 run := func(args ...string) (string, int) {
89 t.Helper()
90 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
91 code := Dispatch(c, append([]string{"build", "log", repo.Path(), fmt.Sprint(n)}, args...))
92 return c.Stdout.(*bytes.Buffer).String() + errOut.String(), code
93 }
94 if out, _ := run("--step", "1"); out != "ok\n" {
95 t.Errorf("--step 1: %q", out)
96 }
97 if out, _ := run("--step", "failed", "--tail", "2"); out != "--- FAIL: TestX\nstep 2/2 failed: exit 1\n" {
98 t.Errorf("--step failed --tail 2: %q", out)
99 }
100 if out, _ := run("--tail", "1"); out != "step 2/2 failed: exit 1\n" {
101 t.Errorf("--tail 1: %q", out)
102 }
103 if _, code := run("--step", "3"); code != protocol.ExitUsage {
104 t.Errorf("--step past the job: exit %d", code)
105 }
106 if _, code := run("--follow", "--tail", "1"); code != protocol.ExitUsage {
107 t.Errorf("--follow with --tail: exit %d", code)
108 }
109}
110
111func TestBuildShowNamesFailedStepAndDuration(t *testing.T) {
112 st, repo, uid, n := failedBuild(t)
113 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
114 if code := Dispatch(c, []string{"build", "show", repo.Path(), fmt.Sprint(n)}); code != protocol.ExitOK {
115 t.Fatalf("exit %d: %s", code, errOut)
116 }
117 out := c.Stdout.(*bytes.Buffer).String()
118 for _, re := range []string{`failed step\s+2/2 go test \./\.\.\. \(exit 1\)`, `duration\s+10m56s`} {
119 if !regexp.MustCompile(re).MatchString(out) {
120 t.Errorf("build show missing %s:\n%s", re, out)
121 }
122 }
123 c, _ = pruneCtx(st, t.TempDir(), store.User{ID: uid})
124 c.JSON = true
125 Dispatch(c, []string{"build", "show", repo.Path(), fmt.Sprint(n)})
126 for _, want := range []string{`"failed_step":2`, `"failed_reason":"exit 1"`, `"duration_s":656`, `"steps":["go build ./...","go test ./..."]`} {
127 if !strings.Contains(c.Stdout.(*bytes.Buffer).String(), want) {
128 t.Errorf("build show --json missing %s", want)
129 }
130 }
131}