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
142 =deploy/runner-podman-setup.sh= (it installs nftables) before =make 142 =deploy/runner-podman-setup.sh= (it installs nftables) before =make
143 deploy-runner=. Deploy gitbayd, then the runner, after validating on 143 deploy-runner=. Deploy gitbayd, then the runner, after validating on
144 a scratch repository per the CI page. (#260) 144 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)
145 148
146* v1.36.0 — 2026-09-23 149* v1.36.0 — 2026-09-23
147 150
internal/control/build.go +72 −4
@@ -40,11 +40,13 @@ func init() {
40 ReadOnly: true, Run: runBuildShow}) 40 ReadOnly: true, Run: runBuildShow})
41 register(Command{Path: []string{"build", "log"}, 41 register(Command{Path: []string{"build", "log"},
42 Summary: "print a build's log, or follow it until the build ends", 42 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>]",
44 Flags: []Flag{ 44 Flags: []Flag{
45 {"--follow", "", "stream the log until the build ends", ""}, 45 {"--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", ""},
46 }, 48 },
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"},
48 ReadOnly: true, Run: runBuildLog}) 50 ReadOnly: true, Run: runBuildLog})
49 51
50 register(Command{Path: []string{"build", "jobs"}, 52 register(Command{Path: []string{"build", "jobs"},
@@ -123,11 +125,22 @@ type BuildOut struct {
123 // names what it ran on rather than only its sha (#241). It is empty 125 // names what it ran on rather than only its sha (#241). It is empty
124 // when the commit is no longer in the repository. 126 // when the commit is no longer in the repository.
125 Subject string `json:"subject,omitempty"` 127 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"`
126} 137}
127 138
128func buildToOut(b store.Build) BuildOut { 139func buildToOut(b store.Build) BuildOut {
129 return BuildOut{Number: b.Number, Job: b.Job, Status: b.Status, SHA: b.SHA, 140 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)}
131} 144}
132 145
133func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) { 146func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) {
@@ -229,7 +242,22 @@ func runBuildShow(c *Ctx, args []string) int {
229 return code 242 return code
230 } 243 }
231 d := buildToOut(b) 244 d := buildToOut(b)
245 json.Unmarshal([]byte(b.Steps), &d.Steps)
232 return c.emit(d, func(w io.Writer) { 246 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 }
233 v := c.view(w) 261 v := c.view(w)
234 v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status) 262 v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status)
235 v.fields( 263 v.fields(
@@ -237,13 +265,16 @@ func runBuildShow(c *Ctx, args []string) int {
237 "ref", d.Ref, 265 "ref", d.Ref,
238 "queued", c.when(d.CreatedAt), 266 "queued", c.when(d.CreatedAt),
239 "finished", c.when(d.FinishedAt), 267 "finished", c.when(d.FinishedAt),
268 "duration", duration,
269 "failed step", failedStep,
270 "failed", failed,
240 "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)), 271 "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)),
241 ) 272 )
242 }) 273 })
243} 274}
244 275
245func runBuildLog(c *Ctx, args []string) int { 276func 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})
247 if err != nil { 278 if err != nil {
248 return c.fail(protocol.ExitUsage, "%v", err) 279 return c.fail(protocol.ExitUsage, "%v", err)
249 } 280 }
@@ -252,12 +283,49 @@ func runBuildLog(c *Ctx, args []string) int {
252 return code 283 return code
253 } 284 }
254 if f.Has("--follow") { 285 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 }
255 return followBuildLog(c, repo, b) 289 return followBuildLog(c, repo, b)
256 } 290 }
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 }
257 log, err := c.Store.BuildLog(b.ID) 297 log, err := c.Store.BuildLog(b.ID)
258 if err != nil { 298 if err != nil {
259 return c.fail(protocol.ExitFailure, "%v", err) 299 return c.fail(protocol.ExitFailure, "%v", err)
260 } 300 }
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 }
261 c.Stdout.Write(log) 329 c.Stdout.Write(log)
262 return protocol.ExitOK 330 return protocol.ExitOK
263} 331}
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}