Commit d2d6505218
Verified · cmc
Layout: unified · split
cmd/gitbay/main.go +5
| @@ -26,6 +26,10 @@ func main() { | |||
| 26 | 26 | ||
| 27 | root.AddCommand( | 27 | root.AddCommand( |
| 28 | authCmd(), | 28 | authCmd(), |
| 29 | group("status", "commit statuses (CI)", | ||
| 30 | pass("set", "report a status: <sha> --context <c> --state <s> [--description d] [--url u]", passOpts{server: []string{"status", "set"}, needsRepo: true}), | ||
| 31 | pass("list", "statuses on a commit: <sha>", passOpts{server: []string{"status", "list"}, needsRepo: true}), | ||
| 32 | ), | ||
| 29 | repoCmd(), | 33 | repoCmd(), |
| 30 | issueCmd(), | 34 | issueCmd(), |
| 31 | mrCmd(), | 35 | mrCmd(), |
| @@ -228,6 +232,7 @@ func repoCmd() *cobra.Command { | |||
| 228 | pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}), | 232 | pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}), |
| 229 | pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), | 233 | pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), |
| 230 | pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}), | 234 | pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}), |
| 235 | pass("require-checks", "gate merges on green statuses: ... on|off", passOpts{server: []string{"repo", "settings", "require-checks"}, needsRepo: true}), | ||
| 231 | pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}), | 236 | pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}), |
| 232 | pass("description", "set the repository description: <text>", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}), | 237 | pass("description", "set the repository description: <text>", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}), |
| 233 | pass("git-daemon", "expose over git://: ... on|off", passOpts{server: []string{"repo", "settings", "git-daemon"}, needsRepo: true}), | 238 | pass("git-daemon", "expose over git://: ... on|off", passOpts{server: []string{"repo", "settings", "git-daemon"}, needsRepo: true}), |
docs/api.org +18
| @@ -58,6 +58,24 @@ printf '{"argv":["issue","create","you/project","--title","t","--file","-"],"std | |||
| 58 | curl -s -H "Authorization: Bearer $TOKEN" -d @- https://gitbay.org/api/v1/cmd | 58 | curl -s -H "Authorization: Bearer $TOKEN" -d @- https://gitbay.org/api/v1/cmd |
| 59 | #+end_src | 59 | #+end_src |
| 60 | 60 | ||
| 61 | * Commit statuses (CI reporting) | ||
| 62 | |||
| 63 | CI reports results through the same command surface (over SSH or the | ||
| 64 | JSON API with a full-scope token; reporting requires write access): | ||
| 65 | |||
| 66 | #+begin_src sh | ||
| 67 | gitbay status set <owner/name> <sha> --context build --state pending | ||
| 68 | gitbay status set <owner/name> <sha> --context build --state success --url https://ci.example/run/1 | ||
| 69 | gitbay status list <owner/name> <sha> --json # {"combined": "...", "statuses": [...]} | ||
| 70 | #+end_src | ||
| 71 | |||
| 72 | One row per (commit, context): re-reporting updates in place. States: | ||
| 73 | =pending=, =success=, =failure=, =error=; the combined state is the | ||
| 74 | worst of them. Statuses appear on commit pages, MR pages, and | ||
| 75 | =mr show=. With =repo settings require-checks <repo> on=, merging | ||
| 76 | requires the MR head to carry statuses and all of them green. Each | ||
| 77 | report also emits a =status= event to webhooks. | ||
| 78 | |||
| 61 | * Webhooks | 79 | * Webhooks |
| 62 | 80 | ||
| 63 | Per-repository outbound POSTs for repository events. Managed by repo | 81 | Per-repository outbound POSTs for repository events. Managed by repo |
e2e/status_test.go added +108
| @@ -0,0 +1,108 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | func TestCommitStatuses(t *testing.T) { | ||
| 11 | inst := startInstance(t) | ||
| 12 | aliceKey := inst.newKey(t, "alice") | ||
| 13 | bobKey := inst.newKey(t, "bob") | ||
| 14 | eveKey := inst.newKey(t, "eve") | ||
| 15 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 16 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 17 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | ||
| 18 | inst.admin(t, "admin", "user", "create", "eve", "--key", eveKey+".pub") | ||
| 19 | |||
| 20 | // Repo with a branch and an MR. | ||
| 21 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/svc"); code != 0 { | ||
| 22 | t.Fatalf("repo create: %s", errOut) | ||
| 23 | } | ||
| 24 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/svc", "bob", "write"); code != 0 { | ||
| 25 | t.Fatal("grant failed") | ||
| 26 | } | ||
| 27 | work := t.TempDir() | ||
| 28 | env := inst.gitEnv(aliceKey) | ||
| 29 | mustGit(t, work, env, "clone", inst.sshURL("alice/svc"), "w") | ||
| 30 | dir := filepath.Join(work, "w") | ||
| 31 | os.WriteFile(filepath.Join(dir, "svc.txt"), []byte("v1\n"), 0o644) | ||
| 32 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 33 | mustGit(t, dir, env, "add", ".") | ||
| 34 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | ||
| 35 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 36 | mustGit(t, dir, env, "checkout", "-q", "-b", "feat") | ||
| 37 | mustGit(t, dir, env, "commit", "-q", "--allow-empty", "-m", "feature") | ||
| 38 | mustGit(t, dir, env, "push", "-q", "origin", "feat") | ||
| 39 | head := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "feat")) | ||
| 40 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/svc", | ||
| 41 | "--source", "feat", "--target", "main", "--title", "'gated'"); code != 0 { | ||
| 42 | t.Fatalf("mr create: %s", errOut) | ||
| 43 | } | ||
| 44 | |||
| 45 | // Reporting requires write: eve (read-only public) is denied. | ||
| 46 | if _, _, code := inst.ssh(t, eveKey, "", "status", "set", "alice/svc", head, "--context", "build", "--state", "success"); code != 4 { | ||
| 47 | t.Fatal("reader reported a status") | ||
| 48 | } | ||
| 49 | |||
| 50 | // Bob (write) reports pending, then success: upsert, not duplicate. | ||
| 51 | if _, errOut, code := inst.ssh(t, bobKey, "", "status", "set", "alice/svc", head, | ||
| 52 | "--context", "build", "--state", "pending", "--description", "'compiling'"); code != 0 { | ||
| 53 | t.Fatalf("status set: %s", errOut) | ||
| 54 | } | ||
| 55 | if _, _, code := inst.ssh(t, bobKey, "", "status", "set", "alice/svc", head, | ||
| 56 | "--context", "build", "--state", "success", "--url", "https://ci.example/1"); code != 0 { | ||
| 57 | t.Fatal("status update failed") | ||
| 58 | } | ||
| 59 | out, _, _ := inst.ssh(t, aliceKey, "", "status", "list", "alice/svc", head, "--json") | ||
| 60 | if !strings.Contains(out, `"combined":"success"`) || strings.Count(out, `"context":"build"`) != 1 { | ||
| 61 | t.Fatalf("status list after upsert: %s", out) | ||
| 62 | } | ||
| 63 | |||
| 64 | // A second failing context drags the combined state down; mr show sees it. | ||
| 65 | if _, _, code := inst.ssh(t, bobKey, "", "status", "set", "alice/svc", head, | ||
| 66 | "--context", "lint", "--state", "failure"); code != 0 { | ||
| 67 | t.Fatal("lint status failed") | ||
| 68 | } | ||
| 69 | out, _, _ = inst.ssh(t, aliceKey, "", "mr", "show", "alice/svc", "1", "--json") | ||
| 70 | if !strings.Contains(out, `"checks_combined":"failure"`) { | ||
| 71 | t.Fatalf("mr show checks: %s", out) | ||
| 72 | } | ||
| 73 | |||
| 74 | // require-checks gates the merge: failing -> refused naming context, | ||
| 75 | // green -> merges. | ||
| 76 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-checks", "alice/svc", "on"); code != 0 { | ||
| 77 | t.Fatal("require-checks failed") | ||
| 78 | } | ||
| 79 | _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1") | ||
| 80 | if code != 4 || !strings.Contains(errOut, "lint=failure") { | ||
| 81 | t.Fatalf("merge with red checks: exit %d, %s", code, errOut) | ||
| 82 | } | ||
| 83 | if _, _, code := inst.ssh(t, bobKey, "", "status", "set", "alice/svc", head, "--context", "lint", "--state", "success"); code != 0 { | ||
| 84 | t.Fatal("lint fix failed") | ||
| 85 | } | ||
| 86 | if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 0 { | ||
| 87 | t.Fatalf("merge with green checks: %s", errOut) | ||
| 88 | } | ||
| 89 | |||
| 90 | // A repo requiring checks refuses merges with NO statuses at all. | ||
| 91 | mustGit(t, dir, env, "checkout", "-q", "-b", "feat2", "main") | ||
| 92 | mustGit(t, dir, env, "commit", "-q", "--allow-empty", "-m", "next") | ||
| 93 | mustGit(t, dir, env, "push", "-q", "origin", "feat2") | ||
| 94 | if _, _, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/svc", | ||
| 95 | "--source", "feat2", "--target", "main", "--title", "'unchecked'"); code != 0 { | ||
| 96 | t.Fatal("mr2 create failed") | ||
| 97 | } | ||
| 98 | _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "2") | ||
| 99 | if code != 4 || !strings.Contains(errOut, "none were reported") { | ||
| 100 | t.Fatalf("merge with no checks: exit %d, %s", code, errOut) | ||
| 101 | } | ||
| 102 | |||
| 103 | // Web: the commit page shows check badges. | ||
| 104 | status, body := inst.get(t, "/alice/svc/commit/"+head) | ||
| 105 | if status != 200 || !strings.Contains(body, "check-success") || !strings.Contains(body, "build") { | ||
| 106 | t.Fatalf("commit page checks: %d", status) | ||
| 107 | } | ||
| 108 | } | ||
internal/control/mr.go +63 −1
| @@ -16,6 +16,8 @@ import ( | |||
| 16 | func init() { | 16 | func init() { |
| 17 | register(Command{Path: []string{"repo", "fork"}, | 17 | register(Command{Path: []string{"repo", "fork"}, |
| 18 | Summary: "fork a repository under your account: repo fork <owner/name> [--name <n>]", Run: runRepoFork}) | 18 | Summary: "fork a repository under your account: repo fork <owner/name> [--name <n>]", Run: runRepoFork}) |
| 19 | register(Command{Path: []string{"repo", "settings", "require-checks"}, | ||
| 20 | Summary: "gate merges on green statuses: repo settings require-checks <owner/name> on|off", Run: runRequireChecks}) | ||
| 19 | register(Command{Path: []string{"repo", "settings", "require-signed"}, | 21 | register(Command{Path: []string{"repo", "settings", "require-signed"}, |
| 20 | Summary: "require verified commit signatures: repo settings require-signed <owner/name> on|off", Run: runRequireSigned}) | 22 | Summary: "require verified commit signatures: repo settings require-signed <owner/name> on|off", Run: runRequireSigned}) |
| 21 | register(Command{Path: []string{"mr", "create"}, | 23 | register(Command{Path: []string{"mr", "create"}, |
| @@ -97,6 +99,24 @@ func runRepoFork(c *Ctx, args []string) int { | |||
| 97 | }) | 99 | }) |
| 98 | } | 100 | } |
| 99 | 101 | ||
| 102 | func runRequireChecks(c *Ctx, args []string) int { | ||
| 103 | if len(args) != 2 || (args[1] != "on" && args[1] != "off") { | ||
| 104 | return c.fail(protocol.ExitUsage, "usage: repo settings require-checks <owner/name> on|off") | ||
| 105 | } | ||
| 106 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | ||
| 107 | if code >= 0 { | ||
| 108 | return code | ||
| 109 | } | ||
| 110 | s := repo.Settings | ||
| 111 | s.RequireChecks = args[1] == "on" | ||
| 112 | if err := c.Store.SetRepoSettings(repo.ID, s); err != nil { | ||
| 113 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 114 | } | ||
| 115 | return c.emit(s, func(w io.Writer) { | ||
| 116 | fmt.Fprintf(w, "require_checks %s on %s\n", args[1], repo.Path()) | ||
| 117 | }) | ||
| 118 | } | ||
| 119 | |||
| 100 | func runRequireSigned(c *Ctx, args []string) int { | 120 | func runRequireSigned(c *Ctx, args []string) int { |
| 101 | if len(args) != 2 || (args[1] != "on" && args[1] != "off") { | 121 | if len(args) != 2 || (args[1] != "on" && args[1] != "off") { |
| 102 | return c.fail(protocol.ExitUsage, "usage: repo settings require-signed <owner/name> on|off") | 122 | return c.fail(protocol.ExitUsage, "usage: repo settings require-signed <owner/name> on|off") |
| @@ -304,6 +324,10 @@ func runMRShow(c *Ctx, args []string) int { | |||
| 304 | if err != nil { | 324 | if err != nil { |
| 305 | return c.fail(protocol.ExitFailure, "%v", err) | 325 | return c.fail(protocol.ExitFailure, "%v", err) |
| 306 | } | 326 | } |
| 327 | statuses, err := c.Store.ListCommitStatuses(repo.ID, mr.HeadSHA) | ||
| 328 | if err != nil { | ||
| 329 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 330 | } | ||
| 307 | type commentOut struct { | 331 | type commentOut struct { |
| 308 | Author string `json:"author"` | 332 | Author string `json:"author"` |
| 309 | Body string `json:"body"` | 333 | Body string `json:"body"` |
| @@ -314,6 +338,15 @@ func runMRShow(c *Ctx, args []string) int { | |||
| 314 | Verdict string `json:"verdict"` | 338 | Verdict string `json:"verdict"` |
| 315 | Stale bool `json:"stale"` | 339 | Stale bool `json:"stale"` |
| 316 | } | 340 | } |
| 341 | type checkOut struct { | ||
| 342 | Context string `json:"context"` | ||
| 343 | State string `json:"state"` | ||
| 344 | URL string `json:"url,omitempty"` | ||
| 345 | } | ||
| 346 | var checks []checkOut | ||
| 347 | for _, st := range statuses { | ||
| 348 | checks = append(checks, checkOut{st.Context, st.State, st.TargetURL}) | ||
| 349 | } | ||
| 317 | var cs []commentOut | 350 | var cs []commentOut |
| 318 | for _, cm := range comments { | 351 | for _, cm := range comments { |
| 319 | cs = append(cs, commentOut{cm.Author, cm.Body, cm.CreatedAt}) | 352 | cs = append(cs, commentOut{cm.Author, cm.Body, cm.CreatedAt}) |
| @@ -324,14 +357,19 @@ func runMRShow(c *Ctx, args []string) int { | |||
| 324 | } | 357 | } |
| 325 | d := struct { | 358 | d := struct { |
| 326 | mrOut | 359 | mrOut |
| 360 | Checks []checkOut `json:"checks,omitempty"` | ||
| 361 | Combined string `json:"checks_combined,omitempty"` | ||
| 327 | Comments []commentOut `json:"comments,omitempty"` | 362 | Comments []commentOut `json:"comments,omitempty"` |
| 328 | Reviews []reviewOut `json:"reviews,omitempty"` | 363 | Reviews []reviewOut `json:"reviews,omitempty"` |
| 329 | }{mrToOut(repo, mr, true), cs, rs} | 364 | }{mrToOut(repo, mr, true), checks, store.CombinedStatus(statuses), cs, rs} |
| 330 | return c.emit(d, func(w io.Writer) { | 365 | return c.emit(d, func(w io.Writer) { |
| 331 | fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, d.State, d.Author, d.Source, d.TargetRef, d.HeadSHA) | 366 | fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, d.State, d.Author, d.Source, d.TargetRef, d.HeadSHA) |
| 332 | if d.Body != "" { | 367 | if d.Body != "" { |
| 333 | fmt.Fprintf(w, "\n%s\n", d.Body) | 368 | fmt.Fprintf(w, "\n%s\n", d.Body) |
| 334 | } | 369 | } |
| 370 | for _, x := range checks { | ||
| 371 | fmt.Fprintf(w, "check: %s %s\n", x.Context, x.State) | ||
| 372 | } | ||
| 335 | for _, r := range rs { | 373 | for _, r := range rs { |
| 336 | stale := "" | 374 | stale := "" |
| 337 | if r.Stale { | 375 | if r.Stale { |
| @@ -478,6 +516,30 @@ func runMRMerge(c *Ctx, args []string) int { | |||
| 478 | return c.fail(protocol.ExitFailure, "MR head ref: %v", err) | 516 | return c.fail(protocol.ExitFailure, "MR head ref: %v", err) |
| 479 | } | 517 | } |
| 480 | 518 | ||
| 519 | // Check gate: with require_checks, the MR head must carry statuses | ||
| 520 | // and every one of them must be green. | ||
| 521 | if repo.Settings.RequireChecks { | ||
| 522 | statuses, err := c.Store.ListCommitStatuses(repo.ID, headSHA) | ||
| 523 | if err != nil { | ||
| 524 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 525 | } | ||
| 526 | switch store.CombinedStatus(statuses) { | ||
| 527 | case "success": | ||
| 528 | case "": | ||
| 529 | return c.fail(protocol.ExitDenied, | ||
| 530 | "%s requires green checks and none were reported on %.10s", repo.Path(), headSHA) | ||
| 531 | default: | ||
| 532 | var bad []string | ||
| 533 | for _, st := range statuses { | ||
| 534 | if st.State != "success" { | ||
| 535 | bad = append(bad, st.Context+"="+st.State) | ||
| 536 | } | ||
| 537 | } | ||
| 538 | return c.fail(protocol.ExitDenied, | ||
| 539 | "%s requires green checks; %.10s has %s", repo.Path(), headSHA, strings.Join(bad, ", ")) | ||
| 540 | } | ||
| 541 | } | ||
| 542 | |||
| 481 | upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA) | 543 | upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA) |
| 482 | if err != nil { | 544 | if err != nil { |
| 483 | return c.fail(protocol.ExitFailure, "%v", err) | 545 | return c.fail(protocol.ExitFailure, "%v", err) |
internal/control/status.go added +134
| @@ -0,0 +1,134 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "io" | ||
| 6 | "strings" | ||
| 7 | |||
| 8 | "gitbay.org/gitbay/internal/gitutil" | ||
| 9 | "gitbay.org/gitbay/internal/policy" | ||
| 10 | "gitbay.org/gitbay/internal/protocol" | ||
| 11 | "gitbay.org/gitbay/internal/store" | ||
| 12 | ) | ||
| 13 | |||
| 14 | func init() { | ||
| 15 | register(Command{Path: []string{"status", "set"}, | ||
| 16 | Summary: "report a commit status (CI): status set <owner/name> <sha> --context <c> --state pending|success|failure|error [--description <d>] [--url <u>]", | ||
| 17 | Run: runStatusSet}) | ||
| 18 | register(Command{Path: []string{"status", "list"}, | ||
| 19 | Summary: "statuses on a commit: status list <owner/name> <sha>", ReadOnly: true, Run: runStatusList}) | ||
| 20 | } | ||
| 21 | |||
| 22 | var validStatusState = map[string]bool{"pending": true, "success": true, "failure": true, "error": true} | ||
| 23 | |||
| 24 | func runStatusSet(c *Ctx, args []string) int { | ||
| 25 | var path, sha, context, state, description, url string | ||
| 26 | rest := args | ||
| 27 | for i := 0; i < len(rest); i++ { | ||
| 28 | switch rest[i] { | ||
| 29 | case "--context", "--state", "--description", "--url": | ||
| 30 | if i+1 >= len(rest) { | ||
| 31 | return c.fail(protocol.ExitUsage, "%s requires a value", rest[i]) | ||
| 32 | } | ||
| 33 | v := rest[i+1] | ||
| 34 | switch rest[i] { | ||
| 35 | case "--context": | ||
| 36 | context = v | ||
| 37 | case "--state": | ||
| 38 | state = v | ||
| 39 | case "--description": | ||
| 40 | description = v | ||
| 41 | case "--url": | ||
| 42 | url = v | ||
| 43 | } | ||
| 44 | i++ | ||
| 45 | default: | ||
| 46 | if path == "" { | ||
| 47 | path = rest[i] | ||
| 48 | } else if sha == "" { | ||
| 49 | sha = rest[i] | ||
| 50 | } else { | ||
| 51 | return c.fail(protocol.ExitUsage, "unexpected argument %q", rest[i]) | ||
| 52 | } | ||
| 53 | } | ||
| 54 | } | ||
| 55 | if path == "" || sha == "" || context == "" || !validStatusState[state] { | ||
| 56 | return c.fail(protocol.ExitUsage, "usage: status set <owner/name> <sha> --context <c> --state pending|success|failure|error") | ||
| 57 | } | ||
| 58 | if url != "" && !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") { | ||
| 59 | return c.fail(protocol.ExitUsage, "--url must be http(s)") | ||
| 60 | } | ||
| 61 | // Reporting a status is a write: CI identities need write access (an | ||
| 62 | // API token with full scope, or an account grant). | ||
| 63 | repo, code := resolveRepo(c, path, policy.CanWrite) | ||
| 64 | if code >= 0 { | ||
| 65 | return code | ||
| 66 | } | ||
| 67 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | ||
| 68 | full, err := gitutil.ResolveRef(dir, sha) | ||
| 69 | if err != nil { | ||
| 70 | return c.fail(protocol.ExitNotFound, "no commit %s in %s", sha, repo.Path()) | ||
| 71 | } | ||
| 72 | if err := c.Store.SetCommitStatus(repo.ID, full, context, state, description, url, c.User.ID); err != nil { | ||
| 73 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 74 | } | ||
| 75 | c.Store.RecordEvent(repo.ID, c.User.ID, "status", | ||
| 76 | fmt.Sprintf(`{"sha":%q,"context":%q,"state":%q}`, full, context, state)) | ||
| 77 | return c.emit(map[string]string{"sha": full, "context": context, "state": state}, func(w io.Writer) { | ||
| 78 | fmt.Fprintf(w, "%s on %.10s: %s\n", context, full, state) | ||
| 79 | }) | ||
| 80 | } | ||
| 81 | |||
| 82 | func runStatusList(c *Ctx, args []string) int { | ||
| 83 | if len(args) != 2 { | ||
| 84 | return c.fail(protocol.ExitUsage, "usage: status list <owner/name> <sha>") | ||
| 85 | } | ||
| 86 | repo, code := resolveRepo(c, args[0], policy.CanRead) | ||
| 87 | if code >= 0 { | ||
| 88 | return code | ||
| 89 | } | ||
| 90 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | ||
| 91 | full, err := gitutil.ResolveRef(dir, args[1]) | ||
| 92 | if err != nil { | ||
| 93 | return c.fail(protocol.ExitNotFound, "no commit %s in %s", args[1], repo.Path()) | ||
| 94 | } | ||
| 95 | statuses, err := c.Store.ListCommitStatuses(repo.ID, full) | ||
| 96 | if err != nil { | ||
| 97 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 98 | } | ||
| 99 | type out struct { | ||
| 100 | Context string `json:"context"` | ||
| 101 | State string `json:"state"` | ||
| 102 | Description string `json:"description,omitempty"` | ||
| 103 | URL string `json:"url,omitempty"` | ||
| 104 | Creator string `json:"creator,omitempty"` | ||
| 105 | } | ||
| 106 | var ds []out | ||
| 107 | for _, s := range statuses { | ||
| 108 | ds = append(ds, out{s.Context, s.State, s.Description, s.TargetURL, s.Creator}) | ||
| 109 | } | ||
| 110 | d := struct { | ||
| 111 | SHA string `json:"sha"` | ||
| 112 | Combined string `json:"combined"` | ||
| 113 | Statuses []out `json:"statuses"` | ||
| 114 | }{full, combinedOf(statuses), ds} | ||
| 115 | return c.emit(d, func(w io.Writer) { | ||
| 116 | fmt.Fprintf(w, "%.10s: %s\n", d.SHA, orNone(d.Combined)) | ||
| 117 | for _, x := range ds { | ||
| 118 | extra := "" | ||
| 119 | if x.Description != "" { | ||
| 120 | extra = "\t" + x.Description | ||
| 121 | } | ||
| 122 | fmt.Fprintf(w, " %s\t%s%s\n", x.Context, x.State, extra) | ||
| 123 | } | ||
| 124 | }) | ||
| 125 | } | ||
| 126 | |||
| 127 | func combinedOf(statuses []store.CommitStatus) string { return store.CombinedStatus(statuses) } | ||
| 128 | |||
| 129 | func orNone(s string) string { | ||
| 130 | if s == "" { | ||
| 131 | return "no statuses" | ||
| 132 | } | ||
| 133 | return s | ||
| 134 | } | ||
internal/httpd/web.go +7 −2
| @@ -537,6 +537,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) { | |||
| 537 | if parsed.CommitterEmail != parsed.AuthorEmail { | 537 | if parsed.CommitterEmail != parsed.AuthorEmail { |
| 538 | committerEmail = parsed.CommitterEmail | 538 | committerEmail = parsed.CommitterEmail |
| 539 | } | 539 | } |
| 540 | checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full) | ||
| 540 | msg := "" | 541 | msg := "" |
| 541 | if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 { | 542 | if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 { |
| 542 | msg = string(parsed.Payload[i+2:]) | 543 | msg = string(parsed.Payload[i+2:]) |
| @@ -545,9 +546,10 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) { | |||
| 545 | repoPage | 546 | repoPage |
| 546 | SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string | 547 | SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string |
| 547 | Sig sigView | 548 | Sig sigView |
| 549 | Checks []store.CommitStatus | ||
| 548 | DiffLines []diffLine | 550 | DiffLines []diffLine |
| 549 | }{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail, | 551 | }{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail, |
| 550 | time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines}) | 552 | time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, checks, lines}) |
| 551 | } | 553 | } |
| 552 | 554 | ||
| 553 | func (s *Server) issues(w http.ResponseWriter, r *http.Request) { | 555 | func (s *Server) issues(w http.ResponseWriter, r *http.Request) { |
| @@ -641,6 +643,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | |||
| 641 | } | 643 | } |
| 642 | comments, _ := s.st.ListMRComments(m.ID) | 644 | comments, _ := s.st.ListMRComments(m.ID) |
| 643 | reviews, _ := s.st.ListMRReviews(m.ID) | 645 | reviews, _ := s.st.ListMRReviews(m.ID) |
| 646 | checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA) | ||
| 644 | 647 | ||
| 645 | headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number) | 648 | headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number) |
| 646 | var lines []diffLine | 649 | var lines []diffLine |
| @@ -659,10 +662,12 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | |||
| 659 | repoPage | 662 | repoPage |
| 660 | MR store.MR | 663 | MR store.MR |
| 661 | BodyHTML template.HTML | 664 | BodyHTML template.HTML |
| 665 | Checks []store.CommitStatus | ||
| 666 | Combined string | ||
| 662 | Comments []renderedComment | 667 | Comments []renderedComment |
| 663 | Reviews []store.MRReview | 668 | Reviews []store.MRReview |
| 664 | DiffLines []diffLine | 669 | DiffLines []diffLine |
| 665 | }{p, m, mdHTML(m.Body), renderComments(comments), reviews, lines}) | 670 | }{p, m, mdHTML(m.Body), checks, store.CombinedStatus(checks), renderComments(comments), reviews, lines}) |
| 666 | } | 671 | } |
| 667 | 672 | ||
| 668 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { | 673 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { |
internal/store/migrations/0008_statuses.down.sql added +1
| @@ -0,0 +1 @@ | |||
| 1 | DROP TABLE commit_statuses; | ||
internal/store/migrations/0008_statuses.up.sql added +14
| @@ -0,0 +1,14 @@ | |||
| 1 | CREATE TABLE commit_statuses ( | ||
| 2 | id INTEGER PRIMARY KEY, | ||
| 3 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | ||
| 4 | commit_sha TEXT NOT NULL, | ||
| 5 | context TEXT NOT NULL, | ||
| 6 | state TEXT NOT NULL CHECK (state IN ('pending','success','failure','error')), | ||
| 7 | description TEXT NOT NULL DEFAULT '', | ||
| 8 | target_url TEXT NOT NULL DEFAULT '', | ||
| 9 | creator_id INTEGER REFERENCES users(id) ON DELETE SET NULL, | ||
| 10 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | ||
| 11 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | ||
| 12 | UNIQUE (repo_id, commit_sha, context) | ||
| 13 | ); | ||
| 14 | CREATE INDEX commit_statuses_sha ON commit_statuses(repo_id, commit_sha); | ||
internal/store/repos.go +1
| @@ -23,6 +23,7 @@ type Repo struct { | |||
| 23 | type RepoSettings struct { | 23 | type RepoSettings struct { |
| 24 | ProtectedBranches []string `json:"protected_branches,omitempty"` | 24 | ProtectedBranches []string `json:"protected_branches,omitempty"` |
| 25 | RequireSignedCommits bool `json:"require_signed_commits,omitempty"` | 25 | RequireSignedCommits bool `json:"require_signed_commits,omitempty"` |
| 26 | RequireChecks bool `json:"require_checks,omitempty"` | ||
| 26 | GitDaemon bool `json:"git_daemon,omitempty"` | 27 | GitDaemon bool `json:"git_daemon,omitempty"` |
| 27 | } | 28 | } |
| 28 | 29 | ||
internal/store/statuses.go added +62
| @@ -0,0 +1,62 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | type CommitStatus struct { | ||
| 4 | Context string | ||
| 5 | State string // pending | success | failure | error | ||
| 6 | Description string | ||
| 7 | TargetURL string | ||
| 8 | Creator string | ||
| 9 | UpdatedAt string | ||
| 10 | } | ||
| 11 | |||
| 12 | // SetCommitStatus upserts the latest state for one context on one commit. | ||
| 13 | func (s *Store) SetCommitStatus(repoID int64, sha, context, state, description, targetURL string, creatorID int64) error { | ||
| 14 | _, err := s.DB.Exec(` | ||
| 15 | INSERT INTO commit_statuses (repo_id, commit_sha, context, state, description, target_url, creator_id) | ||
| 16 | VALUES (?, ?, ?, ?, ?, ?, ?) | ||
| 17 | ON CONFLICT (repo_id, commit_sha, context) DO UPDATE SET | ||
| 18 | state = excluded.state, description = excluded.description, | ||
| 19 | target_url = excluded.target_url, creator_id = excluded.creator_id, | ||
| 20 | updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, | ||
| 21 | repoID, sha, context, state, description, targetURL, creatorID) | ||
| 22 | return err | ||
| 23 | } | ||
| 24 | |||
| 25 | // ListCommitStatuses returns the latest status per context for a commit. | ||
| 26 | func (s *Store) ListCommitStatuses(repoID int64, sha string) ([]CommitStatus, error) { | ||
| 27 | rows, err := s.DB.Query(` | ||
| 28 | SELECT cs.context, cs.state, cs.description, cs.target_url, COALESCE(u.username, ''), cs.updated_at | ||
| 29 | FROM commit_statuses cs LEFT JOIN users u ON u.id = cs.creator_id | ||
| 30 | WHERE cs.repo_id = ? AND cs.commit_sha = ? ORDER BY cs.context`, repoID, sha) | ||
| 31 | if err != nil { | ||
| 32 | return nil, err | ||
| 33 | } | ||
| 34 | defer rows.Close() | ||
| 35 | var out []CommitStatus | ||
| 36 | for rows.Next() { | ||
| 37 | var c CommitStatus | ||
| 38 | if err := rows.Scan(&c.Context, &c.State, &c.Description, &c.TargetURL, &c.Creator, &c.UpdatedAt); err != nil { | ||
| 39 | return nil, err | ||
| 40 | } | ||
| 41 | out = append(out, c) | ||
| 42 | } | ||
| 43 | return out, rows.Err() | ||
| 44 | } | ||
| 45 | |||
| 46 | // CombinedStatus reduces per-context states to one: error/failure dominate, | ||
| 47 | // then pending, then success; "" when no statuses exist. | ||
| 48 | func CombinedStatus(statuses []CommitStatus) string { | ||
| 49 | if len(statuses) == 0 { | ||
| 50 | return "" | ||
| 51 | } | ||
| 52 | combined := "success" | ||
| 53 | for _, s := range statuses { | ||
| 54 | switch s.State { | ||
| 55 | case "error", "failure": | ||
| 56 | return "failure" | ||
| 57 | case "pending": | ||
| 58 | combined = "pending" | ||
| 59 | } | ||
| 60 | } | ||
| 61 | return combined | ||
| 62 | } | ||
internal/web/static/style.css +3
| @@ -57,3 +57,6 @@ pre.diff .meta { color: var(--muted); } | |||
| 57 | .badge-signed_key_expired { color: var(--warn); border-color: var(--warn); } | 57 | .badge-signed_key_expired { color: var(--warn); border-color: var(--warn); } |
| 58 | .badge-signed_key_revoked { color: var(--bad); border-color: var(--bad); } | 58 | .badge-signed_key_revoked { color: var(--bad); border-color: var(--bad); } |
| 59 | .badge-bad_signature { color: var(--bad); border-color: var(--bad); } | 59 | .badge-bad_signature { color: var(--bad); border-color: var(--bad); } |
| 60 | .check-success { color: var(--ok); border-color: var(--ok); } | ||
| 61 | .check-pending { color: var(--warn); border-color: var(--warn); } | ||
| 62 | .check-failure, .check-error { color: var(--bad); border-color: var(--bad); } | ||
internal/web/templates/commit.html +1 −1
| @@ -2,7 +2,7 @@ | |||
| 2 | {{define "content"}} | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | 3 | {{template "repoheader" .}} |
| 4 | <h2><code>{{.SHA}}</code></h2> | 4 | <h2><code>{{.SHA}}</code></h2> |
| 5 | <p>{{template "sigbadge" .Sig}}</p> | 5 | <p>{{template "sigbadge" .Sig}}{{range .Checks}} <span class="badge check-{{.State}}">{{.Context}}: {{.State}}</span>{{end}}</p> |
| 6 | <p>author: {{.AuthorName}} <{{.AuthorEmail}}> · {{.Date}} | 6 | <p>author: {{.AuthorName}} <{{.AuthorEmail}}> · {{.Date}} |
| 7 | {{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{end}}</p> | 7 | {{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{end}}</p> |
| 8 | <pre class="message">{{.Message}}</pre> | 8 | <pre class="message">{{.Message}}</pre> |
internal/web/templates/mr.html +2
| @@ -5,6 +5,8 @@ | |||
| 5 | <p class="crumbs">by {{.MR.Author}} · {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} → {{.MR.TargetRef}} | 5 | <p class="crumbs">by {{.MR.Author}} · {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} → {{.MR.TargetRef}} |
| 6 | @ <code>{{.MR.HeadSHA}}</code></p> | 6 | @ <code>{{.MR.HeadSHA}}</code></p> |
| 7 | {{if .BodyHTML}}<div class="rendered">{{.BodyHTML}}</div>{{end}} | 7 | {{if .BodyHTML}}<div class="rendered">{{.BodyHTML}}</div>{{end}} |
| 8 | {{if .Checks}}<p>checks: <span class="badge check-{{.Combined}}">{{.Combined}}</span> | ||
| 9 | {{range .Checks}} · {{if .TargetURL}}<a href="{{.TargetURL}}" rel="nofollow">{{.Context}}</a>{{else}}{{.Context}}{{end}} <span class="check-{{.State}}">{{.State}}</span>{{end}}</p>{{end}} | ||
| 8 | {{range .Reviews}}<p>review: {{.Reviewer}} — {{.Verdict}}{{if .Stale}} <span class="badge badge-signed_key_expired">stale</span>{{end}}</p>{{end}} | 10 | {{range .Reviews}}<p>review: {{.Reviewer}} — {{.Verdict}}{{if .Stale}} <span class="badge badge-signed_key_expired">stale</span>{{end}}</p>{{end}} |
| 9 | {{range .Comments}} | 11 | {{range .Comments}} |
| 10 | <div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div></div> | 12 | <div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div></div> |