Commit d2d6505218
Verified · cmc
cmd/gitbay/main.go +5
| @@ -26,6 +26,10 @@ func main() { | ||
| 26 | 26 | |
| 27 | 27 | root.AddCommand( |
| 28 | 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 | 33 | repoCmd(), |
| 30 | 34 | issueCmd(), |
| 31 | 35 | mrCmd(), |
| @@ -228,6 +232,7 @@ func repoCmd() *cobra.Command { | ||
| 228 | 232 | pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}), |
| 229 | 233 | pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), |
| 230 | 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 | 236 | pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}), |
| 232 | 237 | pass("description", "set the repository description: <text>", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}), |
| 233 | 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 | 58 | curl -s -H "Authorization: Bearer $TOKEN" -d @- https://gitbay.org/api/v1/cmd |
| 59 | 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 | 79 | * Webhooks |
| 62 | 80 | |
| 63 | 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 | 16 | func init() { |
| 17 | 17 | register(Command{Path: []string{"repo", "fork"}, |
| 18 | 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 | 21 | register(Command{Path: []string{"repo", "settings", "require-signed"}, |
| 20 | 22 | Summary: "require verified commit signatures: repo settings require-signed <owner/name> on|off", Run: runRequireSigned}) |
| 21 | 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 | 120 | func runRequireSigned(c *Ctx, args []string) int { |
| 101 | 121 | if len(args) != 2 || (args[1] != "on" && args[1] != "off") { |
| 102 | 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 | 324 | if err != nil { |
| 305 | 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 | 331 | type commentOut struct { |
| 308 | 332 | Author string `json:"author"` |
| 309 | 333 | Body string `json:"body"` |
| @@ -314,6 +338,15 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 314 | 338 | Verdict string `json:"verdict"` |
| 315 | 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 | 350 | var cs []commentOut |
| 318 | 351 | for _, cm := range comments { |
| 319 | 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 | 358 | d := struct { |
| 326 | 359 | mrOut |
| 360 | Checks []checkOut `json:"checks,omitempty"` | |
| 361 | Combined string `json:"checks_combined,omitempty"` | |
| 327 | 362 | Comments []commentOut `json:"comments,omitempty"` |
| 328 | 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 | 365 | return c.emit(d, func(w io.Writer) { |
| 331 | 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 | 367 | if d.Body != "" { |
| 333 | 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 | 373 | for _, r := range rs { |
| 336 | 374 | stale := "" |
| 337 | 375 | if r.Stale { |
| @@ -478,6 +516,30 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 478 | 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 | 543 | upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA) |
| 482 | 544 | if err != nil { |
| 483 | 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 | 537 | if parsed.CommitterEmail != parsed.AuthorEmail { |
| 538 | 538 | committerEmail = parsed.CommitterEmail |
| 539 | 539 | } |
| 540 | checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full) | |
| 540 | 541 | msg := "" |
| 541 | 542 | if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 { |
| 542 | 543 | msg = string(parsed.Payload[i+2:]) |
| @@ -545,9 +546,10 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) { | ||
| 545 | 546 | repoPage |
| 546 | 547 | SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string |
| 547 | 548 | Sig sigView |
| 549 | Checks []store.CommitStatus | |
| 548 | 550 | DiffLines []diffLine |
| 549 | 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 | 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 | 644 | comments, _ := s.st.ListMRComments(m.ID) |
| 643 | 645 | reviews, _ := s.st.ListMRReviews(m.ID) |
| 646 | checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA) | |
| 644 | 647 | |
| 645 | 648 | headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number) |
| 646 | 649 | var lines []diffLine |
| @@ -659,10 +662,12 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 659 | 662 | repoPage |
| 660 | 663 | MR store.MR |
| 661 | 664 | BodyHTML template.HTML |
| 665 | Checks []store.CommitStatus | |
| 666 | Combined string | |
| 662 | 667 | Comments []renderedComment |
| 663 | 668 | Reviews []store.MRReview |
| 664 | 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 | 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 | 23 | type RepoSettings struct { |
| 24 | 24 | ProtectedBranches []string `json:"protected_branches,omitempty"` |
| 25 | 25 | RequireSignedCommits bool `json:"require_signed_commits,omitempty"` |
| 26 | RequireChecks bool `json:"require_checks,omitempty"` | |
| 26 | 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 | 57 | .badge-signed_key_expired { color: var(--warn); border-color: var(--warn); } |
| 58 | 58 | .badge-signed_key_revoked { color: var(--bad); border-color: var(--bad); } |
| 59 | 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 | 2 | {{define "content"}} |
| 3 | 3 | {{template "repoheader" .}} |
| 4 | 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 | 6 | <p>author: {{.AuthorName}} <{{.AuthorEmail}}> · {{.Date}} |
| 7 | 7 | {{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{end}}</p> |
| 8 | 8 | <pre class="message">{{.Message}}</pre> |
internal/web/templates/mr.html +2
| @@ -5,6 +5,8 @@ | ||
| 5 | 5 | <p class="crumbs">by {{.MR.Author}} · {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} → {{.MR.TargetRef}} |
| 6 | 6 | @ <code>{{.MR.HeadSHA}}</code></p> |
| 7 | 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 | 10 | {{range .Reviews}}<p>review: {{.Reviewer}} — {{.Verdict}}{{if .Stale}} <span class="badge badge-signed_key_expired">stale</span>{{end}}</p>{{end}} |
| 9 | 11 | {{range .Comments}} |
| 10 | 12 | <div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div></div> |