Commit 1cb771a8f3

1cb771a8f37cc7c1d6f0fc4098690124173e2a29

parent: 7a58c237d3

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 05:35 UTC

web: release assets, milestones and org label and milestone writes

The releases page uploads (multipart, streamed to release asset add on
stdin) and removes assets behind a typed name. The milestones page
creates, closes and reopens milestones. The org labels and milestones
pages give admins set, remove, create, close and reopen. Parity rows
updated.

Ref #296

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| choose body markup | yes | yes | yes | 126| choose body markup | yes | yes | yes |
127| preview body markup | n/a | yes | no | 127| preview body markup | n/a | yes | no |
128| issue templates | yes | yes | yes | 128| issue templates | yes | yes | yes |
129| milestone create, close, reopen | yes | no | yes | 129| milestone create, close, reopen | yes | yes | yes |
130| org labels: set, list, remove | yes | list | yes | 130| org labels: set, list, remove | yes | yes | yes |
131| org milestones: create, list, close, reopen | yes | list | yes | 131| org milestones: create, list, close, reopen | yes | yes | yes |
132| closes across repositories | yes | yes | yes | 132| closes across repositories | yes | yes | yes |
133 133
134Labels are created on the fly by =issue label --add= and =mr label 134Labels 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
139at =/<owner>/<repo>/labels=, linked from the issue list: create, 139at =/<owner>/<repo>/labels=, linked from the issue list: create,
140recolour and remove, dispatching the same commands. 140recolour and remove, dispatching the same commands.
141 141
142Org labels and milestones are managed on the CLI, the API and the iOS 142Org labels and milestones are managed at =/<org>/-/labels= and
143client's org screen; =/<org>/-/labels= and =/<org>/-/milestones= show 143=/<org>/-/milestones=, where org admins see the create, colour, remove,
144them on the web. The repository 144close and reopen forms and everyone else sees the list. The repository
145label page's form exists for colour alone, and three org forms nobody 145milestones page carries create, close and reopen for writers; a
146asked for were not worth their handlers. 146milestone the org holds shows no buttons there. Uploading a release
147asset from the releases page streams the file to =release asset add= on
148stdin, capped at =max_asset_bytes=.
147 149
148Issue, MR and release bodies, and their comments, carry the markup they 150Issue, MR and release bodies, and their comments, carry the markup they
149were written in — =--format md|org= on create, comment and edit, stored 151were written in — =--format md|org= on create, comment and edit, stored
@@ -234,8 +236,8 @@ rather than the one the web page shows.
234| delete, transfer | yes | yes | no | 236| delete, transfer | yes | yes | no |
235| rename | yes | yes | yes | 237| rename | yes | yes | yes |
236| release delete | yes | yes | yes | 238| release delete | yes | yes | yes |
237| release asset add | yes | no | n/a | 239| release asset add | yes | yes | n/a |
238| release asset remove | yes | no | yes | 240| release asset remove | yes | yes | yes |
239| snippet create, edit, delete | yes | yes | yes | 241| snippet create, edit, delete | yes | yes | yes |
240| snippet show, list | yes | yes | yes | 242| snippet show, list | yes | yes | yes |
241| snippet file set, get, remove | yes | yes | yes | 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#+end_src 393#+end_src
394 394
395The web shows them under the repository's =releases= tab with rendered 395The web shows them under the repository's =releases= tab with rendered
396notes, sha256 sums, and download links. Feed readers subscribe at 396notes, sha256 sums, and download links; writers add and remove assets
397there (a file upload, up to =max_asset_bytes=; removal asks for the file
398name). Feed readers subscribe at
397=/you/project/releases.atom=; commits are at =/you/project/log.atom= 399=/you/project/releases.atom=; commits are at =/you/project/log.atom=
398(the default branch) or =/you/project/log.atom/<ref>=, and an owner's 400(the default branch) or =/you/project/log.atom/<ref>=, and an owner's
399activity on their public repositories at =/you/activity.atom=. The 401activity 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
415Milestones group issues and MRs toward a release (write access to 417Milestones group issues and MRs toward a release (write access to
416manage, attach with =issue milestone= / =mr milestone=; progress shows 418manage, attach with =issue milestone= / =mr milestone=; progress shows
417on the web at =/owner/name/milestones=): 419on the web at =/owner/name/milestones=, where writers create, close and
420reopen them):
418 421
419#+begin_src sh 422#+begin_src sh
420gitbay milestone create v1.0 --description "first release" --due 2027-01-01 423gitbay 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
428cannot create a label or milestone with a name its org holds. Creating 431cannot create a label or milestone with a name its org holds. Creating
429an org label or milestone whose name repositories under the org already 432an org label or milestone whose name repositories under the org already
430use folds them in: their issues and merge requests move to the org's 433use folds them in: their issues and merge requests move to the org's
431row. Org admins manage them; counts span the repositories you can read. 434row. 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#+begin_src sh 437#+begin_src sh
434gitbay org label set acme bug --color cf222e 438gitbay 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* Unreleased 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- The repository settings page gains access grants and effective access, 14- The repository settings page gains access grants and effective access,
10 webhooks (add, remove, deliveries, redeliver; the secret is a form 15 webhooks (add, remove, deliveries, redeliver; the secret is a form
11 field passed on stdin and never shown again), rename, transfer and 16 field passed on stdin and never shown again), rename, transfer and
e2e/assetweb_test.go added +85
@@ -0,0 +1,85 @@
1package e2e
2
3import (
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).
16func 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
103func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string) (msg string, code int) { 103func (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.
109func (s *Server) runControlReader(u store.User, argv []string, stdin io.Reader) (msg string, code int) {
104 var stdout, stderr bytes.Buffer 110 var stdout, stderr bytes.Buffer
105 ctx := &control.Ctx{ 111 ctx := &control.Ctx{
106 User: u, 112 User: u,
@@ -108,7 +114,7 @@ func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string)
108 Scope: "full", 114 Scope: "full",
109 Store: s.st, 115 Store: s.st,
110 Cfg: s.cfg, 116 Cfg: s.cfg,
111 Stdin: strings.NewReader(stdin), 117 Stdin: stdin,
112 Stdout: &stdout, 118 Stdout: &stdout,
113 Stderr: &stderr, 119 Stderr: &stderr,
114 ViaAPI: true, 120 ViaAPI: true,
internal/httpd/milestoneactions.go added +154
@@ -0,0 +1,154 @@
1package httpd
2
3import (
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.
19func 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
30func (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
51func (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
56func (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
76func (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.
103func (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// see it; anyone else only when some repository under the org is 12// see it; anyone else only when some repository under the org is
13// readable. Everything else is not found, the same answer as for a 13// readable. Everything else is not found, the same answer as for a
14// user owner or an unknown name. 14// user owner or an unknown name.
15func (s *Server) orgScope(w http.ResponseWriter, r *http.Request) (store.Org, store.User, []int64, bool) { 15func (s *Server) orgScope(w http.ResponseWriter, r *http.Request) (store.Org, store.User, []int64, bool, bool) {
16 viewer := s.viewer(r) 16 viewer := s.viewer(r)
17 org, err := s.st.OrgByName(r.PathValue("owner")) 17 org, err := s.st.OrgByName(r.PathValue("owner"))
18 if err != nil { 18 if err != nil {
19 s.notFound(w, r) 19 s.notFound(w, r)
20 return org, viewer, nil, false 20 return org, viewer, nil, false, false
21 } 21 }
22 readable, err := control.ReadableOrgRepoIDs(s.st, viewer, org.ID) 22 readable, err := control.ReadableOrgRepoIDs(s.st, viewer, org.ID)
23 if err != nil { 23 if err != nil {
24 http.Error(w, "internal error", http.StatusInternalServerError) 24 http.Error(w, "internal error", http.StatusInternalServerError)
25 return org, viewer, nil, false 25 return org, viewer, nil, false, false
26 } 26 }
27 role := "" 27 role := ""
28 if viewer.ID != 0 { 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 if role == "" && len(readable) == 0 { 31 if role == "" && len(readable) == 0 {
32 s.notFound(w, r) 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
38func (s *Server) orgLabels(w http.ResponseWriter, r *http.Request) { 38func (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 if !ok { 40 if !ok {
41 return 41 return
42 } 42 }
@@ -54,11 +54,13 @@ func (s *Server) orgLabels(w http.ResponseWriter, r *http.Request) {
54 Org string 54 Org string
55 Labels []store.Label 55 Labels []store.Label
56 LabelColors map[string]template.CSS 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
60func (s *Server) orgMilestones(w http.ResponseWriter, r *http.Request) { 62func (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 if !ok { 64 if !ok {
63 return 65 return
64 } 66 }
@@ -88,5 +90,7 @@ func (s *Server) orgMilestones(w http.ResponseWriter, r *http.Request) {
88 Org string 90 Org string
89 State string 91 State string
90 Milestones []msView 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 @@
1package httpd
2
3import (
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
16type 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
27func 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
46func (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
57func (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.
62func (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
73func (p *p296b) get(path string, ck *http.Cookie) string {
74 return p.do("GET", path, ck, nil, "").Body.String()
75}
76
77func 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
92func 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
155func 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
171func 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
208func 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
223func 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
257func 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
272func 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
302func 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 Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))}, 175 Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))},
176 Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true, 176 Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true,
177 Handler: s.checkOrigin(s.requireUser(s.labelSubmit))}, 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 Route{Method: "GET", Pattern: "/bookmarks", Handler: s.requireUser(s.bookmarksPage)}, 186 Route{Method: "GET", Pattern: "/bookmarks", Handler: s.requireUser(s.bookmarksPage)},
179 Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)}, 187 Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)},
180 Route{Method: "POST", Pattern: "/{owner}/-/snippets/new", Mutating: true, 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 repoPage 897 repoPage
898 State string 898 State string
899 Milestones []msView 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// search runs a bounded literal git grep over the repo's default branch. 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 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a> 8 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a>
9 </nav> 9 </nav>
10</div> 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<ul class="milestonelist"> 26<ul class="milestonelist">
12{{range .Milestones}}<li> 27{{range .Milestones}}<li>
13 <div class="msmain"> 28 <div class="msmain">
@@ -15,6 +30,10 @@
15 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}} 30 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
16 <p class="meta">{{if .DueDate}}due {{.DueDate}} · {{end}}{{.ClosedItems}} closed, {{.OpenItems}} open · {{.Percent}}%</p> 31 <p class="meta">{{if .DueDate}}due {{.DueDate}} · {{end}}{{.ClosedItems}} closed, {{.OpenItems}} open · {{.Percent}}%</p>
17 <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div> 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 </div> 37 </div>
19</li> 38</li>
20{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}milestones</li>{{end}} 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{{define "title"}}labels · {{.Org}}{{end}} 1{{define "title"}}labels · {{.Org}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<h1><a href="/{{.Org}}">{{.Org}}</a> labels</h1> 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}} &lt;label&gt;</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{{if .Labels}}<div class="tablewrap"><table class="keys"> 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{{range .Labels}}<tr> 8{{range .Labels}}<tr>
8 <td><span class="chip label" style="{{index $.LabelColors .Name}}">{{.Name}}</span></td> 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 <td>{{.Issues}}</td> 15 <td>{{.Issues}}</td>
11 <td>{{.MRs}}</td> 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</tr> 23</tr>
13{{end}}</table></div> 24{{end}}</table></div>
14{{else}}<p class="none">No org labels yet.</p>{{end}} 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{{end}} 39{{end}}
internal/web/templates/orgmilestones.html +19
@@ -8,6 +8,21 @@
8 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a> 8 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a>
9 </nav> 9 </nav>
10</div> 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<p class="meta">Progress spans the repositories under {{.Org}} you can read.</p> 26<p class="meta">Progress spans the repositories under {{.Org}} you can read.</p>
12<ul class="milestonelist"> 27<ul class="milestonelist">
13{{range .Milestones}}<li> 28{{range .Milestones}}<li>
@@ -16,6 +31,10 @@
16 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}} 31 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
17 <p class="meta">{{if .DueDate}}due {{.DueDate}} · {{end}}{{.ClosedItems}} closed, {{.OpenItems}} open · {{.Percent}}%</p> 32 <p class="meta">{{if .DueDate}}due {{.DueDate}} · {{end}}{{.ClosedItems}} closed, {{.OpenItems}} open · {{.Percent}}%</p>
18 <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div> 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 </div> 38 </div>
20</li> 39</li>
21{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}org milestones</li>{{end}} 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 <p>{{template "confirmfield" $rel.Tag}} <button type="submit" class="danger">Delete release</button></p> 43 <p>{{template "confirmfield" $rel.Tag}} <button type="submit" class="danger">Delete release</button></p>
44 </form>{{end}}</details>{{end}} 44 </form>{{end}}</details>{{end}}
45 {{if $rel.Assets}}<table class="assets"> 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 {{range $rel.Assets}}<tr> 47 {{range $rel.Assets}}<tr>
48 <td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases/download/{{$rel.Tag}}/{{.Name}}">{{.Name}}</a></td> 48 <td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases/download/{{$rel.Tag}}/{{.Name}}">{{.Name}}</a></td>
49 <td class="size">{{.Size}}</td> 49 <td class="size">{{.Size}}</td>
50 <td class="sha"><code title="{{.SHA256}}">{{short .SHA256}}</code></td> 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 </tr>{{end}} 58 </tr>{{end}}
52 </table>{{end}} 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</article> 68</article>
54{{else}}<p class="empty-note">no releases yet</p>{{end}} 69{{else}}<p class="empty-note">no releases yet</p>{{end}}
55{{end}} 70{{end}}