A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit d2d6505218

d2d650521865a3aa9c2fa64f7452aba32ad46cbb

parent: 8e41115b91

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T15:51:12Z

commit statuses (#1)

status set/list: one row per (commit, context), upserted; states
pending/success/failure/error with worst-of combined. Reporting
requires write access; each report emits a status event to webhooks.
Checks render on commit pages, MR pages, and mr show. repo settings
require-checks gates merges: no statuses or any non-green refuses with
the offending contexts named. Documented in docs/api.org.
cmd/gitbay/main.go +5
@@ -26,6 +26,10 @@ func main() {
2626
2727 root.AddCommand(
2828 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 ),
2933 repoCmd(),
3034 issueCmd(),
3135 mrCmd(),
@@ -228,6 +232,7 @@ func repoCmd() *cobra.Command {
228232 pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}),
229233 pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}),
230234 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}),
231236 pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}),
232237 pass("description", "set the repository description: <text>", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}),
233238 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
5858 curl -s -H "Authorization: Bearer $TOKEN" -d @- https://gitbay.org/api/v1/cmd
5959 #+end_src
6060
61* Commit statuses (CI reporting)
62
63CI reports results through the same command surface (over SSH or the
64JSON API with a full-scope token; reporting requires write access):
65
66#+begin_src sh
67gitbay status set <owner/name> <sha> --context build --state pending
68gitbay status set <owner/name> <sha> --context build --state success --url https://ci.example/run/1
69gitbay status list <owner/name> <sha> --json # {"combined": "...", "statuses": [...]}
70#+end_src
71
72One row per (commit, context): re-reporting updates in place. States:
73=pending=, =success=, =failure=, =error=; the combined state is the
74worst of them. Statuses appear on commit pages, MR pages, and
75=mr show=. With =repo settings require-checks <repo> on=, merging
76requires the MR head to carry statuses and all of them green. Each
77report also emits a =status= event to webhooks.
78
6179 * Webhooks
6280
6381 Per-repository outbound POSTs for repository events. Managed by repo
e2e/status_test.go added +108
@@ -0,0 +1,108 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10func 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 (
1616 func init() {
1717 register(Command{Path: []string{"repo", "fork"},
1818 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})
1921 register(Command{Path: []string{"repo", "settings", "require-signed"},
2022 Summary: "require verified commit signatures: repo settings require-signed <owner/name> on|off", Run: runRequireSigned})
2123 register(Command{Path: []string{"mr", "create"},
@@ -97,6 +99,24 @@ func runRepoFork(c *Ctx, args []string) int {
9799 })
98100 }
99101
102func 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
100120 func runRequireSigned(c *Ctx, args []string) int {
101121 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
102122 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 {
304324 if err != nil {
305325 return c.fail(protocol.ExitFailure, "%v", err)
306326 }
327 statuses, err := c.Store.ListCommitStatuses(repo.ID, mr.HeadSHA)
328 if err != nil {
329 return c.fail(protocol.ExitFailure, "%v", err)
330 }
307331 type commentOut struct {
308332 Author string `json:"author"`
309333 Body string `json:"body"`
@@ -314,6 +338,15 @@ func runMRShow(c *Ctx, args []string) int {
314338 Verdict string `json:"verdict"`
315339 Stale bool `json:"stale"`
316340 }
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 }
317350 var cs []commentOut
318351 for _, cm := range comments {
319352 cs = append(cs, commentOut{cm.Author, cm.Body, cm.CreatedAt})
@@ -324,14 +357,19 @@ func runMRShow(c *Ctx, args []string) int {
324357 }
325358 d := struct {
326359 mrOut
360 Checks []checkOut `json:"checks,omitempty"`
361 Combined string `json:"checks_combined,omitempty"`
327362 Comments []commentOut `json:"comments,omitempty"`
328363 Reviews []reviewOut `json:"reviews,omitempty"`
329 }{mrToOut(repo, mr, true), cs, rs}
364 }{mrToOut(repo, mr, true), checks, store.CombinedStatus(statuses), cs, rs}
330365 return c.emit(d, func(w io.Writer) {
331366 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)
332367 if d.Body != "" {
333368 fmt.Fprintf(w, "\n%s\n", d.Body)
334369 }
370 for _, x := range checks {
371 fmt.Fprintf(w, "check: %s %s\n", x.Context, x.State)
372 }
335373 for _, r := range rs {
336374 stale := ""
337375 if r.Stale {
@@ -478,6 +516,30 @@ func runMRMerge(c *Ctx, args []string) int {
478516 return c.fail(protocol.ExitFailure, "MR head ref: %v", err)
479517 }
480518
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
481543 upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA)
482544 if err != nil {
483545 return c.fail(protocol.ExitFailure, "%v", err)
internal/control/status.go added +134
@@ -0,0 +1,134 @@
1package control
2
3import (
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
14func 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
22var validStatusState = map[string]bool{"pending": true, "success": true, "failure": true, "error": true}
23
24func 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
82func 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
127func combinedOf(statuses []store.CommitStatus) string { return store.CombinedStatus(statuses) }
128
129func 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) {
537537 if parsed.CommitterEmail != parsed.AuthorEmail {
538538 committerEmail = parsed.CommitterEmail
539539 }
540 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
540541 msg := ""
541542 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
542543 msg = string(parsed.Payload[i+2:])
@@ -545,9 +546,10 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
545546 repoPage
546547 SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
547548 Sig sigView
549 Checks []store.CommitStatus
548550 DiffLines []diffLine
549551 }{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})
551553 }
552554
553555 func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
@@ -641,6 +643,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
641643 }
642644 comments, _ := s.st.ListMRComments(m.ID)
643645 reviews, _ := s.st.ListMRReviews(m.ID)
646 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
644647
645648 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
646649 var lines []diffLine
@@ -659,10 +662,12 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
659662 repoPage
660663 MR store.MR
661664 BodyHTML template.HTML
665 Checks []store.CommitStatus
666 Combined string
662667 Comments []renderedComment
663668 Reviews []store.MRReview
664669 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})
666671 }
667672
668673 func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
internal/store/migrations/0008_statuses.down.sql added +1
@@ -0,0 +1 @@
1DROP TABLE commit_statuses;
internal/store/migrations/0008_statuses.up.sql added +14
@@ -0,0 +1,14 @@
1CREATE 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);
14CREATE INDEX commit_statuses_sha ON commit_statuses(repo_id, commit_sha);
internal/store/repos.go +1
@@ -23,6 +23,7 @@ type Repo struct {
2323 type RepoSettings struct {
2424 ProtectedBranches []string `json:"protected_branches,omitempty"`
2525 RequireSignedCommits bool `json:"require_signed_commits,omitempty"`
26 RequireChecks bool `json:"require_checks,omitempty"`
2627 GitDaemon bool `json:"git_daemon,omitempty"`
2728 }
2829
internal/store/statuses.go added +62
@@ -0,0 +1,62 @@
1package store
2
3type 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.
13func (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.
26func (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.
48func 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); }
5757 .badge-signed_key_expired { color: var(--warn); border-color: var(--warn); }
5858 .badge-signed_key_revoked { color: var(--bad); border-color: var(--bad); }
5959 .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 @@
22 {{define "content"}}
33 {{template "repoheader" .}}
44 <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>
66 <p>author: {{.AuthorName}} &lt;{{.AuthorEmail}}&gt; · {{.Date}}
77 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p>
88 <pre class="message">{{.Message}}</pre>
internal/web/templates/mr.html +2
@@ -5,6 +5,8 @@
55 <p class="crumbs">by {{.MR.Author}} · {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} → {{.MR.TargetRef}}
66 @ <code>{{.MR.HeadSHA}}</code></p>
77 {{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}}
810 {{range .Reviews}}<p>review: {{.Reviewer}} — {{.Verdict}}{{if .Stale}} <span class="badge badge-signed_key_expired">stale</span>{{end}}</p>{{end}}
911 {{range .Comments}}
1012 <div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div></div>