internal/control/build.go
597 lines · 20751 bytes
1package control
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "regexp"
10 "strconv"
11 "strings"
12 "time"
13
14 "gitbay.org/gitbay/internal/ci"
15 "gitbay.org/gitbay/internal/gitutil"
16 "gitbay.org/gitbay/internal/policy"
17 "gitbay.org/gitbay/internal/protocol"
18 "gitbay.org/gitbay/internal/store"
19)
20
21func init() {
22 register(Command{Path: []string{"build", "list"},
23 Summary: "list recent builds",
24 Usage: "build list <owner/name>", ReadOnly: true, Run: runBuildList})
25 register(Command{Path: []string{"build", "show"},
26 Summary: "show one build",
27 Usage: "build show <owner/name> <n>", ReadOnly: true, Run: runBuildShow})
28 register(Command{Path: []string{"build", "log"},
29 Summary: "print a build's log",
30 Usage: "build log <owner/name> <n>", ReadOnly: true, Run: runBuildLog})
31
32 register(Command{Path: []string{"build", "jobs"},
33 Summary: "list the jobs a trigger can name",
34 Usage: "build jobs <owner/name>", ReadOnly: true, Run: runBuildJobs})
35
36 register(Command{Path: []string{"build", "cancel"},
37 Summary: "withdraw a queued build before a runner claims it",
38 Usage: "build cancel <owner/name> <n>", Run: runBuildCancel})
39 register(Command{Path: []string{"build", "trigger"},
40 Summary: "queue a job now (scheduled or not)",
41 Usage: "build trigger <owner/name> <job>", Run: runBuildTrigger})
42 // Secrets: set over stdin, listed by name only, injected into the
43 // repo's builds as environment variables. Same discipline as mirror
44 // tokens — the value never appears in argv, logs, or output.
45 register(Command{Path: []string{"repo", "secret", "set"},
46 Summary: "set a build secret",
47 Usage: "repo secret set <owner/name> <NAME> (value on stdin)",
48 ReadsStdin: true, SSHOnly: true, Run: runSecretSet})
49 register(Command{Path: []string{"repo", "secret", "remove"},
50 Summary: "remove a build secret",
51 Usage: "repo secret remove <owner/name> <NAME>", Run: runSecretRemove})
52 register(Command{Path: []string{"repo", "secret", "list"},
53 Summary: "list build secret names",
54 Usage: "repo secret list <owner/name>", ReadOnly: true, Run: runSecretList})
55
56 // Runner commands: the claim/report loop for gitbay-runner. Admin-only —
57 // a runner executes arbitrary repo code, so handing out jobs is the
58 // instance operator's call.
59 register(Command{Path: []string{"runner", "next"},
60 Summary: "claim the oldest pending build (runner protocol)",
61 Usage: "runner next [<owner/name>...]", SSHOnly: true, Run: runRunnerNext})
62 register(Command{Path: []string{"runner", "log"},
63 Summary: "append a build's log from stdin",
64 Usage: "runner log <build-id>", SSHOnly: true, ReadsStdin: true, Run: runRunnerLog})
65 register(Command{Path: []string{"runner", "done"},
66 Summary: "finish a build",
67 Usage: "runner done <build-id> success|failure", SSHOnly: true, Run: runRunnerDone})
68}
69
70type buildOut struct {
71 Number int64 `json:"number"`
72 Job string `json:"job"`
73 Status string `json:"status"`
74 SHA string `json:"sha"`
75 Ref string `json:"ref"`
76 CreatedAt string `json:"created_at"`
77 FinishedAt string `json:"finished_at,omitempty"`
78}
79
80func buildToOut(b store.Build) buildOut {
81 return buildOut{b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt}
82}
83
84func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) {
85 if len(args) != 2 {
86 return store.Repo{}, store.Build{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>")
87 }
88 repo, code := resolveRepo(c, args[0], policy.CanRead)
89 if code >= 0 {
90 return repo, store.Build{}, code
91 }
92 n, err := strconv.ParseInt(args[1], 10, 64)
93 if err != nil {
94 return repo, store.Build{}, c.fail(protocol.ExitUsage, "bad build number %q", args[1])
95 }
96 b, err := c.Store.BuildByNumber(repo.ID, n)
97 if err != nil {
98 return repo, b, c.fail(protocol.ExitNotFound, "no build %d on %s", n, repo.Path())
99 }
100 return repo, b, -1
101}
102
103func runBuildList(c *Ctx, args []string) int {
104 if len(args) != 1 {
105 return c.fail(protocol.ExitUsage, "usage: build list <owner/name>")
106 }
107 repo, code := resolveRepo(c, args[0], policy.CanRead)
108 if code >= 0 {
109 return code
110 }
111 builds, err := c.Store.ListBuilds(repo.ID, 50)
112 if err != nil {
113 return c.fail(protocol.ExitFailure, "%v", err)
114 }
115 var ds []buildOut
116 for _, b := range builds {
117 ds = append(ds, buildToOut(b))
118 }
119 return c.emit(ds, func(w io.Writer) {
120 for _, d := range ds {
121 fmt.Fprintf(w, "%d\t%s\t%s\t%.10s\t%s\n", d.Number, d.Job, d.Status, d.SHA, d.Ref)
122 }
123 })
124}
125
126func runBuildShow(c *Ctx, args []string) int {
127 _, b, code := buildRef(c, args)
128 if code >= 0 {
129 return code
130 }
131 d := buildToOut(b)
132 return c.emit(d, func(w io.Writer) {
133 fmt.Fprintf(w, "build %d\t%s\t%s\n%.10s on %s\nqueued %s", d.Number, d.Job, d.Status, d.SHA, d.Ref, d.CreatedAt)
134 if d.FinishedAt != "" {
135 fmt.Fprintf(w, ", finished %s", d.FinishedAt)
136 }
137 fmt.Fprintln(w)
138 })
139}
140
141func runBuildLog(c *Ctx, args []string) int {
142 _, b, code := buildRef(c, args)
143 if code >= 0 {
144 return code
145 }
146 log, err := c.Store.BuildLog(b.ID)
147 if err != nil {
148 return c.fail(protocol.ExitFailure, "%v", err)
149 }
150 c.Stdout.Write(log)
151 return protocol.ExitOK
152}
153
154type jobOut struct {
155 Name string `json:"name"`
156 Schedule string `json:"schedule,omitempty"`
157 Tags string `json:"tags,omitempty"`
158}
159
160// repoJobs reads the CI config on the default branch — the same file the
161// scheduler reads — and returns its jobs with the sha they came from.
162func repoJobs(c *Ctx, repo store.Repo) ([]ci.Job, string, int) {
163 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
164 sha, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch)
165 if err != nil {
166 return nil, "", c.fail(protocol.ExitFailure, "resolving %s: %v", repo.DefaultBranch, err)
167 }
168 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
169 if err != nil {
170 return nil, "", c.fail(protocol.ExitNotFound, "%s has no %s on %s", repo.Path(), ci.ConfigPath, repo.DefaultBranch)
171 }
172 jobs, err := ci.Parse(raw)
173 if err != nil {
174 return nil, "", c.fail(protocol.ExitUsage, "%v", err)
175 }
176 return jobs, sha, -1
177}
178
179// runBuildJobs answers "what can I trigger?". Without it only a surface
180// that can read the repository's git could offer the choice.
181func runBuildJobs(c *Ctx, args []string) int {
182 if len(args) != 1 {
183 return c.fail(protocol.ExitUsage, "usage: build jobs <owner/name>")
184 }
185 repo, code := resolveRepo(c, args[0], policy.CanRead)
186 if code >= 0 {
187 return code
188 }
189 jobs, _, code := repoJobs(c, repo)
190 if code >= 0 {
191 return code
192 }
193 out := make([]jobOut, 0, len(jobs))
194 for _, j := range jobs {
195 out = append(out, jobOut{Name: j.Name, Schedule: j.Schedule, Tags: j.Tags})
196 }
197 return c.emit(out, func(w io.Writer) {
198 for _, j := range out {
199 switch {
200 case j.Schedule != "":
201 fmt.Fprintf(w, "%s\tschedule %s\n", j.Name, j.Schedule)
202 case j.Tags != "":
203 fmt.Fprintf(w, "%s\ttags %s\n", j.Name, j.Tags)
204 default:
205 fmt.Fprintf(w, "%s\ton push\n", j.Name)
206 }
207 }
208 })
209}
210
211func runBuildTrigger(c *Ctx, args []string) int {
212 if len(args) != 2 {
213 return c.fail(protocol.ExitUsage, "usage: build trigger <owner/name> <job>")
214 }
215 repo, code := resolveRepo(c, args[0], policy.CanWrite)
216 if code >= 0 {
217 return code
218 }
219 jobs, sha, code := repoJobs(c, repo)
220 if code >= 0 {
221 return code
222 }
223 for _, j := range jobs {
224 if j.Name != args[1] {
225 continue
226 }
227 steps, _ := json.Marshal(j.Steps)
228 n, err := c.Store.CreateBuild(repo.ID, j.Name, sha, repo.DefaultBranch, string(steps))
229 if err != nil {
230 return c.fail(protocol.ExitFailure, "%v", err)
231 }
232 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), n)
233 c.Store.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "triggered", url, c.User.ID)
234 return c.emit(map[string]any{"build": n, "job": j.Name, "sha": sha}, func(w io.Writer) {
235 fmt.Fprintf(w, "queued build %d (%s @ %.10s)\n", n, j.Name, sha)
236 })
237 }
238 return c.fail(protocol.ExitNotFound, "no job %q in %s", args[1], ci.ConfigPath)
239}
240
241// secretName is env-var shaped: the value lands in the build environment.
242var secretName = regexp.MustCompile(`^[A-Z_][A-Z0-9_]{0,63}$`)
243
244func runSecretSet(c *Ctx, args []string) int {
245 if len(args) != 2 {
246 return c.fail(protocol.ExitUsage, "usage: repo secret set <owner/name> <NAME> (value on stdin)")
247 }
248 if !secretName.MatchString(args[1]) {
249 return c.fail(protocol.ExitUsage, "secret names are env-var shaped: uppercase letters, digits, _")
250 }
251 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
252 if code >= 0 {
253 return code
254 }
255 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
256 if err != nil {
257 return c.fail(protocol.ExitFailure, "reading secret: %v", err)
258 }
259 value := strings.TrimRight(string(raw), "\n")
260 if value == "" {
261 return c.fail(protocol.ExitUsage, "no value on stdin (pipe it: printf %%s TOKEN | ...)")
262 }
263 if err := c.Store.SetBuildSecret(repo.ID, args[1], value); err != nil {
264 return c.fail(protocol.ExitFailure, "%v", err)
265 }
266 return c.emit(map[string]string{"secret": args[1]}, func(w io.Writer) {
267 fmt.Fprintf(w, "secret %s set on %s\n", args[1], repo.Path())
268 })
269}
270
271func runSecretRemove(c *Ctx, args []string) int {
272 if len(args) != 2 {
273 return c.fail(protocol.ExitUsage, "usage: repo secret remove <owner/name> <NAME>")
274 }
275 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
276 if code >= 0 {
277 return code
278 }
279 if err := c.Store.RemoveBuildSecret(repo.ID, args[1]); err != nil {
280 if errors.Is(err, store.ErrNotFound) {
281 return c.fail(protocol.ExitNotFound, "no secret %s on %s", args[1], repo.Path())
282 }
283 return c.fail(protocol.ExitFailure, "%v", err)
284 }
285 return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
286 fmt.Fprintf(w, "removed %s\n", args[1])
287 })
288}
289
290func runSecretList(c *Ctx, args []string) int {
291 if len(args) != 1 {
292 return c.fail(protocol.ExitUsage, "usage: repo secret list <owner/name>")
293 }
294 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
295 if code >= 0 {
296 return code
297 }
298 names, err := c.Store.ListBuildSecretNames(repo.ID)
299 if err != nil {
300 return c.fail(protocol.ExitFailure, "%v", err)
301 }
302 return c.emit(names, func(w io.Writer) {
303 for _, n := range names {
304 fmt.Fprintln(w, n)
305 }
306 })
307}
308
309func requireRunner(c *Ctx) int {
310 if !c.User.IsAdmin {
311 return c.fail(protocol.ExitDenied, "runner commands are for instance-admin runner accounts")
312 }
313 return -1
314}
315
316func runRunnerNext(c *Ctx, args []string) int {
317 if code := requireRunner(c); code >= 0 {
318 return code
319 }
320 // A runner may limit itself to named repositories. The operator chooses
321 // what a given runner executes by how they start it; this is scoping the
322 // runner asks for, not an ACL the server holds over it.
323 var repoIDs []int64
324 for _, arg := range args {
325 repo, code := resolveRepo(c, arg, policy.CanRead)
326 if code >= 0 {
327 return code
328 }
329 repoIDs = append(repoIDs, repo.ID)
330 }
331 b, ok, err := c.Store.ClaimBuild(repoIDs)
332 if err != nil {
333 return c.fail(protocol.ExitFailure, "%v", err)
334 }
335 // The poll itself is the runner's heartbeat: admin runners reads it.
336 c.Store.TouchRunner(c.User.ID, strings.Join(args, ","), b.ID)
337 if !ok {
338 return c.emit(map[string]any{}, func(w io.Writer) { fmt.Fprintln(w, "no pending builds") })
339 }
340 repo, err := c.Store.RepoByID(b.RepoID)
341 if err != nil {
342 return c.fail(protocol.ExitFailure, "%v", err)
343 }
344 var steps []string
345 json.Unmarshal([]byte(b.Steps), &steps)
346 // Secrets ride the claim: this channel is admin-only and the values
347 // land in the build's environment, nowhere else.
348 secrets, err := c.Store.BuildSecrets(b.RepoID)
349 if err != nil {
350 return c.fail(protocol.ExitFailure, "%v", err)
351 }
352 d := struct {
353 ID int64 `json:"id"`
354 Repo string `json:"repo"`
355 Number int64 `json:"number"`
356 Job string `json:"job"`
357 SHA string `json:"sha"`
358 Ref string `json:"ref"`
359 Steps []string `json:"steps"`
360 Secrets map[string]string `json:"secrets,omitempty"`
361 }{b.ID, repo.Path(), b.Number, b.Job, b.SHA, b.Ref, steps, secrets}
362 return c.emit(d, func(w io.Writer) {
363 fmt.Fprintf(w, "build %d: %s %s @ %.10s\n", d.ID, d.Repo, d.Job, d.SHA)
364 })
365}
366
367func runRunnerLog(c *Ctx, args []string) int {
368 if code := requireRunner(c); code >= 0 {
369 return code
370 }
371 if len(args) != 1 {
372 return c.fail(protocol.ExitUsage, "usage: runner log <build-id> (chunk on stdin)")
373 }
374 id, err := strconv.ParseInt(args[0], 10, 64)
375 if err != nil {
376 return c.fail(protocol.ExitUsage, "bad build id %q", args[0])
377 }
378 // Stream stdin into the log in chunks so long builds appear live. An
379 // append that fails drops its chunk and the loop keeps draining: ending
380 // the session here breaks the runner's pipe, and a broken pipe is how a
381 // transient SQLITE_BUSY used to fail the build the log belonged to.
382 //
383 // The session is also how a running build is cancelled: while it is
384 // open the build's row is watched, and when the row stops saying
385 // running the session ends with ExitNotFound, which the runner reads as
386 // "stop this build". Any other end of the session is a lost stream.
387 type chunk struct {
388 data []byte
389 err error
390 }
391 chunks := make(chan chunk, 4)
392 go func() {
393 buf := make([]byte, 64<<10)
394 for {
395 n, rerr := c.Stdin.Read(buf)
396 if n > 0 {
397 chunks <- chunk{data: append([]byte(nil), buf[:n]...)}
398 }
399 if rerr != nil {
400 chunks <- chunk{err: rerr}
401 return
402 }
403 }
404 }()
405 watch := time.NewTicker(2 * time.Second)
406 defer watch.Stop()
407 dropped := 0
408 for {
409 select {
410 case ch := <-chunks:
411 if len(ch.data) > 0 {
412 if err := c.Store.AppendBuildLog(id, ch.data); err != nil {
413 dropped++
414 slog.Warn("appending build log", "build", id, "err", err)
415 }
416 }
417 if ch.err != nil {
418 if dropped > 0 {
419 slog.Warn("build log incomplete", "build", id, "dropped_chunks", dropped)
420 }
421 return c.emit(map[string]string{"log": "ok"}, func(w io.Writer) {})
422 }
423 case <-watch.C:
424 if b, err := c.Store.BuildByID(id); err == nil && b.Status != "running" {
425 return c.fail(protocol.ExitNotFound, "build %d is %s; stop", id, b.Status)
426 }
427 }
428 }
429}
430
431func runRunnerDone(c *Ctx, args []string) int {
432 if code := requireRunner(c); code >= 0 {
433 return code
434 }
435 if len(args) != 2 || (args[1] != "success" && args[1] != "failure") {
436 return c.fail(protocol.ExitUsage, "usage: runner done <build-id> success|failure")
437 }
438 id, err := strconv.ParseInt(args[0], 10, 64)
439 if err != nil {
440 return c.fail(protocol.ExitUsage, "bad build id %q", args[0])
441 }
442 b, err := c.Store.BuildByID(id)
443 if err != nil {
444 return c.fail(protocol.ExitNotFound, "no build %d", id)
445 }
446 // Cancelled underneath the runner: its report is late, not wrong.
447 // The row, the status and the log were settled by the cancel.
448 if b.Status == "cancelled" {
449 c.Store.RunnerDone(c.User.ID)
450 return c.emit(map[string]any{"build": b.Number, "status": "cancelled"}, func(w io.Writer) {
451 fmt.Fprintf(w, "build %d was cancelled\n", b.Number)
452 })
453 }
454 if err := c.Store.FinishBuild(id, args[1]); err != nil {
455 return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err)
456 }
457 c.Store.RunnerDone(c.User.ID)
458 repo, err := c.Store.RepoByID(b.RepoID)
459 if err != nil {
460 return c.fail(protocol.ExitFailure, "%v", err)
461 }
462 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
463 desc := "build " + args[1]
464 if err := c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, args[1], desc, url, c.User.ID); err != nil {
465 return c.fail(protocol.ExitFailure, "%v", err)
466 }
467 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+args[1],
468 fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
469 // A red build mails the repo's notify targets with the log tail — a
470 // failed scheduled job must not wait to be noticed.
471 if args[1] == "failure" {
472 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
473 tail := ""
474 if log, err := c.Store.BuildLog(id); err == nil && len(log) > 0 {
475 if len(log) > 2000 {
476 log = log[len(log)-2000:]
477 }
478 tail = string(log)
479 }
480 notifyUsers(c, targets,
481 fmt.Sprintf("[%s] build %d failed: %s on %s", repo.Path(), b.Number, b.Job, b.Ref),
482 fmt.Sprintf("job %s failed at %.10s.\n\n…%s\n\n%s\n", b.Job, b.SHA, tail, url))
483 }
484 }
485 return c.emit(map[string]any{"build": b.Number, "status": args[1]}, func(w io.Writer) {
486 fmt.Fprintf(w, "build %d %s\n", b.Number, args[1])
487 })
488}
489
490// QueueBranchBuilds reads .gitbay/ci.yml at sha and creates one pending
491// build per push job, with a pending commit status the runner resolves.
492// A broken config surfaces as a failed "ci/config" status, not silence.
493//
494// Both paths that move a branch call this: post-receive for a push, and
495// the merge path for a merge, which updates the ref directly and so never
496// reaches a hook.
497func QueueBranchBuilds(
498 st *store.Store, root, siteURL string,
499 repo store.Repo, userID int64, branch, sha string, now time.Time,
500) {
501 dir := RepoDir(root, repo.OwnerName, repo.Name)
502 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
503 if err != nil {
504 return // no CI config at this commit
505 }
506 jobs, err := ci.Parse(raw)
507 if err != nil {
508 st.SetCommitStatus(repo.ID, sha, "ci/config", "failure", err.Error(), "", userID)
509 return
510 }
511 // A build is a fact about a commit, not a ref: a job has no branch
512 // filter, so a commit that already passed a job on another branch has
513 // nothing left to prove when a fast-forward lands it here. A failed or
514 // abandoned build does not count; that commit runs again.
515 built, err := st.BuildsForCommit(repo.ID, sha)
516 if err != nil {
517 built = nil
518 }
519 var schedules []store.Schedule
520 for _, j := range jobs {
521 // Tag jobs run on matching tag pushes only.
522 if j.Tags != "" {
523 continue
524 }
525 if b, ok := built[j.Name]; ok && b.Status == "success" {
526 continue
527 }
528 // Scheduled jobs run on their cron, not on push; a default-branch
529 // push (re)registers them.
530 if j.Schedule != "" {
531 if branch == repo.DefaultBranch {
532 schedules = append(schedules, store.Schedule{
533 RepoID: repo.ID, Job: j.Name, Cron: j.Schedule,
534 NextRun: ci.NextRun(j.Schedule, now),
535 })
536 }
537 continue
538 }
539 steps, _ := json.Marshal(j.Steps)
540 n, err := st.CreateBuild(repo.ID, j.Name, sha, branch, string(steps))
541 if err != nil {
542 slog.Error("queueing build", "repo", repo.Path(), "job", j.Name, "err", err)
543 continue
544 }
545 url := fmt.Sprintf("%s/%s/builds/%d", siteURL, repo.Path(), n)
546 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "queued", url, userID)
547 }
548 if branch == repo.DefaultBranch {
549 if err := st.SyncSchedules(repo.ID, schedules); err != nil {
550 slog.Error("syncing schedules", "repo", repo.Path(), "err", err)
551 }
552 }
553}
554
555func runBuildCancel(c *Ctx, args []string) int {
556 repo, b, code := buildRef(c, args)
557 if code >= 0 {
558 return code
559 }
560 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
561 if err != nil {
562 return c.fail(protocol.ExitFailure, "%v", err)
563 }
564 if !policy.CanWrite(c.User, repo, grant) {
565 return c.fail(protocol.ExitDenied, "cancelling a build needs write access to %s", repo.Path())
566 }
567 if b.Status != "pending" && b.Status != "running" {
568 return c.fail(protocol.ExitUsage, "build %d is %s; only a queued or running build can be cancelled", b.Number, b.Status)
569 }
570 if err := c.Store.CancelBuild(b.ID); err != nil {
571 return c.fail(protocol.ExitFailure, "%v", err)
572 }
573 if b.Status == "running" {
574 c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf("\ncancelled by %s while running; the runner stops at its next check\n", c.User.Username)))
575 } else {
576 c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf("cancelled by %s before a runner claimed it\n", c.User.Username)))
577 }
578 // The queued status replaced whatever the commit had for this job. If
579 // the commit passed the job on another ref, that result stands again;
580 // otherwise the context says it was withdrawn.
581 if prev, ok, err := c.Store.SuccessBuildFor(repo.ID, b.SHA, b.Job); err == nil && ok {
582 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), prev.Number)
583 c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "success",
584 fmt.Sprintf("passed in build %d on %s", prev.Number, prev.Ref), url, c.User.ID)
585 } else {
586 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
587 c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "error", "cancelled", url, c.User.ID)
588 }
589 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
590 return c.emit(map[string]any{"number": b.Number, "job": b.Job, "status": "cancelled", "was": b.Status}, func(w io.Writer) {
591 if b.Status == "running" {
592 fmt.Fprintf(w, "cancelled %s build %d (%s); the runner stops at its next check\n", repo.Path(), b.Number, b.Job)
593 return
594 }
595 fmt.Fprintf(w, "cancelled %s build %d (%s)\n", repo.Path(), b.Number, b.Job)
596 })
597}