Commit 1cb771a8f3
Verified · cmc
Layout: unified · split
.gitbay/wiki/Parity.org +12 −10
| @@ -126,9 +126,9 @@ reviews, since an approval was of the diff against the old branch. | ||
| 126 | 126 | | choose body markup | yes | yes | yes | |
| 127 | 127 | | preview body markup | n/a | yes | no | |
| 128 | 128 | | issue templates | yes | yes | yes | |
| 129 | | milestone create, close, reopen | yes | no | yes | | |
| 130 | | org labels: set, list, remove | yes | list | yes | | |
| 131 | | org milestones: create, list, close, reopen | yes | list | yes | | |
| 129 | | milestone create, close, reopen | yes | yes | yes | | |
| 130 | | org labels: set, list, remove | yes | yes | yes | | |
| 131 | | org milestones: create, list, close, reopen | yes | yes | yes | | |
| 132 | 132 | | closes across repositories | yes | yes | yes | |
| 133 | 133 | |
| 134 | 134 | Labels are created on the fly by =issue label --add= and =mr label |
| @@ -139,11 +139,13 @@ chip and derives one from the name when none is set. The set itself is | ||
| 139 | 139 | at =/<owner>/<repo>/labels=, linked from the issue list: create, |
| 140 | 140 | recolour and remove, dispatching the same commands. |
| 141 | 141 | |
| 142 | Org labels and milestones are managed on the CLI, the API and the iOS | |
| 143 | client's org screen; =/<org>/-/labels= and =/<org>/-/milestones= show | |
| 144 | them on the web. The repository | |
| 145 | label page's form exists for colour alone, and three org forms nobody | |
| 146 | asked for were not worth their handlers. | |
| 142 | Org labels and milestones are managed at =/<org>/-/labels= and | |
| 143 | =/<org>/-/milestones=, where org admins see the create, colour, remove, | |
| 144 | close and reopen forms and everyone else sees the list. The repository | |
| 145 | milestones page carries create, close and reopen for writers; a | |
| 146 | milestone the org holds shows no buttons there. Uploading a release | |
| 147 | asset from the releases page streams the file to =release asset add= on | |
| 148 | stdin, capped at =max_asset_bytes=. | |
| 147 | 149 | |
| 148 | 150 | Issue, MR and release bodies, and their comments, carry the markup they |
| 149 | 151 | were written in — =--format md|org= on create, comment and edit, stored |
| @@ -234,8 +236,8 @@ rather than the one the web page shows. | ||
| 234 | 236 | | delete, transfer | yes | yes | no | |
| 235 | 237 | | rename | yes | yes | yes | |
| 236 | 238 | | release delete | yes | yes | yes | |
| 237 | | release asset add | yes | no | n/a | | |
| 238 | | release asset remove | yes | no | yes | | |
| 239 | | release asset add | yes | yes | n/a | | |
| 240 | | release asset remove | yes | yes | yes | | |
| 239 | 241 | | snippet create, edit, delete | yes | yes | yes | |
| 240 | 242 | | snippet show, list | yes | yes | yes | |
| 241 | 243 | | snippet file set, get, remove | yes | yes | yes | |
.gitbay/wiki/Users.org +7 −3
| @@ -393,7 +393,9 @@ gitbay release list / show v1.0 / delete v1.0 --yes | ||
| 393 | 393 | #+end_src |
| 394 | 394 | |
| 395 | 395 | The web shows them under the repository's =releases= tab with rendered |
| 396 | notes, sha256 sums, and download links. Feed readers subscribe at | |
| 396 | notes, sha256 sums, and download links; writers add and remove assets | |
| 397 | there (a file upload, up to =max_asset_bytes=; removal asks for the file | |
| 398 | name). Feed readers subscribe at | |
| 397 | 399 | =/you/project/releases.atom=; commits are at =/you/project/log.atom= |
| 398 | 400 | (the default branch) or =/you/project/log.atom/<ref>=, and an owner's |
| 399 | 401 | activity on their public repositories at =/you/activity.atom=. The |
| @@ -414,7 +416,8 @@ repository there. A bare =owner/name#N= links and does nothing. | ||
| 414 | 416 | |
| 415 | 417 | Milestones group issues and MRs toward a release (write access to |
| 416 | 418 | manage, attach with =issue milestone= / =mr milestone=; progress shows |
| 417 | on the web at =/owner/name/milestones=): | |
| 419 | on the web at =/owner/name/milestones=, where writers create, close and | |
| 420 | reopen them): | |
| 418 | 421 | |
| 419 | 422 | #+begin_src sh |
| 420 | 423 | gitbay milestone create v1.0 --description "first release" --due 2027-01-01 |
| @@ -428,7 +431,8 @@ milestone= and =mr milestone= resolve the org's row first; a repository | ||
| 428 | 431 | cannot create a label or milestone with a name its org holds. Creating |
| 429 | 432 | an org label or milestone whose name repositories under the org already |
| 430 | 433 | use folds them in: their issues and merge requests move to the org's |
| 431 | row. Org admins manage them; counts span the repositories you can read. | |
| 434 | row. Org admins manage them, on the CLI or at =/<org>/-/labels= and | |
| 435 | =/<org>/-/milestones=; counts span the repositories you can read. | |
| 432 | 436 | |
| 433 | 437 | #+begin_src sh |
| 434 | 438 | gitbay org label set acme bug --color cf222e |
CHANGELOG.org +5
| @@ -6,6 +6,11 @@ anything beyond "replace the binary and restart" is needed. | ||
| 6 | 6 | |
| 7 | 7 | * Unreleased |
| 8 | 8 | |
| 9 | - The releases page uploads and removes release assets (the file is | |
| 10 | streamed to =release asset add=; removal asks for the name). The | |
| 11 | repository milestones page creates, closes and reopens milestones, and | |
| 12 | the org labels and milestones pages give org admins set, remove, | |
| 13 | create, close and reopen (#296). | |
| 9 | 14 | - The repository settings page gains access grants and effective access, |
| 10 | 15 | webhooks (add, remove, deliveries, redeliver; the secret is a form |
| 11 | 16 | field passed on stdin and never shown again), rename, transfer and |
e2e/assetweb_test.go added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "mime/multipart" | |
| 6 | "os" | |
| 7 | "path/filepath" | |
| 8 | "strings" | |
| 9 | "testing" | |
| 10 | ) | |
| 11 | ||
| 12 | // TestReleaseAssetUploadFromTheWeb uploads a release asset through the | |
| 13 | // releases page's multipart form. The bytes match what the CLI reads back | |
| 14 | // and what the download route serves, and an upload over max_asset_bytes | |
| 15 | // stores nothing (#296). | |
| 16 | func TestReleaseAssetUploadFromTheWeb(t *testing.T) { | |
| 17 | t.Parallel() | |
| 18 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n[limits]\nmax_asset_bytes = 4096\n") | |
| 19 | aliceKey := inst.newKey(t, "alice") | |
| 20 | inst.admin(t, "admin", "user", "create", "alice", | |
| 21 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 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.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644) | |
| 30 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 31 | mustGit(t, dir, env, "add", ".") | |
| 32 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | |
| 33 | mustGit(t, dir, env, "tag", "-a", "v1.0", "-m", "first") | |
| 34 | mustGit(t, dir, env, "push", "-q", "origin", "main", "v1.0") | |
| 35 | if _, errOut, code := inst.ssh(t, aliceKey, "", "release", "create", "alice/app", "v1.0"); code != 0 { | |
| 36 | t.Fatalf("release create: %s", errOut) | |
| 37 | } | |
| 38 | alice := inst.login(t, aliceKey) | |
| 39 | base := inst.base() + "/alice/app" | |
| 40 | ||
| 41 | send := func(name string, data []byte) (int, string) { | |
| 42 | var buf bytes.Buffer | |
| 43 | mw := multipart.NewWriter(&buf) | |
| 44 | mw.WriteField("tag", "v1.0") | |
| 45 | fw, _ := mw.CreateFormFile("file", name) | |
| 46 | fw.Write(data) | |
| 47 | mw.Close() | |
| 48 | resp, err := alice.Post(base+"/releases/assets", mw.FormDataContentType(), &buf) | |
| 49 | if err != nil { | |
| 50 | t.Fatal(err) | |
| 51 | } | |
| 52 | defer resp.Body.Close() | |
| 53 | var out bytes.Buffer | |
| 54 | out.ReadFrom(resp.Body) | |
| 55 | return resp.StatusCode, out.String() | |
| 56 | } | |
| 57 | ||
| 58 | payload := []byte("BINARY\x00\x01\x02 asset from the browser\n") | |
| 59 | if status, page := send("tool-linux-amd64", payload); status != 200 || !strings.Contains(page, "tool-linux-amd64") { | |
| 60 | t.Fatalf("upload: %d\n%s", status, page) | |
| 61 | } | |
| 62 | got, _, code := inst.ssh(t, aliceKey, "", "release", "asset", "get", "alice/app", "v1.0", "tool-linux-amd64") | |
| 63 | if code != 0 || got != string(payload) { | |
| 64 | t.Fatalf("CLI read back %q (exit %d)", got, code) | |
| 65 | } | |
| 66 | if status, body := browserGet(t, alice, base+"/releases/download/v1.0/tool-linux-amd64"); status != 200 || body != string(payload) { | |
| 67 | t.Fatalf("download: %d %q", status, body) | |
| 68 | } | |
| 69 | ||
| 70 | if _, page := send("big.bin", bytes.Repeat([]byte("x"), 8192)); !strings.Contains(page, "max_asset_bytes") { | |
| 71 | t.Fatalf("oversized upload not refused:\n%s", page) | |
| 72 | } | |
| 73 | out, _, _ := inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0", "--json") | |
| 74 | if strings.Contains(out, "big.bin") { | |
| 75 | t.Fatalf("oversized asset stored: %s", out) | |
| 76 | } | |
| 77 | ||
| 78 | if status, _ := browserPost(t, alice, base+"/releases/assets", map[string][]string{ | |
| 79 | "action": {"remove"}, "tag": {"v1.0"}, "name": {"tool-linux-amd64"}, "confirm": {"tool-linux-amd64"}}); status != 200 { | |
| 80 | t.Fatal("remove failed") | |
| 81 | } | |
| 82 | if out, _, _ = inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0", "--json"); strings.Contains(out, "tool-linux-amd64") { | |
| 83 | t.Fatalf("asset still listed: %s", out) | |
| 84 | } | |
| 85 | } | |
internal/httpd/control.go +7 −1
| @@ -101,6 +101,12 @@ func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg | ||
| 101 | 101 | } |
| 102 | 102 | |
| 103 | 103 | func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string) (msg string, code int) { |
| 104 | return s.runControlReader(u, argv, strings.NewReader(stdin)) | |
| 105 | } | |
| 106 | ||
| 107 | // runControlReader is runControlStdinCode for a body too large to hold | |
| 108 | // as a string: the command reads it from stdin as a stream. | |
| 109 | func (s *Server) runControlReader(u store.User, argv []string, stdin io.Reader) (msg string, code int) { | |
| 104 | 110 | var stdout, stderr bytes.Buffer |
| 105 | 111 | ctx := &control.Ctx{ |
| 106 | 112 | User: u, |
| @@ -108,7 +114,7 @@ func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string) | ||
| 108 | 114 | Scope: "full", |
| 109 | 115 | Store: s.st, |
| 110 | 116 | Cfg: s.cfg, |
| 111 | Stdin: strings.NewReader(stdin), | |
| 117 | Stdin: stdin, | |
| 112 | 118 | Stdout: &stdout, |
| 113 | 119 | Stderr: &stderr, |
| 114 | 120 | ViaAPI: true, |
internal/httpd/milestoneactions.go added +154
| @@ -0,0 +1,154 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "fmt" | |
| 6 | "net/http" | |
| 7 | "path/filepath" | |
| 8 | "strings" | |
| 9 | ||
| 10 | "gitbay.org/gitbay/internal/store" | |
| 11 | ) | |
| 12 | ||
| 13 | // Milestone, org label and release asset forms. Each dispatches the | |
| 14 | // command the CLI runs; who may do it is the command's decision, and the | |
| 15 | // pages only show the forms to those it will accept. | |
| 16 | ||
| 17 | // milestoneCreateArgs builds the argv tail for create: the title, and the | |
| 18 | // description and due date when given. | |
| 19 | func milestoneCreateArgs(r *http.Request, head []string) []string { | |
| 20 | argv := append(head, strings.TrimSpace(r.FormValue("title"))) | |
| 21 | if d := strings.TrimSpace(r.FormValue("description")); d != "" { | |
| 22 | argv = append(argv, "--description", d) | |
| 23 | } | |
| 24 | if d := strings.TrimSpace(r.FormValue("due")); d != "" { | |
| 25 | argv = append(argv, "--due", d) | |
| 26 | } | |
| 27 | return argv | |
| 28 | } | |
| 29 | ||
| 30 | func (s *Server) milestoneSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | |
| 31 | repo := r.PathValue("owner") + "/" + r.PathValue("repo") | |
| 32 | back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "milestones", msg) } | |
| 33 | title := strings.TrimSpace(r.FormValue("title")) | |
| 34 | if title == "" { | |
| 35 | back(w, r, "name the milestone") | |
| 36 | return | |
| 37 | } | |
| 38 | var argv []string | |
| 39 | switch r.FormValue("action") { | |
| 40 | case "close": | |
| 41 | argv = []string{"milestone", "close", repo, title} | |
| 42 | case "reopen": | |
| 43 | argv = []string{"milestone", "reopen", repo, title} | |
| 44 | default: | |
| 45 | argv = milestoneCreateArgs(r, []string{"milestone", "create", repo}) | |
| 46 | } | |
| 47 | _, msg, code := s.runControlCode(u, argv) | |
| 48 | s.done(w, r, code, msg, back) | |
| 49 | } | |
| 50 | ||
| 51 | func (s *Server) orgBack(w http.ResponseWriter, r *http.Request, page, msg string) { | |
| 52 | s.setFlash(w, msg) | |
| 53 | http.Redirect(w, r, "/"+r.PathValue("owner")+"/-/"+page, http.StatusSeeOther) | |
| 54 | } | |
| 55 | ||
| 56 | func (s *Server) orgLabelSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | |
| 57 | org := r.PathValue("owner") | |
| 58 | back := func(w http.ResponseWriter, r *http.Request, msg string) { s.orgBack(w, r, "labels", msg) } | |
| 59 | name := strings.TrimSpace(r.FormValue("name")) | |
| 60 | if name == "" { | |
| 61 | back(w, r, "name the label") | |
| 62 | return | |
| 63 | } | |
| 64 | argv := []string{"org", "label", "set", org, name, "--color", strings.TrimSpace(r.FormValue("color"))} | |
| 65 | if r.FormValue("action") == "remove" { | |
| 66 | if ok, msg := confirmed(r, name); !ok { | |
| 67 | back(w, r, msg) | |
| 68 | return | |
| 69 | } | |
| 70 | argv = []string{"org", "label", "remove", org, name} | |
| 71 | } | |
| 72 | _, msg, code := s.runControlCode(u, argv) | |
| 73 | s.done(w, r, code, msg, back) | |
| 74 | } | |
| 75 | ||
| 76 | func (s *Server) orgMilestoneSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | |
| 77 | org := r.PathValue("owner") | |
| 78 | back := func(w http.ResponseWriter, r *http.Request, msg string) { s.orgBack(w, r, "milestones", msg) } | |
| 79 | title := strings.TrimSpace(r.FormValue("title")) | |
| 80 | if title == "" { | |
| 81 | back(w, r, "name the milestone") | |
| 82 | return | |
| 83 | } | |
| 84 | var argv []string | |
| 85 | switch r.FormValue("action") { | |
| 86 | case "close": | |
| 87 | argv = []string{"org", "milestone", "close", org, title} | |
| 88 | case "reopen": | |
| 89 | argv = []string{"org", "milestone", "reopen", org, title} | |
| 90 | default: | |
| 91 | argv = milestoneCreateArgs(r, []string{"org", "milestone", "create", org}) | |
| 92 | } | |
| 93 | _, msg, code := s.runControlCode(u, argv) | |
| 94 | s.done(w, r, code, msg, back) | |
| 95 | } | |
| 96 | ||
| 97 | // releaseAssetSubmit uploads or removes a release asset. The upload is | |
| 98 | // parsed with a small memory budget, so the rest of the file spills to a | |
| 99 | // temporary file, and reaches release asset add as a stream on stdin. | |
| 100 | // The body is capped a little above max_asset_bytes so an oversized file | |
| 101 | // is refused without being read to the end; the command applies the | |
| 102 | // exact limit. | |
| 103 | func (s *Server) releaseAssetSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | |
| 104 | repo := r.PathValue("owner") + "/" + r.PathValue("repo") | |
| 105 | back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "releases", msg) } | |
| 106 | limit := s.cfg.Limits.MaxAssetBytes | |
| 107 | r.Body = http.MaxBytesReader(w, r.Body, limit+1<<20) | |
| 108 | if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { | |
| 109 | var tooBig *http.MaxBytesError | |
| 110 | if errors.As(err, &tooBig) { | |
| 111 | back(w, r, fmt.Sprintf("asset exceeds max_asset_bytes (%d)", limit)) | |
| 112 | return | |
| 113 | } | |
| 114 | back(w, r, "unreadable upload") | |
| 115 | return | |
| 116 | } | |
| 117 | if r.MultipartForm != nil { | |
| 118 | defer r.MultipartForm.RemoveAll() | |
| 119 | } | |
| 120 | tag := strings.TrimSpace(r.FormValue("tag")) | |
| 121 | if tag == "" { | |
| 122 | back(w, r, "pick a release") | |
| 123 | return | |
| 124 | } | |
| 125 | if r.FormValue("action") == "remove" { | |
| 126 | name := strings.TrimSpace(r.FormValue("name")) | |
| 127 | if ok, msg := confirmed(r, name); !ok || name == "" { | |
| 128 | if name == "" { | |
| 129 | msg = "name the asset" | |
| 130 | } | |
| 131 | back(w, r, msg) | |
| 132 | return | |
| 133 | } | |
| 134 | _, msg, code := s.runControlCode(u, []string{"release", "asset", "remove", repo, tag, name}) | |
| 135 | s.done(w, r, code, msg, back) | |
| 136 | return | |
| 137 | } | |
| 138 | f, hdr, err := r.FormFile("file") | |
| 139 | if err != nil { | |
| 140 | back(w, r, "choose a file") | |
| 141 | return | |
| 142 | } | |
| 143 | defer f.Close() | |
| 144 | if hdr.Size == 0 { | |
| 145 | back(w, r, "the file is empty") | |
| 146 | return | |
| 147 | } | |
| 148 | name := strings.TrimSpace(r.FormValue("name")) | |
| 149 | if name == "" { | |
| 150 | name = filepath.Base(hdr.Filename) | |
| 151 | } | |
| 152 | msg, code := s.runControlReader(u, []string{"release", "asset", "add", repo, tag, name}, f) | |
| 153 | s.done(w, r, code, msg, back) | |
| 154 | } | |
internal/httpd/orglabels.go +13 −9
| @@ -12,17 +12,17 @@ import ( | ||
| 12 | 12 | // see it; anyone else only when some repository under the org is |
| 13 | 13 | // readable. Everything else is not found, the same answer as for a |
| 14 | 14 | // user owner or an unknown name. |
| 15 | func (s *Server) orgScope(w http.ResponseWriter, r *http.Request) (store.Org, store.User, []int64, bool) { | |
| 15 | func (s *Server) orgScope(w http.ResponseWriter, r *http.Request) (store.Org, store.User, []int64, bool, bool) { | |
| 16 | 16 | viewer := s.viewer(r) |
| 17 | 17 | org, err := s.st.OrgByName(r.PathValue("owner")) |
| 18 | 18 | if err != nil { |
| 19 | 19 | s.notFound(w, r) |
| 20 | return org, viewer, nil, false | |
| 20 | return org, viewer, nil, false, false | |
| 21 | 21 | } |
| 22 | 22 | readable, err := control.ReadableOrgRepoIDs(s.st, viewer, org.ID) |
| 23 | 23 | if err != nil { |
| 24 | 24 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 25 | return org, viewer, nil, false | |
| 25 | return org, viewer, nil, false, false | |
| 26 | 26 | } |
| 27 | 27 | role := "" |
| 28 | 28 | if viewer.ID != 0 { |
| @@ -30,13 +30,13 @@ func (s *Server) orgScope(w http.ResponseWriter, r *http.Request) (store.Org, st | ||
| 30 | 30 | } |
| 31 | 31 | if role == "" && len(readable) == 0 { |
| 32 | 32 | s.notFound(w, r) |
| 33 | return org, viewer, nil, false | |
| 33 | return org, viewer, nil, false, false | |
| 34 | 34 | } |
| 35 | return org, viewer, readable, true | |
| 35 | return org, viewer, readable, true, role == "admin" | |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | 38 | func (s *Server) orgLabels(w http.ResponseWriter, r *http.Request) { |
| 39 | org, viewer, readable, ok := s.orgScope(w, r) | |
| 39 | org, viewer, readable, ok, admin := s.orgScope(w, r) | |
| 40 | 40 | if !ok { |
| 41 | 41 | return |
| 42 | 42 | } |
| @@ -54,11 +54,13 @@ func (s *Server) orgLabels(w http.ResponseWriter, r *http.Request) { | ||
| 54 | 54 | Org string |
| 55 | 55 | Labels []store.Label |
| 56 | 56 | LabelColors map[string]template.CSS |
| 57 | }{s.baseFor(viewer), org.Name, labels, colorStyles(stored)}) | |
| 57 | CanAdmin bool | |
| 58 | Notice string | |
| 59 | }{s.baseFor(viewer), org.Name, labels, colorStyles(stored), admin, s.takeFlash(w, r)}) | |
| 58 | 60 | } |
| 59 | 61 | |
| 60 | 62 | func (s *Server) orgMilestones(w http.ResponseWriter, r *http.Request) { |
| 61 | org, viewer, readable, ok := s.orgScope(w, r) | |
| 63 | org, viewer, readable, ok, admin := s.orgScope(w, r) | |
| 62 | 64 | if !ok { |
| 63 | 65 | return |
| 64 | 66 | } |
| @@ -88,5 +90,7 @@ func (s *Server) orgMilestones(w http.ResponseWriter, r *http.Request) { | ||
| 88 | 90 | Org string |
| 89 | 91 | State string |
| 90 | 92 | Milestones []msView |
| 91 | }{s.baseFor(viewer), org.Name, state, views}) | |
| 93 | CanAdmin bool | |
| 94 | Notice string | |
| 95 | }{s.baseFor(viewer), org.Name, state, views, admin, s.takeFlash(w, r)}) | |
| 92 | 96 | } |
internal/httpd/parity296b_test.go added +315
| @@ -0,0 +1,315 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "io" | |
| 6 | "mime/multipart" | |
| 7 | "net/http" | |
| 8 | "net/http/httptest" | |
| 9 | "net/url" | |
| 10 | "strings" | |
| 11 | "testing" | |
| 12 | ||
| 13 | "gitbay.org/gitbay/internal/store" | |
| 14 | ) | |
| 15 | ||
| 16 | type p296b struct { | |
| 17 | s *Server | |
| 18 | st *store.Store | |
| 19 | h http.Handler | |
| 20 | repo store.Repo | |
| 21 | orgID int64 | |
| 22 | alice *http.Cookie | |
| 23 | bob *http.Cookie | |
| 24 | aliceU store.User | |
| 25 | } | |
| 26 | ||
| 27 | func newP296b(t *testing.T) *p296b { | |
| 28 | t.Helper() | |
| 29 | e := newSettingsEnv(t) | |
| 30 | e.s.cfg.Limits.MaxAssetBytes = 1 << 10 | |
| 31 | orgID, err := e.st.CreateOrg("acme", e.alice.ID) | |
| 32 | if err != nil { | |
| 33 | t.Fatal(err) | |
| 34 | } | |
| 35 | if err := e.st.SetOrgMember(orgID, e.bob.ID, "member"); err != nil { | |
| 36 | t.Fatal(err) | |
| 37 | } | |
| 38 | if _, err := e.st.CreateRelease(e.repo.ID, "v1", "One", "", e.alice.ID, "md"); err != nil { | |
| 39 | t.Fatal(err) | |
| 40 | } | |
| 41 | return &p296b{s: e.s, st: e.st, h: e.s.Handler(), repo: e.repo, orgID: orgID, | |
| 42 | alice: sessionCookieFor(t, e.s, e.st, e.alice.ID), | |
| 43 | bob: sessionCookieFor(t, e.s, e.st, e.bob.ID), aliceU: e.alice} | |
| 44 | } | |
| 45 | ||
| 46 | func (p *p296b) do(method, path string, ck *http.Cookie, body io.Reader, ctype string) *httptest.ResponseRecorder { | |
| 47 | req := httptest.NewRequest(method, path, body) | |
| 48 | if ctype != "" { | |
| 49 | req.Header.Set("Content-Type", ctype) | |
| 50 | } | |
| 51 | req.AddCookie(ck) | |
| 52 | rr := httptest.NewRecorder() | |
| 53 | p.h.ServeHTTP(rr, req) | |
| 54 | return rr | |
| 55 | } | |
| 56 | ||
| 57 | func (p *p296b) post(path string, ck *http.Cookie, f url.Values) *httptest.ResponseRecorder { | |
| 58 | return p.do("POST", path, ck, strings.NewReader(f.Encode()), "application/x-www-form-urlencoded") | |
| 59 | } | |
| 60 | ||
| 61 | // follow returns the page a redirect points at, carrying the flash. | |
| 62 | func (p *p296b) follow(rr *httptest.ResponseRecorder, ck *http.Cookie) string { | |
| 63 | req := httptest.NewRequest("GET", rr.Header().Get("Location"), nil) | |
| 64 | req.AddCookie(ck) | |
| 65 | for _, c := range rr.Result().Cookies() { | |
| 66 | req.AddCookie(c) | |
| 67 | } | |
| 68 | out := httptest.NewRecorder() | |
| 69 | p.h.ServeHTTP(out, req) | |
| 70 | return out.Body.String() | |
| 71 | } | |
| 72 | ||
| 73 | func (p *p296b) get(path string, ck *http.Cookie) string { | |
| 74 | return p.do("GET", path, ck, nil, "").Body.String() | |
| 75 | } | |
| 76 | ||
| 77 | func upload(t *testing.T, fields map[string]string, fname string, data []byte) (io.Reader, string) { | |
| 78 | t.Helper() | |
| 79 | var buf bytes.Buffer | |
| 80 | mw := multipart.NewWriter(&buf) | |
| 81 | for k, v := range fields { | |
| 82 | mw.WriteField(k, v) | |
| 83 | } | |
| 84 | if fname != "" { | |
| 85 | fw, _ := mw.CreateFormFile("file", fname) | |
| 86 | fw.Write(data) | |
| 87 | } | |
| 88 | mw.Close() | |
| 89 | return &buf, mw.FormDataContentType() | |
| 90 | } | |
| 91 | ||
| 92 | func TestReleaseAssetUploadAndRemove(t *testing.T) { | |
| 93 | p := newP296b(t) | |
| 94 | const path = "/alice/app/releases/assets" | |
| 95 | body, ct := upload(t, map[string]string{"tag": "v1"}, "tool.bin", []byte("payload")) | |
| 96 | rr := p.do("POST", path, p.alice, body, ct) | |
| 97 | if rr.Code != http.StatusSeeOther { | |
| 98 | t.Fatalf("upload: %d %s", rr.Code, rr.Body.String()) | |
| 99 | } | |
| 100 | rel, _ := p.st.ReleaseByTag(p.repo.ID, "v1") | |
| 101 | if len(rel.Assets) != 1 || rel.Assets[0].Name != "tool.bin" || rel.Assets[0].Size != 7 { | |
| 102 | t.Fatalf("assets %+v", rel.Assets) | |
| 103 | } | |
| 104 | page := p.get("/alice/app/releases", p.alice) | |
| 105 | for _, want := range []string{"Add asset", `enctype="multipart/form-data"`, "tool.bin", `value="remove"`} { | |
| 106 | if !strings.Contains(page, want) { | |
| 107 | t.Errorf("writer page lacks %q", want) | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | // Refusals: over the limit, empty, bad name, no file. | |
| 112 | for name, tc := range map[string]struct { | |
| 113 | fname string | |
| 114 | data []byte | |
| 115 | field map[string]string | |
| 116 | want string | |
| 117 | }{ | |
| 118 | "oversized": {"big.bin", bytes.Repeat([]byte("x"), 2<<10), map[string]string{"tag": "v1"}, "max_asset_bytes"}, | |
| 119 | "way over": {"huge.bin", bytes.Repeat([]byte("x"), 3<<20), map[string]string{"tag": "v1"}, "max_asset_bytes"}, | |
| 120 | "empty": {"e.bin", nil, map[string]string{"tag": "v1"}, "empty"}, | |
| 121 | "bad name": {"x.bin", []byte("x"), map[string]string{"tag": "v1", "name": ".hidden"}, "invalid asset name"}, | |
| 122 | "no file": {"", nil, map[string]string{"tag": "v1"}, "choose a file"}, | |
| 123 | "no release": {"a.bin", []byte("x"), map[string]string{"tag": "v9"}, ""}, | |
| 124 | } { | |
| 125 | body, ct := upload(t, tc.field, tc.fname, tc.data) | |
| 126 | rr := p.do("POST", path, p.alice, body, ct) | |
| 127 | if tc.want == "" { | |
| 128 | if rr.Code != http.StatusNotFound { | |
| 129 | t.Errorf("%s: %d", name, rr.Code) | |
| 130 | } | |
| 131 | continue | |
| 132 | } | |
| 133 | if rr.Code != http.StatusSeeOther || !strings.Contains(p.follow(rr, p.alice), tc.want) { | |
| 134 | t.Errorf("%s: %d, no %q on the page", name, rr.Code, tc.want) | |
| 135 | } | |
| 136 | } | |
| 137 | if rel, _ = p.st.ReleaseByTag(p.repo.ID, "v1"); len(rel.Assets) != 1 { | |
| 138 | t.Fatalf("a refused upload stored something: %+v", rel.Assets) | |
| 139 | } | |
| 140 | ||
| 141 | // Remove needs the name typed. | |
| 142 | rr = p.post(path, p.alice, url.Values{"action": {"remove"}, "tag": {"v1"}, "name": {"tool.bin"}}) | |
| 143 | if !strings.Contains(p.follow(rr, p.alice), "type tool.bin to confirm") { | |
| 144 | t.Fatal("no confirmation demanded") | |
| 145 | } | |
| 146 | if rel, _ = p.st.ReleaseByTag(p.repo.ID, "v1"); len(rel.Assets) != 1 { | |
| 147 | t.Fatal("removed without confirmation") | |
| 148 | } | |
| 149 | p.post(path, p.alice, url.Values{"action": {"remove"}, "tag": {"v1"}, "name": {"tool.bin"}, "confirm": {"tool.bin"}}) | |
| 150 | if rel, _ = p.st.ReleaseByTag(p.repo.ID, "v1"); len(rel.Assets) != 0 { | |
| 151 | t.Fatalf("not removed: %+v", rel.Assets) | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | func TestReleaseAssetReaderSeesNoFormAndIsRefused(t *testing.T) { | |
| 156 | p := newP296b(t) | |
| 157 | body, ct := upload(t, map[string]string{"tag": "v1"}, "tool.bin", []byte("payload")) | |
| 158 | rr := p.do("POST", "/alice/app/releases/assets", p.bob, body, ct) | |
| 159 | if !strings.Contains(p.follow(rr, p.bob), `role="alert"`) { | |
| 160 | t.Fatalf("no refusal shown: %d", rr.Code) | |
| 161 | } | |
| 162 | if rel, _ := p.st.ReleaseByTag(p.repo.ID, "v1"); len(rel.Assets) != 0 { | |
| 163 | t.Fatal("a reader uploaded an asset") | |
| 164 | } | |
| 165 | page := p.get("/alice/app/releases", p.bob) | |
| 166 | if strings.Contains(page, "Add asset") || strings.Contains(page, "releases/assets") { | |
| 167 | t.Fatal("reader sees the asset form") | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | func TestRepoMilestoneCreateCloseReopen(t *testing.T) { | |
| 172 | p := newP296b(t) | |
| 173 | const path = "/alice/app/milestones" | |
| 174 | rr := p.post(path, p.alice, url.Values{"title": {"v2"}, "description": {"next"}, "due": {"2026-12-01"}}) | |
| 175 | if rr.Code != http.StatusSeeOther { | |
| 176 | t.Fatalf("create: %d", rr.Code) | |
| 177 | } | |
| 178 | m, err := p.st.MilestoneByTitle(p.repo, "v2") | |
| 179 | if err != nil || m.Description != "next" || m.DueDate != "2026-12-01" { | |
| 180 | t.Fatalf("%+v %v", m, err) | |
| 181 | } | |
| 182 | page := p.get(path, p.alice) | |
| 183 | for _, want := range []string{"New milestone", `value="close"`} { | |
| 184 | if !strings.Contains(page, want) { | |
| 185 | t.Errorf("page lacks %q", want) | |
| 186 | } | |
| 187 | } | |
| 188 | p.post(path, p.alice, url.Values{"action": {"close"}, "title": {"v2"}}) | |
| 189 | if m, _ = p.st.MilestoneByTitle(p.repo, "v2"); m.State != "closed" { | |
| 190 | t.Fatalf("state %q", m.State) | |
| 191 | } | |
| 192 | p.post(path, p.alice, url.Values{"action": {"reopen"}, "title": {"v2"}}) | |
| 193 | if m, _ = p.st.MilestoneByTitle(p.repo, "v2"); m.State != "open" { | |
| 194 | t.Fatalf("state %q", m.State) | |
| 195 | } | |
| 196 | ||
| 197 | // Refusals show on the page. | |
| 198 | rr = p.post(path, p.alice, url.Values{"title": {"v3"}, "due": {"soon"}}) | |
| 199 | if !strings.Contains(p.follow(rr, p.alice), "--due must be YYYY-MM-DD") { | |
| 200 | t.Fatal("bad date not reported") | |
| 201 | } | |
| 202 | rr = p.post(path, p.alice, url.Values{"title": {"v2"}}) | |
| 203 | if rr.Code != http.StatusSeeOther || !strings.Contains(p.follow(rr, p.alice), `role="alert"`) { | |
| 204 | t.Fatal("duplicate not reported") | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | func TestRepoMilestoneReaderSeesNoForm(t *testing.T) { | |
| 209 | p := newP296b(t) | |
| 210 | page := p.get("/alice/app/milestones", p.bob) | |
| 211 | if strings.Contains(page, "New milestone") || strings.Contains(page, `action="/alice/app/milestones"`) { | |
| 212 | t.Fatal("reader sees the form") | |
| 213 | } | |
| 214 | rr := p.post("/alice/app/milestones", p.bob, url.Values{"title": {"sneaky"}}) | |
| 215 | if !strings.Contains(p.follow(rr, p.bob), `role="alert"`) { | |
| 216 | t.Fatal("no refusal") | |
| 217 | } | |
| 218 | if _, err := p.st.MilestoneByTitle(p.repo, "sneaky"); err == nil { | |
| 219 | t.Fatal("a reader created a milestone") | |
| 220 | } | |
| 221 | } | |
| 222 | ||
| 223 | func TestOrgLabelSetAndRemove(t *testing.T) { | |
| 224 | p := newP296b(t) | |
| 225 | const path = "/acme/-/labels" | |
| 226 | rr := p.post(path, p.alice, url.Values{"name": {"bug"}, "color": {"d73a4a"}}) | |
| 227 | if rr.Code != http.StatusSeeOther { | |
| 228 | t.Fatalf("set: %d", rr.Code) | |
| 229 | } | |
| 230 | labels, _ := p.st.ListOrgLabels(p.orgID, nil) | |
| 231 | if len(labels) != 1 || labels[0].Name != "bug" || labels[0].Color != "#d73a4a" { | |
| 232 | t.Fatalf("%+v", labels) | |
| 233 | } | |
| 234 | page := p.get(path, p.alice) | |
| 235 | for _, want := range []string{"New org label", `value="remove"`, `aria-label="Colour for bug"`} { | |
| 236 | if !strings.Contains(page, want) { | |
| 237 | t.Errorf("page lacks %q", want) | |
| 238 | } | |
| 239 | } | |
| 240 | rr = p.post(path, p.alice, url.Values{"name": {"bug"}, "color": {"zzz"}}) | |
| 241 | if !strings.Contains(p.follow(rr, p.alice), "--color takes rrggbb") { | |
| 242 | t.Fatal("bad colour not reported") | |
| 243 | } | |
| 244 | rr = p.post(path, p.alice, url.Values{"action": {"remove"}, "name": {"bug"}}) | |
| 245 | if !strings.Contains(p.follow(rr, p.alice), "type bug to confirm") { | |
| 246 | t.Fatal("no confirmation demanded") | |
| 247 | } | |
| 248 | if labels, _ = p.st.ListOrgLabels(p.orgID, nil); len(labels) != 1 { | |
| 249 | t.Fatal("removed without confirmation") | |
| 250 | } | |
| 251 | p.post(path, p.alice, url.Values{"action": {"remove"}, "name": {"bug"}, "confirm": {"bug"}}) | |
| 252 | if labels, _ = p.st.ListOrgLabels(p.orgID, nil); len(labels) != 0 { | |
| 253 | t.Fatalf("not removed: %+v", labels) | |
| 254 | } | |
| 255 | } | |
| 256 | ||
| 257 | func TestOrgLabelMemberSeesNoFormAndIsRefused(t *testing.T) { | |
| 258 | p := newP296b(t) | |
| 259 | page := p.get("/acme/-/labels", p.bob) | |
| 260 | if strings.Contains(page, "New org label") || strings.Contains(page, `action="/acme/-/labels"`) { | |
| 261 | t.Fatal("member sees the form") | |
| 262 | } | |
| 263 | rr := p.post("/acme/-/labels", p.bob, url.Values{"name": {"bug"}}) | |
| 264 | if !strings.Contains(p.follow(rr, p.bob), "only admins of acme") { | |
| 265 | t.Fatalf("no refusal: %d", rr.Code) | |
| 266 | } | |
| 267 | if labels, _ := p.st.ListOrgLabels(p.orgID, nil); len(labels) != 0 { | |
| 268 | t.Fatal("a member created an org label") | |
| 269 | } | |
| 270 | } | |
| 271 | ||
| 272 | func TestOrgMilestoneCreateCloseReopen(t *testing.T) { | |
| 273 | p := newP296b(t) | |
| 274 | const path = "/acme/-/milestones" | |
| 275 | if rr := p.post(path, p.alice, url.Values{"title": {"mobile"}, "due": {"2026-12-01"}}); rr.Code != http.StatusSeeOther { | |
| 276 | t.Fatalf("create: %d", rr.Code) | |
| 277 | } | |
| 278 | state := func(s string) int { | |
| 279 | ms, _ := p.st.ListOrgMilestones(p.orgID, s, nil) | |
| 280 | return len(ms) | |
| 281 | } | |
| 282 | if state("open") != 1 { | |
| 283 | t.Fatal("not created") | |
| 284 | } | |
| 285 | if page := p.get(path, p.alice); !strings.Contains(page, "New org milestone") || !strings.Contains(page, `value="close"`) { | |
| 286 | t.Fatal("admin page lacks the controls") | |
| 287 | } | |
| 288 | p.post(path, p.alice, url.Values{"action": {"close"}, "title": {"mobile"}}) | |
| 289 | if state("closed") != 1 || state("open") != 0 { | |
| 290 | t.Fatal("not closed") | |
| 291 | } | |
| 292 | p.post(path, p.alice, url.Values{"action": {"reopen"}, "title": {"mobile"}}) | |
| 293 | if state("open") != 1 { | |
| 294 | t.Fatal("not reopened") | |
| 295 | } | |
| 296 | rr := p.post(path, p.alice, url.Values{"title": {"mobile"}}) | |
| 297 | if !strings.Contains(p.follow(rr, p.alice), `role="alert"`) { | |
| 298 | t.Fatal("duplicate not reported") | |
| 299 | } | |
| 300 | } | |
| 301 | ||
| 302 | func TestOrgMilestoneMemberSeesNoFormAndIsRefused(t *testing.T) { | |
| 303 | p := newP296b(t) | |
| 304 | page := p.get("/acme/-/milestones", p.bob) | |
| 305 | if strings.Contains(page, "New org milestone") || strings.Contains(page, `action="/acme/-/milestones"`) { | |
| 306 | t.Fatal("member sees the form") | |
| 307 | } | |
| 308 | rr := p.post("/acme/-/milestones", p.bob, url.Values{"title": {"sneaky"}}) | |
| 309 | if !strings.Contains(p.follow(rr, p.bob), "only admins of acme") { | |
| 310 | t.Fatalf("no refusal: %d", rr.Code) | |
| 311 | } | |
| 312 | if ms, _ := p.st.ListOrgMilestones(p.orgID, "all", nil); len(ms) != 0 { | |
| 313 | t.Fatal("a member created an org milestone") | |
| 314 | } | |
| 315 | } | |
internal/httpd/routes.go +8
| @@ -175,6 +175,14 @@ func (s *Server) Routes() []Route { | ||
| 175 | 175 | Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))}, |
| 176 | 176 | Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true, |
| 177 | 177 | Handler: s.checkOrigin(s.requireUser(s.labelSubmit))}, |
| 178 | Route{Method: "POST", Pattern: "/{owner}/{repo}/releases/assets", Mutating: true, | |
| 179 | Handler: s.checkOrigin(s.requireUser(s.releaseAssetSubmit))}, | |
| 180 | Route{Method: "POST", Pattern: "/{owner}/{repo}/milestones", Mutating: true, | |
| 181 | Handler: s.checkOrigin(s.requireUser(s.milestoneSubmit))}, | |
| 182 | Route{Method: "POST", Pattern: "/{owner}/-/labels", Mutating: true, | |
| 183 | Handler: s.checkOrigin(s.requireUser(s.orgLabelSubmit))}, | |
| 184 | Route{Method: "POST", Pattern: "/{owner}/-/milestones", Mutating: true, | |
| 185 | Handler: s.checkOrigin(s.requireUser(s.orgMilestoneSubmit))}, | |
| 178 | 186 | Route{Method: "GET", Pattern: "/bookmarks", Handler: s.requireUser(s.bookmarksPage)}, |
| 179 | 187 | Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)}, |
| 180 | 188 | Route{Method: "POST", Pattern: "/{owner}/-/snippets/new", Mutating: true, |
internal/httpd/web.go +3 −1
| @@ -897,7 +897,9 @@ func (s *Server) milestones(w http.ResponseWriter, r *http.Request) { | ||
| 897 | 897 | repoPage |
| 898 | 898 | State string |
| 899 | 899 | Milestones []msView |
| 900 | }{p, state, views}) | |
| 900 | CanWrite bool | |
| 901 | Notice string | |
| 902 | }{p, state, views, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)}) | |
| 901 | 903 | } |
| 902 | 904 | |
| 903 | 905 | // search runs a bounded literal git grep over the repo's default branch. |
internal/web/templates/milestones.html +19
| @@ -8,6 +8,21 @@ | ||
| 8 | 8 | <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a> |
| 9 | 9 | </nav> |
| 10 | 10 | </div> |
| 11 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | |
| 12 | {{if .CanWrite}} | |
| 13 | <details class="editbox"> | |
| 14 | <summary>New milestone</summary> | |
| 15 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones" class="setform stack"> | |
| 16 | <label for="mstitle">Title</label> | |
| 17 | <input type="text" id="mstitle" name="title" required> | |
| 18 | <label for="msdesc">Description</label> | |
| 19 | <input type="text" id="msdesc" name="description"> | |
| 20 | <label for="msdue">Due</label> | |
| 21 | <input type="text" id="msdue" name="due" placeholder="YYYY-MM-DD"> | |
| 22 | <button type="submit" class="btn">Create milestone</button> | |
| 23 | </form> | |
| 24 | </details> | |
| 25 | {{end}} | |
| 11 | 26 | <ul class="milestonelist"> |
| 12 | 27 | {{range .Milestones}}<li> |
| 13 | 28 | <div class="msmain"> |
| @@ -15,6 +30,10 @@ | ||
| 15 | 30 | {{if .Description}}<p class="desc">{{.Description}}</p>{{end}} |
| 16 | 31 | <p class="meta">{{if .DueDate}}due {{.DueDate}} · {{end}}{{.ClosedItems}} closed, {{.OpenItems}} open · {{.Percent}}%</p> |
| 17 | 32 | <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div> |
| 33 | {{if and $.CanWrite (not .OrgID)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/milestones" class="inline"> | |
| 34 | <input type="hidden" name="title" value="{{.Title}}"> | |
| 35 | {{if eq .State "open"}}<input type="hidden" name="action" value="close"><button type="submit" class="btn">Close</button>{{else}}<input type="hidden" name="action" value="reopen"><button type="submit" class="btn">Reopen</button>{{end}} | |
| 36 | </form>{{end}} | |
| 18 | 37 | </div> |
| 19 | 38 | </li> |
| 20 | 39 | {{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}milestones</li>{{end}} |
internal/web/templates/orglabels.html +27 −3
| @@ -1,15 +1,39 @@ | ||
| 1 | 1 | {{define "title"}}labels · {{.Org}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 3 | <h1><a href="/{{.Org}}">{{.Org}}</a> labels</h1> |
| 4 | <p class="meta">Every repository under {{.Org}} sees these beside its own. Managed with <code>gitbay org label set {{.Org}} <label></code>; counts span the repositories you can read.</p> | |
| 4 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | |
| 5 | <p class="meta">Every repository under {{.Org}} sees these beside its own. Counts span the repositories you can read.</p> | |
| 5 | 6 | {{if .Labels}}<div class="tablewrap"><table class="keys"> |
| 6 | <tr class="cols"><th scope="col">label</th><th scope="col">colour</th><th scope="col">issues</th><th scope="col">merge requests</th></tr> | |
| 7 | <tr class="cols"><th scope="col">label</th><th scope="col">colour</th><th scope="col">issues</th><th scope="col">merge requests</th>{{if .CanAdmin}}<th scope="col"><span class="vh">actions</span></th>{{end}}</tr> | |
| 7 | 8 | {{range .Labels}}<tr> |
| 8 | 9 | <td><span class="chip label" style="{{index $.LabelColors .Name}}">{{.Name}}</span></td> |
| 9 | <td><span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span></td> | |
| 10 | <td>{{if $.CanAdmin}}<form method="post" action="/{{$.Org}}/-/labels" class="inline"> | |
| 11 | <input type="hidden" name="name" value="{{.Name}}"> | |
| 12 | <input type="text" name="color" value="{{.Color}}" aria-label="Colour for {{.Name}}" placeholder="rrggbb" size="8"> | |
| 13 | <button type="submit" class="btn">Save</button> | |
| 14 | </form>{{else}}<span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span>{{end}}</td> | |
| 10 | 15 | <td>{{.Issues}}</td> |
| 11 | 16 | <td>{{.MRs}}</td> |
| 17 | {{if $.CanAdmin}}<td class="act"><form method="post" action="/{{$.Org}}/-/labels" class="inline"> | |
| 18 | <input type="hidden" name="action" value="remove"> | |
| 19 | <input type="hidden" name="name" value="{{.Name}}"> | |
| 20 | {{template "confirmfield" .Name}} | |
| 21 | <button type="submit" class="danger">Remove</button> | |
| 22 | </form></td>{{end}} | |
| 12 | 23 | </tr> |
| 13 | 24 | {{end}}</table></div> |
| 14 | 25 | {{else}}<p class="none">No org labels yet.</p>{{end}} |
| 26 | {{if .CanAdmin}} | |
| 27 | <details class="editbox"> | |
| 28 | <summary>New org label</summary> | |
| 29 | <form method="post" action="/{{.Org}}/-/labels" class="setform stack"> | |
| 30 | <label for="labelname">Name</label> | |
| 31 | <input type="text" id="labelname" name="name" maxlength="50" required> | |
| 32 | <label for="labelcolor">Colour</label> | |
| 33 | <input type="text" id="labelcolor" name="color" placeholder="rrggbb, or blank for one picked from the name"> | |
| 34 | <button type="submit" class="btn">Create label</button> | |
| 35 | </form> | |
| 36 | </details> | |
| 37 | <p class="meta">Removing a label takes it off every issue and merge request under {{.Org}}. Creating one folds in same-named repository labels.</p> | |
| 38 | {{end}} | |
| 15 | 39 | {{end}} |
internal/web/templates/orgmilestones.html +19
| @@ -8,6 +8,21 @@ | ||
| 8 | 8 | <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a> |
| 9 | 9 | </nav> |
| 10 | 10 | </div> |
| 11 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | |
| 12 | {{if .CanAdmin}} | |
| 13 | <details class="editbox"> | |
| 14 | <summary>New org milestone</summary> | |
| 15 | <form method="post" action="/{{.Org}}/-/milestones" class="setform stack"> | |
| 16 | <label for="mstitle">Title</label> | |
| 17 | <input type="text" id="mstitle" name="title" required> | |
| 18 | <label for="msdesc">Description</label> | |
| 19 | <input type="text" id="msdesc" name="description"> | |
| 20 | <label for="msdue">Due</label> | |
| 21 | <input type="text" id="msdue" name="due" placeholder="YYYY-MM-DD"> | |
| 22 | <button type="submit" class="btn">Create milestone</button> | |
| 23 | </form> | |
| 24 | </details> | |
| 25 | {{end}} | |
| 11 | 26 | <p class="meta">Progress spans the repositories under {{.Org}} you can read.</p> |
| 12 | 27 | <ul class="milestonelist"> |
| 13 | 28 | {{range .Milestones}}<li> |
| @@ -16,6 +31,10 @@ | ||
| 16 | 31 | {{if .Description}}<p class="desc">{{.Description}}</p>{{end}} |
| 17 | 32 | <p class="meta">{{if .DueDate}}due {{.DueDate}} · {{end}}{{.ClosedItems}} closed, {{.OpenItems}} open · {{.Percent}}%</p> |
| 18 | 33 | <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div> |
| 34 | {{if $.CanAdmin}}<form method="post" action="/{{$.Org}}/-/milestones" class="inline"> | |
| 35 | <input type="hidden" name="title" value="{{.Title}}"> | |
| 36 | {{if eq .State "open"}}<input type="hidden" name="action" value="close"><button type="submit" class="btn">Close</button>{{else}}<input type="hidden" name="action" value="reopen"><button type="submit" class="btn">Reopen</button>{{end}} | |
| 37 | </form>{{end}} | |
| 19 | 38 | </div> |
| 20 | 39 | </li> |
| 21 | 40 | {{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}org milestones</li>{{end}} |
internal/web/templates/releases.html +16 −1
| @@ -43,13 +43,28 @@ | ||
| 43 | 43 | <p>{{template "confirmfield" $rel.Tag}} <button type="submit" class="danger">Delete release</button></p> |
| 44 | 44 | </form>{{end}}</details>{{end}} |
| 45 | 45 | {{if $rel.Assets}}<table class="assets"> |
| 46 | <thead><tr><th scope="col">File</th><th scope="col">Bytes</th><th scope="col">SHA-256</th></tr></thead> | |
| 46 | <thead><tr><th scope="col">File</th><th scope="col">Bytes</th><th scope="col">SHA-256</th>{{if $.CanWrite}}<th scope="col"><span class="vh">actions</span></th>{{end}}</tr></thead> | |
| 47 | 47 | {{range $rel.Assets}}<tr> |
| 48 | 48 | <td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases/download/{{$rel.Tag}}/{{.Name}}">{{.Name}}</a></td> |
| 49 | 49 | <td class="size">{{.Size}}</td> |
| 50 | 50 | <td class="sha"><code title="{{.SHA256}}">{{short .SHA256}}</code></td> |
| 51 | {{if $.CanWrite}}<td class="act"><form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases/assets" class="inline"> | |
| 52 | <input type="hidden" name="action" value="remove"> | |
| 53 | <input type="hidden" name="tag" value="{{$rel.Tag}}"> | |
| 54 | <input type="hidden" name="name" value="{{.Name}}"> | |
| 55 | {{template "confirmfield" .Name}} | |
| 56 | <button type="submit" class="danger">Remove</button> | |
| 57 | </form></td>{{end}} | |
| 51 | 58 | </tr>{{end}} |
| 52 | 59 | </table>{{end}} |
| 60 | {{if $.CanWrite}}<details class="editbox"><summary>Add asset</summary> | |
| 61 | <form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases/assets" enctype="multipart/form-data" class="commentform"> | |
| 62 | <input type="hidden" name="tag" value="{{$rel.Tag}}"> | |
| 63 | <p><input type="file" name="file" aria-label="File" required></p> | |
| 64 | <p><input type="text" name="name" aria-label="Name" placeholder="name (defaults to the file's name)"></p> | |
| 65 | <p><button type="submit" class="btn">Upload</button></p> | |
| 66 | </form> | |
| 67 | </details>{{end}} | |
| 53 | 68 | </article> |
| 54 | 69 | {{else}}<p class="empty-note">no releases yet</p>{{end}} |
| 55 | 70 | {{end}} |