Commit fba8034399
Verified · cmc
cmd/gitbay/main.go +1
| @@ -43,6 +43,7 @@ func newRoot() *cobra.Command { | ||
| 43 | 43 | pass("list", "recent builds: <owner/name>", passOpts{server: []string{"build", "list"}, needsRepo: true}), |
| 44 | 44 | pass("show", "one build: <owner/name> <n>", passOpts{server: []string{"build", "show"}, needsRepo: true}), |
| 45 | 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 | 47 | pass("trigger", "queue a job now: <job>", passOpts{server: []string{"build", "trigger"}, needsRepo: true}), |
| 47 | 48 | ), |
| 48 | 49 | pass("dashboard", "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds", |
internal/control/build.go +62 −11
| @@ -24,6 +24,9 @@ func init() { | ||
| 24 | 24 | register(Command{Path: []string{"build", "log"}, |
| 25 | 25 | Summary: "print a build's log: build log <owner/name> <n>", ReadOnly: true, Run: runBuildLog}) |
| 26 | 26 | |
| 27 | register(Command{Path: []string{"build", "jobs"}, | |
| 28 | Summary: "list the jobs a trigger can name: build jobs <owner/name>", ReadOnly: true, Run: runBuildJobs}) | |
| 29 | ||
| 27 | 30 | register(Command{Path: []string{"build", "trigger"}, |
| 28 | 31 | Summary: "queue a job now (scheduled or not): build trigger <owner/name> <job>", Run: runBuildTrigger}) |
| 29 | 32 | // Secrets: set over stdin, listed by name only, injected into the |
| @@ -132,26 +135,74 @@ func runBuildLog(c *Ctx, args []string) int { | ||
| 132 | 135 | return protocol.ExitOK |
| 133 | 136 | } |
| 134 | 137 | |
| 135 | func runBuildTrigger(c *Ctx, args []string) int { | |
| 136 | if len(args) != 2 { | |
| 137 | return c.fail(protocol.ExitUsage, "usage: build trigger <owner/name> <job>") | |
| 138 | } | |
| 139 | repo, code := resolveRepo(c, args[0], policy.CanWrite) | |
| 140 | if code >= 0 { | |
| 141 | return code | |
| 142 | } | |
| 138 | type jobOut struct { | |
| 139 | Name string `json:"name"` | |
| 140 | Schedule string `json:"schedule,omitempty"` | |
| 141 | Tags string `json:"tags,omitempty"` | |
| 142 | } | |
| 143 | ||
| 144 | // repoJobs reads the CI config on the default branch — the same file the | |
| 145 | // scheduler reads — and returns its jobs with the sha they came from. | |
| 146 | func repoJobs(c *Ctx, repo store.Repo) ([]ci.Job, string, int) { | |
| 143 | 147 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) |
| 144 | 148 | sha, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch) |
| 145 | 149 | if err != nil { |
| 146 | return c.fail(protocol.ExitFailure, "resolving %s: %v", repo.DefaultBranch, err) | |
| 150 | return nil, "", c.fail(protocol.ExitFailure, "resolving %s: %v", repo.DefaultBranch, err) | |
| 147 | 151 | } |
| 148 | 152 | raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16) |
| 149 | 153 | if err != nil { |
| 150 | return c.fail(protocol.ExitNotFound, "%s has no %s on %s", repo.Path(), ci.ConfigPath, repo.DefaultBranch) | |
| 154 | return nil, "", c.fail(protocol.ExitNotFound, "%s has no %s on %s", repo.Path(), ci.ConfigPath, repo.DefaultBranch) | |
| 151 | 155 | } |
| 152 | 156 | jobs, err := ci.Parse(raw) |
| 153 | 157 | if err != nil { |
| 154 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 158 | return nil, "", c.fail(protocol.ExitUsage, "%v", err) | |
| 159 | } | |
| 160 | return jobs, sha, -1 | |
| 161 | } | |
| 162 | ||
| 163 | // runBuildJobs answers "what can I trigger?". Without it only a surface | |
| 164 | // that can read the repository's git could offer the choice. | |
| 165 | func runBuildJobs(c *Ctx, args []string) int { | |
| 166 | if len(args) != 1 { | |
| 167 | return c.fail(protocol.ExitUsage, "usage: build jobs <owner/name>") | |
| 168 | } | |
| 169 | repo, code := resolveRepo(c, args[0], policy.CanRead) | |
| 170 | if code >= 0 { | |
| 171 | return code | |
| 172 | } | |
| 173 | jobs, _, code := repoJobs(c, repo) | |
| 174 | if code >= 0 { | |
| 175 | return code | |
| 176 | } | |
| 177 | out := make([]jobOut, 0, len(jobs)) | |
| 178 | for _, j := range jobs { | |
| 179 | out = append(out, jobOut{Name: j.Name, Schedule: j.Schedule, Tags: j.Tags}) | |
| 180 | } | |
| 181 | return c.emit(out, func(w io.Writer) { | |
| 182 | for _, j := range out { | |
| 183 | switch { | |
| 184 | case j.Schedule != "": | |
| 185 | fmt.Fprintf(w, "%s\tschedule %s\n", j.Name, j.Schedule) | |
| 186 | case j.Tags != "": | |
| 187 | fmt.Fprintf(w, "%s\ttags %s\n", j.Name, j.Tags) | |
| 188 | default: | |
| 189 | fmt.Fprintf(w, "%s\ton push\n", j.Name) | |
| 190 | } | |
| 191 | } | |
| 192 | }) | |
| 193 | } | |
| 194 | ||
| 195 | func runBuildTrigger(c *Ctx, args []string) int { | |
| 196 | if len(args) != 2 { | |
| 197 | return c.fail(protocol.ExitUsage, "usage: build trigger <owner/name> <job>") | |
| 198 | } | |
| 199 | repo, code := resolveRepo(c, args[0], policy.CanWrite) | |
| 200 | if code >= 0 { | |
| 201 | return code | |
| 202 | } | |
| 203 | jobs, sha, code := repoJobs(c, repo) | |
| 204 | if code >= 0 { | |
| 205 | return code | |
| 155 | 206 | } |
| 156 | 207 | for _, j := range jobs { |
| 157 | 208 | if j.Name != args[1] { |
internal/control/control_test.go +15
| @@ -54,3 +54,18 @@ func TestLookupLongestMatch(t *testing.T) { | ||
| 54 | 54 | t.Errorf("Lookup keys list --json = %v %v %v", cmd.Path, rest, ok) |
| 55 | 55 | } |
| 56 | 56 | } |
| 57 | ||
| 58 | // TestBuildJobsIsAReadCommand pins the properties the surfaces depend on: | |
| 59 | // the web build page and a read-scoped API token both need it over GET. | |
| 60 | func TestBuildJobsIsAReadCommand(t *testing.T) { | |
| 61 | cmd, _, ok := Lookup([]string{"build", "jobs"}) | |
| 62 | if !ok { | |
| 63 | t.Fatal("build jobs not registered") | |
| 64 | } | |
| 65 | if !cmd.ReadOnly { | |
| 66 | t.Error("build jobs must be ReadOnly; listing jobs changes nothing") | |
| 67 | } | |
| 68 | if cmd.SSHOnly { | |
| 69 | t.Error("build jobs must not be SSHOnly; the web and the app need it") | |
| 70 | } | |
| 71 | } | |
internal/httpd/builds.go +40 −26
| @@ -3,35 +3,46 @@ package httpd | ||
| 3 | 3 | import ( |
| 4 | 4 | "net/http" |
| 5 | 5 | "strconv" |
| 6 | ||
| 7 | "gitbay.org/gitbay/internal/ci" | |
| 8 | "gitbay.org/gitbay/internal/gitutil" | |
| 9 | "gitbay.org/gitbay/internal/store" | |
| 10 | 6 | ) |
| 11 | 7 | |
| 8 | // buildView mirrors the build commands' JSON. The templates read these | |
| 9 | // names; nothing here touches the store. | |
| 10 | type buildView struct { | |
| 11 | Number int64 `json:"number"` | |
| 12 | Job string `json:"job"` | |
| 13 | Status string `json:"status"` | |
| 14 | SHA string `json:"sha"` | |
| 15 | Ref string `json:"ref"` | |
| 16 | CreatedAt string `json:"created_at"` | |
| 17 | FinishedAt string `json:"finished_at"` | |
| 18 | } | |
| 19 | ||
| 20 | type jobView struct { | |
| 21 | Name string `json:"name"` | |
| 22 | Schedule string `json:"schedule"` | |
| 23 | Tags string `json:"tags"` | |
| 24 | } | |
| 25 | ||
| 12 | 26 | func (s *Server) builds(w http.ResponseWriter, r *http.Request) { |
| 13 | 27 | p, ok := s.repoFor(w, r, "") |
| 14 | 28 | if !ok { |
| 15 | 29 | return |
| 16 | 30 | } |
| 17 | 31 | p.Tab = "builds" |
| 18 | builds, _ := s.st.ListBuilds(p.Repo.ID, 50) | |
| 19 | // The jobs a trigger can name come from the config on the default | |
| 20 | // branch, the same file the scheduler reads. | |
| 21 | var jobs []string | |
| 22 | if sha, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+p.Repo.DefaultBranch); err == nil { | |
| 23 | if raw, err := gitutil.ReadBlob(p.Dir, sha, ci.ConfigPath, 1<<16); err == nil { | |
| 24 | if parsed, err := ci.Parse(raw); err == nil { | |
| 25 | for _, j := range parsed { | |
| 26 | jobs = append(jobs, j.Name) | |
| 27 | } | |
| 28 | } | |
| 29 | } | |
| 30 | } | |
| 32 | viewer := s.webViewer(r) | |
| 33 | ||
| 34 | var builds []buildView | |
| 35 | s.runControlInto(viewer, []string{"build", "list", p.Repo.Path()}, &builds) | |
| 36 | ||
| 37 | // The jobs a trigger can name. A repo without a CI config has none; | |
| 38 | // that is not an error for this page. | |
| 39 | var jobs []jobView | |
| 40 | s.runControlInto(viewer, []string{"build", "jobs", p.Repo.Path()}, &jobs) | |
| 41 | ||
| 31 | 42 | s.render(w, "builds.html", struct { |
| 32 | 43 | repoPage |
| 33 | Builds []store.Build | |
| 34 | Jobs []string | |
| 44 | Builds []buildView | |
| 45 | Jobs []jobView | |
| 35 | 46 | CanWrite bool |
| 36 | 47 | Notice string |
| 37 | 48 | }{p, builds, jobs, s.canWriteRepo(r, p.Repo), r.URL.Query().Get("e")}) |
| @@ -43,20 +54,23 @@ func (s *Server) build(w http.ResponseWriter, r *http.Request) { | ||
| 43 | 54 | return |
| 44 | 55 | } |
| 45 | 56 | p.Tab = "builds" |
| 46 | n, err := strconv.ParseInt(r.PathValue("n"), 10, 64) | |
| 47 | if err != nil { | |
| 57 | if _, err := strconv.ParseInt(r.PathValue("n"), 10, 64); err != nil { | |
| 48 | 58 | s.notFound(w, r) |
| 49 | 59 | return |
| 50 | 60 | } |
| 51 | b, err := s.st.BuildByNumber(p.Repo.ID, n) | |
| 52 | if err != nil { | |
| 61 | n := r.PathValue("n") | |
| 62 | viewer := s.webViewer(r) | |
| 63 | ||
| 64 | var b buildView | |
| 65 | if _, ok := s.runControlInto(viewer, []string{"build", "show", p.Repo.Path(), n}, &b); !ok { | |
| 53 | 66 | s.notFound(w, r) |
| 54 | 67 | return |
| 55 | 68 | } |
| 56 | log, _ := s.st.BuildLog(b.ID) | |
| 69 | log, _, _ := s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n}) | |
| 70 | ||
| 57 | 71 | s.render(w, "build.html", struct { |
| 58 | 72 | repoPage |
| 59 | Build store.Build | |
| 73 | Build buildView | |
| 60 | 74 | Log string |
| 61 | }{p, b, string(log)}) | |
| 75 | }{p, b, log}) | |
| 62 | 76 | } |
internal/httpd/control.go +10
| @@ -3,6 +3,7 @@ package httpd | ||
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | 5 | "encoding/json" |
| 6 | "net/http" | |
| 6 | 7 | "strings" |
| 7 | 8 | |
| 8 | 9 | "gitbay.org/gitbay/internal/control" |
| @@ -213,3 +214,12 @@ func (s *Server) namedTip(c gitutil.EntryCommit) namedCommit { | ||
| 213 | 214 | c.Author = names.name(c.Email, c.Author) |
| 214 | 215 | return namedCommit{EntryCommit: c, User: user} |
| 215 | 216 | } |
| 217 | ||
| 218 | // webViewer is the account behind a page request, or the zero user when | |
| 219 | // the instance serves the web without accounts. | |
| 220 | func (s *Server) webViewer(r *http.Request) store.User { | |
| 221 | if s.cfg.Web.Mode != "accounts" { | |
| 222 | return store.User{} | |
| 223 | } | |
| 224 | return s.viewer(r) | |
| 225 | } | |
internal/web/templates/builds.html +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds" class="setform"> |
| 7 | 7 | <label for="job">Run a job now</label> |
| 8 | 8 | <select id="job" name="job"> |
| 9 | {{range .Jobs}}<option value="{{.}}">{{.}}</option>{{end}} | |
| 9 | {{range .Jobs}}<option value="{{.Name}}">{{.Name}}{{if .Schedule}} (schedule {{.Schedule}}){{else if .Tags}} (tags {{.Tags}}){{end}}</option>{{end}} | |
| 10 | 10 | </select> |
| 11 | 11 | <button type="submit" class="primary">Run</button> |
| 12 | 12 | </form> |