Commit 97bab07d1a
Verified · cmc
e2e/releaseweb_test.go added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "net/url" | |
| 5 | "os" | |
| 6 | "path/filepath" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | // TestReleaseAndBuildWeb creates and edits a release and triggers a build | |
| 12 | // from the browser, each through the command the CLI runs. | |
| 13 | func TestReleaseAndBuildWeb(t *testing.T) { | |
| 14 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | |
| 15 | inst.runner = buildRunner(t) | |
| 16 | aliceKey := inst.newKey(t, "alice") | |
| 17 | inst.admin(t, "admin", "user", "create", "alice", | |
| 18 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 19 | runnerKey := inst.newKey(t, "ci") | |
| 20 | inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin") | |
| 21 | ||
| 22 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 23 | t.Fatalf("repo create: %s", errOut) | |
| 24 | } | |
| 25 | env := inst.gitEnv(aliceKey) | |
| 26 | work := t.TempDir() | |
| 27 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 28 | dir := filepath.Join(work, "w") | |
| 29 | os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755) | |
| 30 | os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), | |
| 31 | []byte("jobs:\n smoke:\n steps:\n - echo ok\n"), 0o644) | |
| 32 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644) | |
| 33 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 34 | mustGit(t, dir, env, "add", ".") | |
| 35 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | |
| 36 | mustGit(t, dir, env, "tag", "-a", "v1.0", "-m", "first") | |
| 37 | mustGit(t, dir, env, "push", "-q", "origin", "main", "v1.0") | |
| 38 | ||
| 39 | alice := inst.login(t, aliceKey) | |
| 40 | base := inst.base() + "/alice/app" | |
| 41 | ||
| 42 | // The pushed tag is offered, and creating from it works. | |
| 43 | _, page := browserGet(t, alice, base+"/releases") | |
| 44 | if !strings.Contains(page, `value="v1.0"`) { | |
| 45 | t.Fatalf("tag not offered:\n%s", page) | |
| 46 | } | |
| 47 | if status, _ := browserPost(t, alice, base+"/releases", url.Values{ | |
| 48 | "tag": {"v1.0"}, "title": {"First light"}, "notes": {"the **first** one"}}); status != 200 { | |
| 49 | t.Fatal("release create failed") | |
| 50 | } | |
| 51 | out, _, _ := inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0", "--json") | |
| 52 | if !strings.Contains(out, "First light") || !strings.Contains(out, "the **first** one") { | |
| 53 | t.Fatalf("release not created:\n%s", out) | |
| 54 | } | |
| 55 | ||
| 56 | // Editing keeps the tag and replaces the fields. | |
| 57 | if status, _ := browserPost(t, alice, base+"/releases", url.Values{ | |
| 58 | "action": {"edit"}, "tag": {"v1.0"}, "title": {"Second light"}, "notes": {"revised"}}); status != 200 { | |
| 59 | t.Fatal("release edit failed") | |
| 60 | } | |
| 61 | out, _, _ = inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0", "--json") | |
| 62 | if !strings.Contains(out, "Second light") || !strings.Contains(out, "revised") { | |
| 63 | t.Fatalf("release not edited:\n%s", out) | |
| 64 | } | |
| 65 | // A released tag is no longer offered for creation. | |
| 66 | if _, page = browserGet(t, alice, base+"/releases"); strings.Contains(page, `<option value="v1.0"`) { | |
| 67 | t.Fatalf("released tag still offered:\n%s", page) | |
| 68 | } | |
| 69 | ||
| 70 | // Builds: the job from the config is offered and runs on demand. | |
| 71 | _, bpage := browserGet(t, alice, base+"/builds") | |
| 72 | if !strings.Contains(bpage, `value="smoke"`) { | |
| 73 | t.Fatalf("job not offered:\n%s", bpage) | |
| 74 | } | |
| 75 | if status, _ := browserPost(t, alice, base+"/builds", url.Values{"job": {"smoke"}}); status != 200 { | |
| 76 | t.Fatal("trigger failed") | |
| 77 | } | |
| 78 | inst.runnerOnce(t, runnerKey) | |
| 79 | out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app") | |
| 80 | if !strings.Contains(out, "smoke\tsuccess") { | |
| 81 | t.Fatalf("triggered build did not run:\n%s", out) | |
| 82 | } | |
| 83 | ||
| 84 | // A reader sees neither control. | |
| 85 | bobKey := inst.newKey(t, "bob") | |
| 86 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 87 | bob := inst.login(t, bobKey) | |
| 88 | if _, p := browserGet(t, bob, base+"/builds"); strings.Contains(p, "Run a job now") { | |
| 89 | t.Fatal("reader sees the trigger control") | |
| 90 | } | |
| 91 | if _, p := browserGet(t, bob, base+"/releases"); strings.Contains(p, "New release") { | |
| 92 | t.Fatal("reader sees the release form") | |
| 93 | } | |
| 94 | } | |
internal/httpd/builds.go +19 −2
| @@ -4,6 +4,8 @@ import ( | ||
| 4 | 4 | "net/http" |
| 5 | 5 | "strconv" |
| 6 | 6 | |
| 7 | "gitbay.org/gitbay/internal/ci" | |
| 8 | "gitbay.org/gitbay/internal/gitutil" | |
| 7 | 9 | "gitbay.org/gitbay/internal/store" |
| 8 | 10 | ) |
| 9 | 11 | |
| @@ -14,10 +16,25 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) { | ||
| 14 | 16 | } |
| 15 | 17 | p.Tab = "builds" |
| 16 | 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 | } | |
| 17 | 31 | s.render(w, "builds.html", struct { |
| 18 | 32 | repoPage |
| 19 | Builds []store.Build | |
| 20 | }{p, builds}) | |
| 33 | Builds []store.Build | |
| 34 | Jobs []string | |
| 35 | CanWrite bool | |
| 36 | Notice string | |
| 37 | }{p, builds, jobs, s.canWriteRepo(r, p.Repo), r.URL.Query().Get("e")}) | |
| 21 | 38 | } |
| 22 | 39 | |
| 23 | 40 | func (s *Server) build(w http.ResponseWriter, r *http.Request) { |
internal/httpd/releaseactions.go added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "net/http" | |
| 6 | "net/url" | |
| 7 | "strings" | |
| 8 | ||
| 9 | "gitbay.org/gitbay/internal/store" | |
| 10 | ) | |
| 11 | ||
| 12 | // Release and build actions. As elsewhere, the browser chooses arguments | |
| 13 | // and the command decides: tag existence, access, and the gates around | |
| 14 | // archived repositories all stay in one implementation. | |
| 15 | ||
| 16 | func (s *Server) backTo(w http.ResponseWriter, r *http.Request, page, msg string) { | |
| 17 | dest := fmt.Sprintf("/%s/%s/%s", r.PathValue("owner"), r.PathValue("repo"), page) | |
| 18 | if msg != "" { | |
| 19 | if len(msg) > 300 { | |
| 20 | msg = msg[:300] | |
| 21 | } | |
| 22 | dest += "?e=" + url.QueryEscape(msg) | |
| 23 | } | |
| 24 | http.Redirect(w, r, dest, http.StatusSeeOther) | |
| 25 | } | |
| 26 | ||
| 27 | func (s *Server) releaseSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | |
| 28 | repo := r.PathValue("owner") + "/" + r.PathValue("repo") | |
| 29 | tag := strings.TrimSpace(r.FormValue("tag")) | |
| 30 | title := strings.TrimSpace(r.FormValue("title")) | |
| 31 | notes := strings.TrimSpace(r.FormValue("notes")) | |
| 32 | if tag == "" { | |
| 33 | s.backTo(w, r, "releases", "pick a tag") | |
| 34 | return | |
| 35 | } | |
| 36 | verb := "create" | |
| 37 | if r.FormValue("action") == "edit" { | |
| 38 | verb = "edit" | |
| 39 | } | |
| 40 | argv := []string{"release", verb, repo, tag} | |
| 41 | if title != "" { | |
| 42 | argv = append(argv, "--title", title) | |
| 43 | } | |
| 44 | // edit needs at least one field; create takes the tag alone. | |
| 45 | if notes != "" || verb == "edit" { | |
| 46 | argv = append(argv, "--notes", notes) | |
| 47 | } | |
| 48 | _, msg, ok := s.runControl(u, argv) | |
| 49 | if ok { | |
| 50 | msg = "" | |
| 51 | } | |
| 52 | s.backTo(w, r, "releases", msg) | |
| 53 | } | |
| 54 | ||
| 55 | func (s *Server) buildTriggerSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | |
| 56 | repo := r.PathValue("owner") + "/" + r.PathValue("repo") | |
| 57 | job := strings.TrimSpace(r.FormValue("job")) | |
| 58 | if job == "" { | |
| 59 | s.backTo(w, r, "builds", "pick a job") | |
| 60 | return | |
| 61 | } | |
| 62 | _, msg, ok := s.runControl(u, []string{"build", "trigger", repo, job}) | |
| 63 | if ok { | |
| 64 | msg = "" | |
| 65 | } | |
| 66 | s.backTo(w, r, "builds", msg) | |
| 67 | } | |
internal/httpd/routes.go +4
| @@ -120,6 +120,10 @@ func (s *Server) Routes() []Route { | ||
| 120 | 120 | Handler: s.checkOrigin(s.requireUser(s.issueAssignSubmit))}, |
| 121 | 121 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/milestone", Mutating: true, |
| 122 | 122 | Handler: s.checkOrigin(s.requireUser(s.issueMilestoneSubmit))}, |
| 123 | Route{Method: "POST", Pattern: "/{owner}/{repo}/releases", Mutating: true, | |
| 124 | Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))}, | |
| 125 | Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true, | |
| 126 | Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))}, | |
| 123 | 127 | Route{Method: "GET", Pattern: "/{owner}/{repo}/settings", |
| 124 | 128 | Handler: s.requireUser(s.settingsForm)}, |
| 125 | 129 | Route{Method: "POST", Pattern: "/{owner}/{repo}/settings", Mutating: true, |
internal/httpd/web.go +18 −2
| @@ -136,7 +136,7 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) { | ||
| 136 | 136 | Host string |
| 137 | 137 | Accounts bool |
| 138 | 138 | Signup bool |
| 139 | }{basePage{Site: s.siteName()}, host, s.cfg.Web.Mode == "accounts", | |
| 139 | }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts", | |
| 140 | 140 | s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"}) |
| 141 | 141 | } |
| 142 | 142 | |
| @@ -566,10 +566,26 @@ func (s *Server) releases(w http.ResponseWriter, r *http.Request) { | ||
| 566 | 566 | for _, rel := range rels { |
| 567 | 567 | views = append(views, relView{rel, md(rel.Notes)}) |
| 568 | 568 | } |
| 569 | // Tags without a release yet are what a create form can offer. | |
| 570 | released := map[string]bool{} | |
| 571 | for _, rel := range rels { | |
| 572 | released[rel.Tag] = true | |
| 573 | } | |
| 574 | var freeTags []string | |
| 575 | if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil { | |
| 576 | for _, tg := range tags { | |
| 577 | if !released[tg.Name] { | |
| 578 | freeTags = append(freeTags, tg.Name) | |
| 579 | } | |
| 580 | } | |
| 581 | } | |
| 569 | 582 | s.render(w, "releases.html", struct { |
| 570 | 583 | repoPage |
| 571 | 584 | Releases []relView |
| 572 | }{p, views}) | |
| 585 | FreeTags []string | |
| 586 | CanWrite bool | |
| 587 | Notice string | |
| 588 | }{p, views, freeTags, s.canWriteRepo(r, p.Repo), r.URL.Query().Get("e")}) | |
| 573 | 589 | } |
| 574 | 590 | |
| 575 | 591 | // releaseAsset streams one uploaded asset. Tags containing '/' are not |
internal/web/templates/builds.html +10
| @@ -1,6 +1,16 @@ | ||
| 1 | 1 | {{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 3 | <h1>Builds</h1> |
| 4 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | |
| 5 | {{if and .CanWrite .Jobs}} | |
| 6 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds" class="setform"> | |
| 7 | <label for="job">Run a job now</label> | |
| 8 | <select id="job" name="job"> | |
| 9 | {{range .Jobs}}<option value="{{.}}">{{.}}</option>{{end}} | |
| 10 | </select> | |
| 11 | <button type="submit" class="primary">Run</button> | |
| 12 | </form> | |
| 13 | {{end}} | |
| 4 | 14 | <details class="editbox"><summary>Status badge</summary> |
| 5 | 15 | <p class="meta">Paste into a README; it shows the newest build's state.</p> |
| 6 | 16 | <pre class="code">[](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre> |
internal/web/templates/releases.html +24
| @@ -1,6 +1,22 @@ | ||
| 1 | 1 | {{define "title"}}releases · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 3 | <h1>Releases</h1> |
| 4 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | |
| 5 | {{if and .CanWrite .FreeTags}} | |
| 6 | <details class="editbox"><summary>New release</summary> | |
| 7 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/releases" class="commentform"> | |
| 8 | <p class="branchpick"> | |
| 9 | <label for="tag">Tag</label> | |
| 10 | <select id="tag" name="tag" required> | |
| 11 | {{range .FreeTags}}<option value="{{.}}">{{.}}</option>{{end}} | |
| 12 | </select> | |
| 13 | </p> | |
| 14 | <p><input type="text" name="title" aria-label="Title" placeholder="title (defaults to the tag)"></p> | |
| 15 | <p><textarea name="notes" aria-label="Notes" rows="6" placeholder="what changed"></textarea></p> | |
| 16 | <p><button type="submit" class="primary">Create release</button></p> | |
| 17 | </form> | |
| 18 | </details> | |
| 19 | {{else if .CanWrite}}<p class="meta">Push a tag to create a release from it.</p>{{end}} | |
| 4 | 20 | {{range $rel := .Releases}} |
| 5 | 21 | <article class="release"> |
| 6 | 22 | <header class="releasehead"> |
| @@ -8,6 +24,14 @@ | ||
| 8 | 24 | <p class="meta"><span class="refchip">{{$rel.Tag}}</span> {{if $rel.Author}}{{$rel.Author}} · {{end}}{{when $rel.CreatedAt}} · <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/archive/{{$rel.Tag}}.tar.gz">source tar.gz</a></p> |
| 9 | 25 | </header> |
| 10 | 26 | {{if $rel.NotesHTML}}<div class="rendered">{{$rel.NotesHTML}}</div>{{end}} |
| 27 | {{if $.CanWrite}}<details class="editbox"><summary>Edit</summary> | |
| 28 | <form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases" class="commentform"> | |
| 29 | <input type="hidden" name="action" value="edit"> | |
| 30 | <input type="hidden" name="tag" value="{{$rel.Tag}}"> | |
| 31 | <p><input type="text" name="title" aria-label="Title" value="{{$rel.Title}}"></p> | |
| 32 | <p><textarea name="notes" aria-label="Notes" rows="6">{{$rel.Notes}}</textarea></p> | |
| 33 | <p><button type="submit">Save</button></p> | |
| 34 | </form></details>{{end}} | |
| 11 | 35 | {{if $rel.Assets}}<table class="assets"> |
| 12 | 36 | {{range $rel.Assets}}<tr> |
| 13 | 37 | <td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases/download/{{$rel.Tag}}/{{.Name}}">{{.Name}}</a></td> |