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