build cancel: withdraw a queued build !166

merged merged by cmc on 2026-09-02 03:50 UTC · krz/gitbay:build-cancel into main

6 files changed, +168 −5

Layout: unified · split

cmd/gitbay/main.go +1
@@ -45,6 +45,7 @@ func newRoot() *cobra.Command {
45 pass("log", "a build's log: <owner/name> <n>", passOpts{server: []string{"build", "log"}, needsRepo: true}), 45 pass("log", "a build's log: <owner/name> <n>", passOpts{server: []string{"build", "log"}, needsRepo: true}),
46 pass("jobs", "list the jobs a trigger can name", passOpts{server: []string{"build", "jobs"}, needsRepo: true}), 46 pass("jobs", "list the jobs a trigger can name", passOpts{server: []string{"build", "jobs"}, needsRepo: true}),
47 pass("trigger", "queue a job now: <job>", passOpts{server: []string{"build", "trigger"}, needsRepo: true}), 47 pass("trigger", "queue a job now: <job>", passOpts{server: []string{"build", "trigger"}, needsRepo: true}),
48 pass("cancel", "withdraw a queued build: <n>", passOpts{server: []string{"build", "cancel"}, needsRepo: true}),
48 ), 49 ),
49 pass("dashboard", "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds", 50 pass("dashboard", "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds",
50 passOpts{server: []string{"dashboard"}}), 51 passOpts{server: []string{"dashboard"}}),
e2e/build_cancel_test.go added +95
@@ -0,0 +1,95 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// build cancel withdraws a queued build; a running one is the runner's.
11// Cancelling a duplicate of a commit that already passed puts that
12// result back on the commit.
13func TestBuildCancel(t *testing.T) {
14 inst := startInstance(t)
15 inst.runner = buildRunner(t)
16 aliceKey := inst.newKey(t, "alice")
17 bobKey := inst.newKey(t, "bob")
18 runnerKey := inst.newKey(t, "ci")
19 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
20 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
21 inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin")
22 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
23 t.Fatal("repo create failed")
24 }
25 work := t.TempDir()
26 env := inst.gitEnv(aliceKey)
27 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
28 dir := filepath.Join(work, "w")
29 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
30 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n unit:\n steps:\n - echo fine\n"), 0o644)
31 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
32 mustGit(t, dir, env, "add", ".")
33 mustGit(t, dir, env, "commit", "-q", "-m", "ci")
34 mustGit(t, dir, env, "push", "-q", "origin", "main")
35 sha := strings.Fields(strings.TrimSpace(mustGit(t, dir, env, "ls-remote", "origin", "refs/heads/main")))[0]
36
37 // Build 1 is queued. A reader cannot cancel it; the owner can.
38 if _, _, code := inst.ssh(t, bobKey, "", "build", "cancel", "alice/app", "1"); code != 4 {
39 t.Fatal("reader cancelled a build")
40 }
41 if out, errOut, code := inst.ssh(t, aliceKey, "", "build", "cancel", "alice/app", "1"); code != 0 || !strings.Contains(out, "cancelled alice/app build 1") {
42 t.Fatalf("cancel: exit %d %s%s", code, out, errOut)
43 }
44 if list := inst.buildList(t, aliceKey); !strings.Contains(list, "unit\tcancelled") {
45 t.Fatalf("build list after cancel:\n%s", list)
46 }
47 if out, _, _ := inst.ssh(t, aliceKey, "", "build", "log", "alice/app", "1"); !strings.Contains(out, "cancelled by alice") {
48 t.Fatalf("log after cancel:\n%s", out)
49 }
50 if st, _, _ := inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha); !strings.Contains(st, "error") || !strings.Contains(st, "cancelled") {
51 t.Fatalf("status after cancel:\n%s", st)
52 }
53 if _, _, code := inst.ssh(t, aliceKey, "", "build", "cancel", "alice/app", "1"); code != 2 {
54 t.Fatal("cancelled a cancelled build")
55 }
56 // Nothing for a runner to claim.
57 if out := inst.runnerOnce(t, runnerKey); strings.Contains(out, "unit") && !strings.Contains(out, "no pending") {
58 t.Fatalf("runner picked up a cancelled build:\n%s", out)
59 }
60 if status, _ := inst.get(t, "/alice/app/badge/build.svg"); status != 200 {
61 t.Fatalf("badge after cancel: %d", status)
62 }
63
64 // Build 2: triggered, run to success. Build 3: the same commit queued
65 // again; cancelling it restores the passed result on the commit.
66 if _, _, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "unit"); code != 0 {
67 t.Fatal("trigger failed")
68 }
69 inst.runnerOnce(t, runnerKey)
70 if list := inst.buildList(t, aliceKey); !strings.Contains(list, "unit\tsuccess") {
71 t.Fatalf("build 2 did not pass:\n%s", list)
72 }
73 if _, _, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "unit"); code != 0 {
74 t.Fatal("second trigger failed")
75 }
76 if st, _, _ := inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha); !strings.Contains(st, "pending") {
77 t.Fatalf("status before cancelling the duplicate:\n%s", st)
78 }
79 if _, errOut, code := inst.ssh(t, aliceKey, "", "build", "cancel", "alice/app", "3"); code != 0 {
80 t.Fatalf("cancel duplicate: %s", errOut)
81 }
82 if st, _, _ := inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha); !strings.Contains(st, "success") || !strings.Contains(st, "passed in build 2") {
83 t.Fatalf("status after cancelling the duplicate:\n%s", st)
84 }
85 // A running build cannot be cancelled here.
86 if _, _, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "unit"); code != 0 {
87 t.Fatal("third trigger failed")
88 }
89 if _, _, code := inst.ssh(t, runnerKey, "", "runner", "next"); code != 0 {
90 t.Fatal("claim failed")
91 }
92 if _, errOut, code := inst.ssh(t, aliceKey, "", "build", "cancel", "alice/app", "4"); code != 2 || !strings.Contains(errOut, "running") {
93 t.Fatalf("cancelled a running build: exit %d %s", code, errOut)
94 }
95}
internal/control/build.go +39
@@ -33,6 +33,9 @@ func init() {
33 Summary: "list the jobs a trigger can name", 33 Summary: "list the jobs a trigger can name",
34 Usage: "build jobs <owner/name>", ReadOnly: true, Run: runBuildJobs}) 34 Usage: "build jobs <owner/name>", ReadOnly: true, Run: runBuildJobs})
35 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})
36 register(Command{Path: []string{"build", "trigger"}, 39 register(Command{Path: []string{"build", "trigger"},
37 Summary: "queue a job now (scheduled or not)", 40 Summary: "queue a job now (scheduled or not)",
38 Usage: "build trigger <owner/name> <job>", Run: runBuildTrigger}) 41 Usage: "build trigger <owner/name> <job>", Run: runBuildTrigger})
@@ -511,3 +514,39 @@ func QueueBranchBuilds(
511 } 514 }
512 } 515 }
513} 516}
517
518func runBuildCancel(c *Ctx, args []string) int {
519 repo, b, code := buildRef(c, args)
520 if code >= 0 {
521 return code
522 }
523 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
524 if err != nil {
525 return c.fail(protocol.ExitFailure, "%v", err)
526 }
527 if !policy.CanWrite(c.User, repo, grant) {
528 return c.fail(protocol.ExitDenied, "cancelling a build needs write access to %s", repo.Path())
529 }
530 if b.Status != "pending" {
531 return c.fail(protocol.ExitUsage, "build %d is %s; only a queued build can be cancelled", b.Number, b.Status)
532 }
533 if err := c.Store.CancelBuild(b.ID); err != nil {
534 return c.fail(protocol.ExitFailure, "%v", err)
535 }
536 c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf("cancelled by %s before a runner claimed it\n", c.User.Username)))
537 // The queued status replaced whatever the commit had for this job. If
538 // the commit passed the job on another ref, that result stands again;
539 // otherwise the context says it was withdrawn.
540 if prev, ok, err := c.Store.SuccessBuildFor(repo.ID, b.SHA, b.Job); err == nil && ok {
541 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), prev.Number)
542 c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "success",
543 fmt.Sprintf("passed in build %d on %s", prev.Number, prev.Ref), url, c.User.ID)
544 } else {
545 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
546 c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "error", "cancelled", url, c.User.ID)
547 }
548 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
549 return c.emit(map[string]any{"number": b.Number, "job": b.Job, "status": "cancelled"}, func(w io.Writer) {
550 fmt.Fprintf(w, "cancelled %s build %d (%s)\n", repo.Path(), b.Number, b.Job)
551 })
552}
internal/httpd/badge.go +6 −5
@@ -13,11 +13,12 @@ import (
13 13
14// badgeColors are the shield fills per build state. 14// badgeColors are the shield fills per build state.
15var badgeColors = map[string]string{ 15var badgeColors = map[string]string{
16 "success": "#2da44e", 16 "success": "#2da44e",
17 "failure": "#cf222e", 17 "failure": "#cf222e",
18 "running": "#bf8700", 18 "running": "#bf8700",
19 "pending": "#bf8700", 19 "pending": "#bf8700",
20 "unknown": "#6b7280", 20 "cancelled": "#6b7280",
21 "unknown": "#6b7280",
21} 22}
22 23
23// badgeWidth approximates Verdana 11px advance so the pill fits its text 24// badgeWidth approximates Verdana 11px advance so the pill fits its text
internal/store/builds.go +26
@@ -290,3 +290,29 @@ func (b Build) Elapsed() time.Duration {
290 } 290 }
291 return 0 291 return 0
292} 292}
293
294// CancelBuild withdraws a build that no runner has claimed. A running
295// build is the runner's to finish; cancelling it here would leave the
296// runner reporting on a row that says otherwise.
297func (s *Store) CancelBuild(id int64) error {
298 res, err := s.DB.Exec(`UPDATE builds SET status = 'cancelled',
299 finished_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id = ? AND status = 'pending'`, id)
300 if err != nil {
301 return err
302 }
303 if n, _ := res.RowsAffected(); n == 0 {
304 return ErrNotFound
305 }
306 return nil
307}
308
309// SuccessBuildFor finds a passed build of the commit for the job, on any
310// ref: what a cancelled duplicate can point back at.
311func (s *Store) SuccessBuildFor(repoID int64, sha, job string) (Build, bool, error) {
312 b, err := scanBuild(s.DB.QueryRow(buildSelect+
313 " WHERE repo_id = ? AND sha = ? AND job = ? AND status = 'success' ORDER BY number DESC LIMIT 1", repoID, sha, job))
314 if errors.Is(err, sql.ErrNoRows) {
315 return Build{}, false, nil
316 }
317 return b, err == nil, err
318}
internal/web/static/style.css +1
@@ -1019,6 +1019,7 @@ table.difftable td.ln a.cmt:focus { color: var(--accent); text-decoration: under
1019.badge.check-success { --chip: var(--ok); } 1019.badge.check-success { --chip: var(--ok); }
1020.badge.check-pending { --chip: var(--warn); } 1020.badge.check-pending { --chip: var(--warn); }
1021.badge.check-failure, .badge.check-error { --chip: var(--bad); } 1021.badge.check-failure, .badge.check-error { --chip: var(--bad); }
1022.badge.check-cancelled, .chip.check-cancelled { --chip: var(--neutral); }
1022.badge.check-running, .chip.check-running { --chip: var(--warn); } 1023.badge.check-running, .chip.check-running { --chip: var(--warn); }
1023.chip.check-success { --chip: var(--ok); } 1024.chip.check-success { --chip: var(--ok); }
1024.chip.check-pending { --chip: var(--warn); } 1025.chip.check-pending { --chip: var(--warn); }