internal/control/status.go
157 lines · 5235 bytes
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)",
17 Usage: "status set <owner/name> <sha> --context <c> --state pending|success|failure|error [--description <d>] [--url <u>]",
18 Flags: []Flag{
19 {"--context", "<c>", "the check this status reports for; ci/ is reserved for the instance's builds", ""},
20 {"--state", "pending|success|failure|error", "the check's outcome", ""},
21 {"--description", "<d>", "short text shown beside the state", ""},
22 {"--url", "<u>", "link to the check's own output", ""},
23 },
24 Examples: []string{
25 "status set krz/gitbay a1b2c3d --context ext/lint --state success",
26 },
27 Run: runStatusSet})
28 register(Command{Path: []string{"status", "list"},
29 Summary: "statuses on a commit",
30 Usage: "status list <owner/name> <sha>",
31 Examples: []string{"status list krz/gitbay a1b2c3d"},
32 ReadOnly: true, Run: runStatusList})
33}
34
35var validStatusState = map[string]bool{"pending": true, "success": true, "failure": true, "error": true}
36
37func runStatusSet(c *Ctx, args []string) int {
38 var path, sha, context, state, description, url string
39 rest := args
40 for i := 0; i < len(rest); i++ {
41 switch rest[i] {
42 case "--context", "--state", "--description", "--url":
43 if i+1 >= len(rest) {
44 return c.fail(protocol.ExitUsage, "%s requires a value", rest[i])
45 }
46 v := rest[i+1]
47 switch rest[i] {
48 case "--context":
49 context = v
50 case "--state":
51 state = v
52 case "--description":
53 description = v
54 case "--url":
55 url = v
56 }
57 i++
58 default:
59 if path == "" {
60 path = rest[i]
61 } else if sha == "" {
62 sha = rest[i]
63 } else {
64 return c.fail(protocol.ExitUsage, "unexpected argument %q", rest[i])
65 }
66 }
67 }
68 if path == "" || sha == "" || context == "" || !validStatusState[state] {
69 return c.usage()
70 }
71 // ci/<job> statuses are the build subsystem's: queued, reused,
72 // skipped and finished by the server itself. A writer who could post
73 // one could mark ci/test green on their own head before, or instead
74 // of, the build (#258). Case-folded, so CI/test is no way around it.
75 if strings.HasPrefix(strings.ToLower(context), "ci/") {
76 return c.fail(protocol.ExitDenied, "the ci/ prefix is reserved for the instance's builds; report under another name, such as ext/%s",
77 strings.TrimPrefix(strings.ToLower(context), "ci/"))
78 }
79 if url != "" && !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
80 return c.fail(protocol.ExitUsage, "--url must be http(s)")
81 }
82 // Reporting a status is a write: CI identities need write access (an
83 // API token with full scope, or an account grant).
84 repo, code := resolveRepo(c, path, policy.CanWrite)
85 if code >= 0 {
86 return code
87 }
88 if code := refuseArchived(c, repo); code >= 0 {
89 return code
90 }
91 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
92 full, err := gitutil.ResolveRef(dir, sha)
93 if err != nil {
94 return c.fail(protocol.ExitNotFound, "no commit %s in %s", sha, repo.Path())
95 }
96 if err := c.Store.SetCommitStatus(repo.ID, full, context, state, description, url, c.User.ID); err != nil {
97 return c.fail(protocol.ExitFailure, "%v", err)
98 }
99 c.Store.RecordEvent(repo.ID, c.User.ID, "status",
100 fmt.Sprintf(`{"sha":%q,"context":%q,"state":%q}`, full, context, state))
101 TryQueuedMergesAt(c.Store, c.Cfg, repo.ID, full)
102 return c.emit(map[string]string{"sha": full, "context": context, "state": state}, func(w io.Writer) {
103 fmt.Fprintf(w, "%s on %.10s: %s\n", context, full, state)
104 })
105}
106
107func runStatusList(c *Ctx, args []string) int {
108 if len(args) != 2 {
109 return c.usage()
110 }
111 repo, code := resolveRepo(c, args[0], policy.CanRead)
112 if code >= 0 {
113 return code
114 }
115 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
116 full, err := gitutil.ResolveRef(dir, args[1])
117 if err != nil {
118 return c.fail(protocol.ExitNotFound, "no commit %s in %s", args[1], repo.Path())
119 }
120 statuses, err := c.Store.ListCommitStatuses(repo.ID, full)
121 if err != nil {
122 return c.fail(protocol.ExitFailure, "%v", err)
123 }
124 type out struct {
125 Context string `json:"context"`
126 State string `json:"state"`
127 Description string `json:"description,omitempty"`
128 URL string `json:"url,omitempty"`
129 Creator string `json:"creator,omitempty"`
130 }
131 var ds []out
132 for _, s := range statuses {
133 ds = append(ds, out{s.Context, s.State, s.Description, s.TargetURL, s.Creator})
134 }
135 d := struct {
136 SHA string `json:"sha"`
137 Combined string `json:"combined"`
138 Statuses []out `json:"statuses"`
139 }{full, combinedOf(statuses), ds}
140 return c.emit(d, func(w io.Writer) {
141 tb := c.table(w, "CONTEXT", "STATE", "DESCRIPTION")
142 tb.row(cText("combined"), cState(orNone(d.Combined)), cText(fmt.Sprintf("%.10s", d.SHA)))
143 for _, x := range ds {
144 tb.row(cText(x.Context), cState(x.State), cFlex(x.Description))
145 }
146 tb.flush()
147 })
148}
149
150func combinedOf(statuses []store.CommitStatus) string { return store.CombinedStatus(statuses) }
151
152func orNone(s string) string {
153 if s == "" {
154 return "no statuses"
155 }
156 return s
157}