web: releases, builds, and a login page that names the host !72
11 files changed, +267 −11
Layout: unified · split
e2e/accounts_test.go +21
| @@ -217,3 +217,24 @@ func TestViewOnlyHasNoLoginOnTheWire(t *testing.T) { | |||
| 217 | t.Fatalf("web login in view_only: exit %d, %s", code, errOut) | 217 | t.Fatalf("web login in view_only: exit %d, %s", code, errOut) |
| 218 | } | 218 | } |
| 219 | } | 219 | } |
| 220 | |||
| 221 | // TestTitleIsNotAHostname pins the split between the instance's display name | ||
| 222 | // and its hostname: the login page prints a command to paste into a terminal, | ||
| 223 | // so it must name the host even when the operator has set a display title. | ||
| 224 | func TestTitleIsNotAHostname(t *testing.T) { | ||
| 225 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\ntitle = \"GitBay\"\n") | ||
| 226 | |||
| 227 | status, body := inst.get(t, "/login") | ||
| 228 | if status != 200 { | ||
| 229 | t.Fatalf("/login = %d", status) | ||
| 230 | } | ||
| 231 | if strings.Contains(body, "ssh git@GitBay") { | ||
| 232 | t.Fatal("login page tells you to ssh to the display title") | ||
| 233 | } | ||
| 234 | if !strings.Contains(body, "ssh git@gitbay.test web login") { | ||
| 235 | t.Fatalf("login page does not name the host:\n%s", body) | ||
| 236 | } | ||
| 237 | if !strings.Contains(body, "GitBay") { | ||
| 238 | t.Fatal("login page dropped the display title entirely") | ||
| 239 | } | ||
| 240 | } | ||
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/accounts.go +4 −4
| @@ -66,7 +66,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { | |||
| 66 | s.render(w, "login.html", struct { | 66 | s.render(w, "login.html", struct { |
| 67 | basePage | 67 | basePage |
| 68 | Error string | 68 | Error string |
| 69 | }{basePage{Site: s.siteName()}, ""}) | 69 | }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, ""}) |
| 70 | return | 70 | return |
| 71 | } | 71 | } |
| 72 | userID, err := s.st.ConsumeLoginToken(store.HashToken(token)) | 72 | userID, err := s.st.ConsumeLoginToken(store.HashToken(token)) |
| @@ -74,7 +74,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { | |||
| 74 | s.render(w, "login.html", struct { | 74 | s.render(w, "login.html", struct { |
| 75 | basePage | 75 | basePage |
| 76 | Error string | 76 | Error string |
| 77 | }{basePage{Site: s.siteName()}, | 77 | }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, |
| 78 | "that login link is invalid, expired, or already used — mint a new one"}) | 78 | "that login link is invalid, expired, or already used — mint a new one"}) |
| 79 | return | 79 | return |
| 80 | } | 80 | } |
| @@ -226,7 +226,7 @@ func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) { | |||
| 226 | Mode string // open | invite | 226 | Mode string // open | invite |
| 227 | Error string | 227 | Error string |
| 228 | Username string | 228 | Username string |
| 229 | }{basePage{Site: s.siteName()}, s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username}) | 229 | }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username}) |
| 230 | } | 230 | } |
| 231 | 231 | ||
| 232 | func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) { | 232 | func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) { |
| @@ -248,7 +248,7 @@ func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) { | |||
| 248 | Username string | 248 | Username string |
| 249 | Message string | 249 | Message string |
| 250 | Host string | 250 | Host string |
| 251 | }{basePage{Site: s.siteName()}, username, msg, s.cfg.SiteHost()}) | 251 | }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, username, msg, s.cfg.SiteHost()}) |
| 252 | } | 252 | } |
| 253 | 253 | ||
| 254 | // issueCreateForm renders the new-issue form, prefilled from the repo's | 254 | // issueCreateForm renders the new-issue form, prefilled from the repo's |
internal/httpd/builds.go +19 −2
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | "net/http" | 4 | "net/http" |
| 5 | "strconv" | 5 | "strconv" |
| 6 | 6 | ||
| 7 | "gitbay.org/gitbay/internal/ci" | ||
| 8 | "gitbay.org/gitbay/internal/gitutil" | ||
| 7 | "gitbay.org/gitbay/internal/store" | 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 | p.Tab = "builds" | 17 | p.Tab = "builds" |
| 16 | builds, _ := s.st.ListBuilds(p.Repo.ID, 50) | 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 | s.render(w, "builds.html", struct { | 31 | s.render(w, "builds.html", struct { |
| 18 | repoPage | 32 | repoPage |
| 19 | Builds []store.Build | 33 | Builds []store.Build |
| 20 | }{p, builds}) | 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 | func (s *Server) build(w http.ResponseWriter, r *http.Request) { | 40 | func (s *Server) build(w http.ResponseWriter, r *http.Request) { |
internal/httpd/page.go +5 −2
| @@ -36,7 +36,10 @@ func (r rail) Empty() bool { return len(r.Pinned) == 0 && len(r.Reviews) == 0 } | |||
| 36 | // basePage is what the layout needs on every page, repo or not. Page | 36 | // basePage is what the layout needs on every page, repo or not. Page |
| 37 | // structs embed it so the rail and the site name are always in scope. | 37 | // structs embed it so the rail and the site name are always in scope. |
| 38 | type basePage struct { | 38 | type basePage struct { |
| 39 | // Site is the instance's display name; Host is the name a command or | ||
| 40 | // URL must use. Anything copy-pasteable takes Host. | ||
| 39 | Site string | 41 | Site string |
| 42 | Host string | ||
| 40 | Viewer string | 43 | Viewer string |
| 41 | Rail rail | 44 | Rail rail |
| 42 | } | 45 | } |
| @@ -45,7 +48,7 @@ type basePage struct { | |||
| 45 | // resolved a viewer. | 48 | // resolved a viewer. |
| 46 | func (s *Server) base(r *http.Request) basePage { | 49 | func (s *Server) base(r *http.Request) basePage { |
| 47 | if s.cfg.Web.Mode != "accounts" { | 50 | if s.cfg.Web.Mode != "accounts" { |
| 48 | return basePage{Site: s.siteName()} | 51 | return basePage{Site: s.siteName(), Host: s.cfg.SiteHost()} |
| 49 | } | 52 | } |
| 50 | return s.baseFor(s.viewer(r)) | 53 | return s.baseFor(s.viewer(r)) |
| 51 | } | 54 | } |
| @@ -53,7 +56,7 @@ func (s *Server) base(r *http.Request) basePage { | |||
| 53 | // baseFor is base for a handler that already holds the viewer, so the | 56 | // baseFor is base for a handler that already holds the viewer, so the |
| 54 | // session lookup is not repeated. | 57 | // session lookup is not repeated. |
| 55 | func (s *Server) baseFor(viewer store.User) basePage { | 58 | func (s *Server) baseFor(viewer store.User) basePage { |
| 56 | b := basePage{Site: s.siteName()} | 59 | b := basePage{Site: s.siteName(), Host: s.cfg.SiteHost()} |
| 57 | if viewer.ID == 0 { | 60 | if viewer.ID == 0 { |
| 58 | return b | 61 | return b |
| 59 | } | 62 | } |
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 | Handler: s.checkOrigin(s.requireUser(s.issueAssignSubmit))}, | 120 | Handler: s.checkOrigin(s.requireUser(s.issueAssignSubmit))}, |
| 121 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/milestone", Mutating: true, | 121 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/milestone", Mutating: true, |
| 122 | Handler: s.checkOrigin(s.requireUser(s.issueMilestoneSubmit))}, | 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 | Route{Method: "GET", Pattern: "/{owner}/{repo}/settings", | 127 | Route{Method: "GET", Pattern: "/{owner}/{repo}/settings", |
| 124 | Handler: s.requireUser(s.settingsForm)}, | 128 | Handler: s.requireUser(s.settingsForm)}, |
| 125 | Route{Method: "POST", Pattern: "/{owner}/{repo}/settings", Mutating: true, | 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 | Host string | 136 | Host string |
| 137 | Accounts bool | 137 | Accounts bool |
| 138 | Signup bool | 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 | s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"}) | 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 | for _, rel := range rels { | 566 | for _, rel := range rels { |
| 567 | views = append(views, relView{rel, md(rel.Notes)}) | 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 | s.render(w, "releases.html", struct { | 582 | s.render(w, "releases.html", struct { |
| 570 | repoPage | 583 | repoPage |
| 571 | Releases []relView | 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 | // releaseAsset streams one uploaded asset. Tags containing '/' are not | 591 | // releaseAsset streams one uploaded asset. Tags containing '/' are not |
internal/web/templates/builds.html +10
| @@ -1,6 +1,16 @@ | |||
| 1 | {{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 1 | {{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 2 | {{define "content"}} |
| 3 | <h1>Builds</h1> | 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 | <details class="editbox"><summary>Status badge</summary> | 14 | <details class="editbox"><summary>Status badge</summary> |
| 5 | <p class="meta">Paste into a README; it shows the newest build's state.</p> | 15 | <p class="meta">Paste into a README; it shows the newest build's state.</p> |
| 6 | <pre class="code">[](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre> | 16 | <pre class="code">[](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre> |
internal/web/templates/login.html +1 −1
| @@ -4,6 +4,6 @@ | |||
| 4 | {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} | 4 | {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} |
| 5 | <p>Browser sessions are minted over SSH — there is no password. From a machine | 5 | <p>Browser sessions are minted over SSH — there is no password. From a machine |
| 6 | with your registered key:</p> | 6 | with your registered key:</p> |
| 7 | <pre class="message">ssh git@{{.Site}} web login</pre> | 7 | <pre class="message">ssh git@{{.Host}} web login</pre> |
| 8 | <p>then open the printed URL within five minutes.</p> | 8 | <p>then open the printed URL within five minutes.</p> |
| 9 | {{end}} | 9 | {{end}} |
internal/web/templates/releases.html +24
| @@ -1,6 +1,22 @@ | |||
| 1 | {{define "title"}}releases · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 1 | {{define "title"}}releases · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 2 | {{define "content"}} |
| 3 | <h1>Releases</h1> | 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 | {{range $rel := .Releases}} | 20 | {{range $rel := .Releases}} |
| 5 | <article class="release"> | 21 | <article class="release"> |
| 6 | <header class="releasehead"> | 22 | <header class="releasehead"> |
| @@ -8,6 +24,14 @@ | |||
| 8 | <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> | 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 | </header> | 25 | </header> |
| 10 | {{if $rel.NotesHTML}}<div class="rendered">{{$rel.NotesHTML}}</div>{{end}} | 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 | {{if $rel.Assets}}<table class="assets"> | 35 | {{if $rel.Assets}}<table class="assets"> |
| 12 | {{range $rel.Assets}}<tr> | 36 | {{range $rel.Assets}}<tr> |
| 13 | <td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases/download/{{$rel.Tag}}/{{.Name}}">{{.Name}}</a></td> | 37 | <td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases/download/{{$rel.Tag}}/{{.Name}}">{{.Name}}</a></td> |