A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit fba8034399

fba8034399d2aa9a49dd0c9c177d1cd3429f5199

parent: ff6271a9d4

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-28T05:21:33Z

build: a jobs command, and build pages that dispatch it

The web read builds from the store and parsed .gitbay/ci.yml from git
inline to fill the trigger picker, so the job list was reachable only
from a browser. `build jobs <owner/name>` reads the same config the
scheduler reads and returns each job with its schedule or tag glob.

Both build pages now dispatch build list/show/log/jobs. repoJobs is the
one reader; trigger shares it.
cmd/gitbay/main.go +1
@@ -43,6 +43,7 @@ func newRoot() *cobra.Command {
4343 pass("list", "recent builds: <owner/name>", passOpts{server: []string{"build", "list"}, needsRepo: true}),
4444 pass("show", "one build: <owner/name> <n>", passOpts{server: []string{"build", "show"}, needsRepo: true}),
4545 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}),
4647 pass("trigger", "queue a job now: <job>", passOpts{server: []string{"build", "trigger"}, needsRepo: true}),
4748 ),
4849 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() {
2424 register(Command{Path: []string{"build", "log"},
2525 Summary: "print a build's log: build log <owner/name> <n>", ReadOnly: true, Run: runBuildLog})
2626
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
2730 register(Command{Path: []string{"build", "trigger"},
2831 Summary: "queue a job now (scheduled or not): build trigger <owner/name> <job>", Run: runBuildTrigger})
2932 // Secrets: set over stdin, listed by name only, injected into the
@@ -132,26 +135,74 @@ func runBuildLog(c *Ctx, args []string) int {
132135 return protocol.ExitOK
133136 }
134137
135func 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 }
138type 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.
146func repoJobs(c *Ctx, repo store.Repo) ([]ci.Job, string, int) {
143147 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
144148 sha, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch)
145149 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)
147151 }
148152 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
149153 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)
151155 }
152156 jobs, err := ci.Parse(raw)
153157 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.
165func 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
195func 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
155206 }
156207 for _, j := range jobs {
157208 if j.Name != args[1] {
internal/control/control_test.go +15
@@ -54,3 +54,18 @@ func TestLookupLongestMatch(t *testing.T) {
5454 t.Errorf("Lookup keys list --json = %v %v %v", cmd.Path, rest, ok)
5555 }
5656 }
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.
60func 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
33 import (
44 "net/http"
55 "strconv"
6
7 "gitbay.org/gitbay/internal/ci"
8 "gitbay.org/gitbay/internal/gitutil"
9 "gitbay.org/gitbay/internal/store"
106 )
117
8// buildView mirrors the build commands' JSON. The templates read these
9// names; nothing here touches the store.
10type 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
20type jobView struct {
21 Name string `json:"name"`
22 Schedule string `json:"schedule"`
23 Tags string `json:"tags"`
24}
25
1226 func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
1327 p, ok := s.repoFor(w, r, "")
1428 if !ok {
1529 return
1630 }
1731 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
3142 s.render(w, "builds.html", struct {
3243 repoPage
33 Builds []store.Build
34 Jobs []string
44 Builds []buildView
45 Jobs []jobView
3546 CanWrite bool
3647 Notice string
3748 }{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) {
4354 return
4455 }
4556 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 {
4858 s.notFound(w, r)
4959 return
5060 }
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 {
5366 s.notFound(w, r)
5467 return
5568 }
56 log, _ := s.st.BuildLog(b.ID)
69 log, _, _ := s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n})
70
5771 s.render(w, "build.html", struct {
5872 repoPage
59 Build store.Build
73 Build buildView
6074 Log string
61 }{p, b, string(log)})
75 }{p, b, log})
6276 }
internal/httpd/control.go +10
@@ -3,6 +3,7 @@ package httpd
33 import (
44 "bytes"
55 "encoding/json"
6 "net/http"
67 "strings"
78
89 "gitbay.org/gitbay/internal/control"
@@ -213,3 +214,12 @@ func (s *Server) namedTip(c gitutil.EntryCommit) namedCommit {
213214 c.Author = names.name(c.Email, c.Author)
214215 return namedCommit{EntryCommit: c, User: user}
215216 }
217
218// webViewer is the account behind a page request, or the zero user when
219// the instance serves the web without accounts.
220func (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 @@
66 <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds" class="setform">
77 <label for="job">Run a job now</label>
88 <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}}
1010 </select>
1111 <button type="submit" class="primary">Run</button>
1212 </form>