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.
126126| choose body markup | yes | yes | yes |
127127| preview body markup | n/a | yes | no |
128128| 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 |
132132| closes across repositories | yes | yes | yes |
133133
134134Labels 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
139139at =/<owner>/<repo>/labels=, linked from the issue list: create,
140140recolour and remove, dispatching the same commands.
141141
142Org labels and milestones are managed on the CLI, the API and the iOS
143client's org screen; =/<org>/-/labels= and =/<org>/-/milestones= show
144them on the web. The repository
145label page's form exists for colour alone, and three org forms nobody
146asked for were not worth their handlers.
142Org labels and milestones are managed at =/<org>/-/labels= and
143=/<org>/-/milestones=, where org admins see the create, colour, remove,
144close and reopen forms and everyone else sees the list. The repository
145milestones page carries create, close and reopen for writers; a
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=.
147149
148150Issue, MR and release bodies, and their comments, carry the markup they
149151were written in — =--format md|org= on create, comment and edit, stored
@@ -234,8 +236,8 @@ rather than the one the web page shows.
234236| delete, transfer | yes | yes | no |
235237| rename | yes | yes | yes |
236238| 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 |
239241| snippet create, edit, delete | yes | yes | yes |
240242| snippet show, list | yes | yes | yes |
241243| 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
393393#+end_src
394394
395395The 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
397399=/you/project/releases.atom=; commits are at =/you/project/log.atom=
398400(the default branch) or =/you/project/log.atom/<ref>=, and an owner's
399401activity on their public repositories at =/you/activity.atom=. The
@@ -414,7 +416,8 @@ repository there. A bare =owner/name#N= links and does nothing.
414416
415417Milestones group issues and MRs toward a release (write access to
416418manage, 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):
418421
419422#+begin_src sh
420423gitbay 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
428431cannot create a label or milestone with a name its org holds. Creating
429432an org label or milestone whose name repositories under the org already
430433use 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.
432436
433437#+begin_src sh
434438gitbay org label set acme bug --color cf222e
CHANGELOG.org +5
@@ -6,6 +6,11 @@ anything beyond "replace the binary and restart" is needed.
66
77* Unreleased
88
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).
914- The repository settings page gains access grants and effective access,
1015 webhooks (add, remove, deliveries, redeliver; the secret is a form
1116 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
101101}
102102
103103func (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) {
104110 var stdout, stderr bytes.Buffer
105111 ctx := &control.Ctx{
106112 User: u,
@@ -108,7 +114,7 @@ func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string)
108114 Scope: "full",
109115 Store: s.st,
110116 Cfg: s.cfg,
111 Stdin: strings.NewReader(stdin),
117 Stdin: stdin,
112118 Stdout: &stdout,
113119 Stderr: &stderr,
114120 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 (
1212// see it; anyone else only when some repository under the org is
1313// readable. Everything else is not found, the same answer as for a
1414// 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) {
1616 viewer := s.viewer(r)
1717 org, err := s.st.OrgByName(r.PathValue("owner"))
1818 if err != nil {
1919 s.notFound(w, r)
20 return org, viewer, nil, false
20 return org, viewer, nil, false, false
2121 }
2222 readable, err := control.ReadableOrgRepoIDs(s.st, viewer, org.ID)
2323 if err != nil {
2424 http.Error(w, "internal error", http.StatusInternalServerError)
25 return org, viewer, nil, false
25 return org, viewer, nil, false, false
2626 }
2727 role := ""
2828 if viewer.ID != 0 {
@@ -30,13 +30,13 @@ func (s *Server) orgScope(w http.ResponseWriter, r *http.Request) (store.Org, st
3030 }
3131 if role == "" && len(readable) == 0 {
3232 s.notFound(w, r)
33 return org, viewer, nil, false
33 return org, viewer, nil, false, false
3434 }
35 return org, viewer, readable, true
35 return org, viewer, readable, true, role == "admin"
3636}
3737
3838func (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)
4040 if !ok {
4141 return
4242 }
@@ -54,11 +54,13 @@ func (s *Server) orgLabels(w http.ResponseWriter, r *http.Request) {
5454 Org string
5555 Labels []store.Label
5656 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)})
5860}
5961
6062func (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)
6264 if !ok {
6365 return
6466 }
@@ -88,5 +90,7 @@ func (s *Server) orgMilestones(w http.ResponseWriter, r *http.Request) {
8890 Org string
8991 State string
9092 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)})
9296}
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 {
175175 Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))},
176176 Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true,
177177 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))},
178186 Route{Method: "GET", Pattern: "/bookmarks", Handler: s.requireUser(s.bookmarksPage)},
179187 Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)},
180188 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) {
897897 repoPage
898898 State string
899899 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)})
901903}
902904
903905// search runs a bounded literal git grep over the repo's default branch.
internal/web/templates/milestones.html +19
@@ -8,6 +8,21 @@
88 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a>
99 </nav>
1010</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}}
1126<ul class="milestonelist">
1227{{range .Milestones}}<li>
1328 <div class="msmain">
@@ -15,6 +30,10 @@
1530 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
1631 <p class="meta">{{if .DueDate}}due {{.DueDate}} · {{end}}{{.ClosedItems}} closed, {{.OpenItems}} open · {{.Percent}}%</p>
1732 <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}}
1837 </div>
1938</li>
2039{{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 @@
11{{define "title"}}labels · {{.Org}}{{end}}
22{{define "content"}}
33<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>
56{{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>
78{{range .Labels}}<tr>
89 <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>
1015 <td>{{.Issues}}</td>
1116 <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}}
1223</tr>
1324{{end}}</table></div>
1425{{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}}
1539{{end}}
internal/web/templates/orgmilestones.html +19
@@ -8,6 +8,21 @@
88 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a>
99 </nav>
1010</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}}
1126<p class="meta">Progress spans the repositories under {{.Org}} you can read.</p>
1227<ul class="milestonelist">
1328{{range .Milestones}}<li>
@@ -16,6 +31,10 @@
1631 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
1732 <p class="meta">{{if .DueDate}}due {{.DueDate}} · {{end}}{{.ClosedItems}} closed, {{.OpenItems}} open · {{.Percent}}%</p>
1833 <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}}
1938 </div>
2039</li>
2140{{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 @@
4343 <p>{{template "confirmfield" $rel.Tag}} <button type="submit" class="danger">Delete release</button></p>
4444 </form>{{end}}</details>{{end}}
4545 {{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>
4747 {{range $rel.Assets}}<tr>
4848 <td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases/download/{{$rel.Tag}}/{{.Name}}">{{.Name}}</a></td>
4949 <td class="size">{{.Size}}</td>
5050 <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}}
5158 </tr>{{end}}
5259 </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}}
5368</article>
5469{{else}}<p class="empty-note">no releases yet</p>{{end}}
5570{{end}}