Commit e6b51c178e
Verified · cmc
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 | ||
| 128 | func buildToOut(b store.Build) BuildOut { | 139 | func 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 | ||
| 133 | func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) { | 146 | func 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 | ||
| 245 | func runBuildLog(c *Ctx, args []string) int { | 276 | func 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import "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. | ||
| 7 | type 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. | ||
| 18 | func 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. | ||
| 44 | func 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. | ||
| 63 | func 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. | ||
| 77 | func 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 15 | func 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. | ||
| 36 | func 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 | |||
| 44 | func 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. | ||
| 62 | func 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 | |||
| 86 | func 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 | |||
| 111 | func 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 | } | ||