Commit 784b5dfad3

784b5dfad3f6ed718ada2a43910225c220abb310

parent: eec3395d09

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-11 15:20 UTC

control, web: repository commands see org labels and milestones and refuse to change them

Ref #203
internal/control/ghimport.go +1 −1
@@ -256,7 +256,7 @@ func runImportIssues(c *Ctx, args []string) int {
256256 return c.fail(protocol.ExitFailure, "%v", err)
257257 }
258258 for _, l := range it.Labels {
259 c.Store.SetIssueLabel(repo.ID, iss.ID, l.Name, true)
259 c.Store.SetIssueLabel(repo, iss.ID, l.Name, true)
260260 }
261261 if it.State != "open" {
262262 c.Store.SetIssueState(iss.ID, "closed")
internal/control/issue.go +2 −2
@@ -388,12 +388,12 @@ func runIssueLabel(c *Ctx, args []string) int {
388388 return code
389389 }
390390 for _, l := range adds {
391 if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, true); err != nil {
391 if err := c.Store.SetIssueLabel(repo, issue.ID, l, true); err != nil {
392392 return c.fail(protocol.ExitFailure, "%v", err)
393393 }
394394 }
395395 for _, l := range removes {
396 if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, false); err != nil {
396 if err := c.Store.SetIssueLabel(repo, issue.ID, l, false); err != nil {
397397 if errors.Is(err, store.ErrNotFound) {
398398 return c.fail(protocol.ExitNotFound, "%v", err)
399399 }
internal/control/label.go +16 −10
@@ -37,13 +37,17 @@ func runLabelList(c *Ctx, args []string) int {
3737 if code >= 0 {
3838 return code
3939 }
40 labels, err := c.Store.ListLabels(repo.ID)
40 readable, err := ReadableScope(c.Store, c.User, repo)
41 if err != nil {
42 return c.fail(protocol.ExitFailure, "%v", err)
43 }
44 labels, err := c.Store.ListLabels(repo, readable)
4145 if err != nil {
4246 return c.fail(protocol.ExitFailure, "%v", err)
4347 }
4448 return c.emit(labels, func(w io.Writer) {
4549 for _, l := range labels {
46 fmt.Fprintf(w, "%s\t%s\t%d\n", l.Name, l.Color, l.Issues)
50 fmt.Fprintf(w, "%s\t%s\t%d%s\n", l.Name, l.Color, l.Issues, map[bool]string{true: "\torg"}[l.Org])
4751 }
4852 })
4953}
@@ -78,15 +82,14 @@ func runLabelSet(c *Ctx, args []string) int {
7882 }
7983 if !colorSet {
8084 // Keep the colour it has, if any; this is "make sure it exists".
81 if labels, err := c.Store.ListLabels(repo.ID); err == nil {
82 for _, l := range labels {
83 if l.Name == name {
84 color = l.Color
85 }
86 }
85 if l, err := c.Store.LabelByName(repo, name); err == nil && !l.Org {
86 color = l.Color
8787 }
8888 }
89 if err := c.Store.SetLabel(repo.ID, name, color); err != nil {
89 if err := c.Store.SetLabel(repo, name, color); err != nil {
90 if errors.Is(err, store.ErrOrgScoped) {
91 return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "label", name, "set"))
92 }
9093 return c.fail(protocol.ExitFailure, "%v", err)
9194 }
9295 return c.emit(store.Label{Name: name, Color: color}, func(w io.Writer) {
@@ -109,7 +112,10 @@ func runLabelRemove(c *Ctx, args []string) int {
109112 if code := refuseArchived(c, repo); code >= 0 {
110113 return code
111114 }
112 if err := c.Store.DeleteLabel(repo.ID, args[1]); err != nil {
115 if err := c.Store.DeleteLabel(repo, args[1]); err != nil {
116 if errors.Is(err, store.ErrOrgScoped) {
117 return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "label", args[1], "remove"))
118 }
113119 if errors.Is(err, store.ErrNotFound) {
114120 return c.fail(protocol.ExitNotFound, "no label %q in %s", args[1], repo.Path())
115121 }
internal/control/migrate.go +1 −1
@@ -247,7 +247,7 @@ func runAccountImportBundle(c *Ctx, args []string) int {
247247 return c.fail(protocol.ExitFailure, "%v", err)
248248 }
249249 for _, l := range bi.Labels {
250 c.Store.SetIssueLabel(repo.ID, iss.ID, l, true)
250 c.Store.SetIssueLabel(repo, iss.ID, l, true)
251251 }
252252 if bi.State != "open" {
253253 c.Store.SetIssueState(iss.ID, "closed")
internal/control/milestone.go +21 −6
@@ -60,7 +60,10 @@ func runMilestoneCreate(c *Ctx, args []string) int {
6060 if code := refuseArchived(c, repo); code >= 0 {
6161 return code
6262 }
63 if _, err := c.Store.CreateMilestone(repo.ID, title, description, due); err != nil {
63 if _, err := c.Store.CreateMilestone(repo, title, description, due); err != nil {
64 if errors.Is(err, store.ErrOrgScoped) {
65 return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "milestone", title, "create"))
66 }
6467 return c.failErr(err)
6568 }
6669 return c.emit(map[string]string{"milestone": title}, func(w io.Writer) {
@@ -84,7 +87,11 @@ func runMilestoneList(c *Ctx, args []string) int {
8487 if code >= 0 {
8588 return code
8689 }
87 ms, err := c.Store.ListMilestones(repo.ID, state)
90 readable, err := ReadableScope(c.Store, c.User, repo)
91 if err != nil {
92 return c.fail(protocol.ExitFailure, "%v", err)
93 }
94 ms, err := c.Store.ListMilestones(repo, state, readable)
8895 if err != nil {
8996 return c.fail(protocol.ExitFailure, "%v", err)
9097 }
@@ -93,12 +100,13 @@ func runMilestoneList(c *Ctx, args []string) int {
93100 Description string `json:"description,omitempty"`
94101 Due string `json:"due,omitempty"`
95102 State string `json:"state"`
103 Org bool `json:"org,omitempty"`
96104 Open int `json:"open"`
97105 Closed int `json:"closed"`
98106 }
99107 var ds []out
100108 for _, m := range ms {
101 ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OpenItems, m.ClosedItems})
109 ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OrgID != 0, m.OpenItems, m.ClosedItems})
102110 }
103111 return c.emit(ds, func(w io.Writer) {
104112 for _, d := range ds {
@@ -106,7 +114,11 @@ func runMilestoneList(c *Ctx, args []string) int {
106114 if due == "" {
107115 due = "-"
108116 }
109 fmt.Fprintf(w, "%s\t%s\tdue %s\t%d open, %d closed\n", d.Title, d.State, due, d.Open, d.Closed)
117 mark := ""
118 if d.Org {
119 mark = "\torg"
120 }
121 fmt.Fprintf(w, "%s\t%s\tdue %s\t%d open, %d closed%s\n", d.Title, d.State, due, d.Open, d.Closed, mark)
110122 }
111123 })
112124}
@@ -129,10 +141,13 @@ func setMilestoneState(c *Ctx, args []string, state string) int {
129141 if code := refuseArchived(c, repo); code >= 0 {
130142 return code
131143 }
132 m, err := c.Store.MilestoneByTitle(repo.ID, args[1])
144 m, err := c.Store.MilestoneByTitle(repo, args[1])
133145 if err != nil {
134146 return milestoneErr(c, repo, args[1], err)
135147 }
148 if m.OrgID != 0 {
149 return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "milestone", m.Title, verb))
150 }
136151 if err := c.Store.SetMilestoneState(m.ID, state); err != nil {
137152 return c.fail(protocol.ExitFailure, "%v", err)
138153 }
@@ -184,7 +199,7 @@ func runMRMilestone(c *Ctx, args []string) int {
184199func setItemMilestone(c *Ctx, repo store.Repo, noun string, number int64, title string, set func(int64) error) int {
185200 var id int64
186201 if title != "none" {
187 m, err := c.Store.MilestoneByTitle(repo.ID, title)
202 m, err := c.Store.MilestoneByTitle(repo, title)
188203 if err != nil {
189204 return milestoneErr(c, repo, title, err)
190205 }
internal/control/orgscope_test.go added +154
@@ -0,0 +1,154 @@
1package control
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/config"
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13// orgFixture: alice admins org acme with acme/core (public) and acme/priv
14// (private); bob is a plain member; carol is outside. alice also owns
15// alice/app.
16type orgFixture struct {
17 st *store.Store
18 alice, bob, carol int64
19 org int64
20 core, priv, app store.Repo
21}
22
23func newOrgFixture(t *testing.T) orgFixture {
24 t.Helper()
25 st, err := store.Open(":memory:")
26 if err != nil {
27 t.Fatal(err)
28 }
29 t.Cleanup(func() { st.Close() })
30 if err := st.MigrateUp(); err != nil {
31 t.Fatal(err)
32 }
33 var f orgFixture
34 f.st = st
35 user := func(name string) int64 {
36 id, err := st.CreateUser(name, false)
37 if err != nil {
38 t.Fatal(err)
39 }
40 return id
41 }
42 f.alice, f.bob, f.carol = user("alice"), user("bob"), user("carol")
43 if f.org, err = st.CreateOrg("acme", f.alice); err != nil {
44 t.Fatal(err)
45 }
46 if err := st.SetOrgMember(f.org, f.bob, "member"); err != nil {
47 t.Fatal(err)
48 }
49 repo := func(kind string, owner int64, name, vis string) store.Repo {
50 id, err := st.CreateRepo(kind, owner, name, vis)
51 if err != nil {
52 t.Fatal(err)
53 }
54 r, _ := st.RepoByID(id)
55 return r
56 }
57 f.core = repo("org", f.org, "core", "public")
58 f.priv = repo("org", f.org, "priv", "private")
59 f.app = repo("user", f.alice, "app", "public")
60 return f
61}
62
63func (f orgFixture) ctx(uid int64) (*Ctx, *bytes.Buffer) {
64 var out bytes.Buffer
65 name := map[int64]string{f.alice: "alice", f.bob: "bob", f.carol: "carol"}[uid]
66 return &Ctx{
67 User: store.User{ID: uid, Username: name},
68 Scope: "full",
69 Source: "SHA256:session",
70 Store: f.st,
71 Cfg: config.Config{Server: config.Server{SiteURL: "https://x.test"}},
72 Stdin: strings.NewReader(""),
73 Stdout: &out,
74 Stderr: &out,
75 JSON: true,
76 }, &out
77}
78
79func TestReadableOrgRepoIDs(t *testing.T) {
80 f := newOrgFixture(t)
81 ids, err := ReadableOrgRepoIDs(f.st, store.User{ID: f.bob, Username: "bob"}, f.org)
82 if err != nil || len(ids) != 2 {
83 t.Fatalf("member reads %v, %v; want both", ids, err)
84 }
85 ids, _ = ReadableOrgRepoIDs(f.st, store.User{ID: f.carol, Username: "carol"}, f.org)
86 if len(ids) != 1 || ids[0] != f.core.ID {
87 t.Fatalf("outsider reads %v; want core only", ids)
88 }
89 ids, _ = ReadableOrgRepoIDs(f.st, store.User{}, f.org)
90 if len(ids) != 1 || ids[0] != f.core.ID {
91 t.Fatalf("anonymous reads %v; want core only", ids)
92 }
93 ids, _ = ReadableScope(f.st, store.User{ID: f.alice, Username: "alice"}, f.app)
94 if len(ids) != 1 || ids[0] != f.app.ID {
95 t.Fatalf("user repo scope %v; want itself", ids)
96 }
97}
98
99func TestRepoLabelCommandsRefuseOrgNames(t *testing.T) {
100 f := newOrgFixture(t)
101 if _, err := f.st.SetOrgLabel(f.org, "bug", ""); err != nil {
102 t.Fatal(err)
103 }
104 c, out := f.ctx(f.alice)
105 if code := runLabelSet(c, []string{"acme/core", "bug", "--color", "ff0000"}); code != protocol.ExitFailure ||
106 !strings.Contains(out.String(), "org label set acme bug") {
107 t.Fatalf("label set over org name: exit %d %s", code, out.String())
108 }
109 out.Reset()
110 if code := runLabelRemove(c, []string{"acme/core", "bug"}); code != protocol.ExitFailure ||
111 !strings.Contains(out.String(), "org label remove acme bug") {
112 t.Fatalf("label remove of org row: exit %d %s", code, out.String())
113 }
114 out.Reset()
115 // issue label --add resolves to the org row, and label list marks it.
116 iid, _ := f.st.CreateIssue(f.core.ID, f.alice, "c1", "", "md")
117 _ = iid
118 if code := runIssueLabel(c, []string{"acme/core", "1", "--add", "bug"}); code != protocol.ExitOK {
119 t.Fatalf("issue label: exit %d %s", code, out.String())
120 }
121 out.Reset()
122 if code := runLabelList(c, []string{"acme/core"}); code != protocol.ExitOK ||
123 !strings.Contains(out.String(), `"org":true`) || !strings.Contains(out.String(), `"issues":1`) {
124 t.Fatalf("label list: exit %d %s", code, out.String())
125 }
126}
127
128func TestRepoMilestoneCommandsRefuseOrgTitles(t *testing.T) {
129 f := newOrgFixture(t)
130 if _, _, err := f.st.CreateOrgMilestone(f.org, "v1", "", ""); err != nil {
131 t.Fatal(err)
132 }
133 c, out := f.ctx(f.alice)
134 if code := runMilestoneCreate(c, []string{"acme/core", "v1"}); code != protocol.ExitFailure ||
135 !strings.Contains(out.String(), "org milestone create acme v1") {
136 t.Fatalf("milestone create over org title: exit %d %s", code, out.String())
137 }
138 out.Reset()
139 if code := runMilestoneClose(c, []string{"acme/core", "v1"}); code != protocol.ExitFailure ||
140 !strings.Contains(out.String(), "org milestone close acme v1") {
141 t.Fatalf("milestone close of org row: exit %d %s", code, out.String())
142 }
143 out.Reset()
144 // Attaching by title from a repo resolves the org milestone.
145 f.st.CreateIssue(f.core.ID, f.alice, "c1", "", "md")
146 if code := runIssueMilestone(c, []string{"acme/core", "1", "v1"}); code != protocol.ExitOK {
147 t.Fatalf("issue milestone: exit %d %s", code, out.String())
148 }
149 out.Reset()
150 if code := runMilestoneList(c, []string{"acme/core"}); code != protocol.ExitOK ||
151 !strings.Contains(out.String(), `"org":true`) || !strings.Contains(out.String(), `"open":1`) {
152 t.Fatalf("milestone list: exit %d %s", code, out.String())
153 }
154}
internal/control/scope.go added +48
@@ -0,0 +1,48 @@
1package control
2
3import (
4 "fmt"
5
6 "gitbay.org/gitbay/internal/policy"
7 "gitbay.org/gitbay/internal/store"
8)
9
10// ReadableOrgRepoIDs is the org's repositories user may read. Counts on
11// org labels and milestones are taken over these, so a private
12// repository's issues never show in a number someone outside it sees. A
13// zero user is anonymous.
14func ReadableOrgRepoIDs(st *store.Store, user store.User, orgID int64) ([]int64, error) {
15 repos, err := st.ListReposForOwner("org", orgID)
16 if err != nil {
17 return nil, err
18 }
19 var ids []int64
20 for _, r := range repos {
21 grant := ""
22 if user.ID != 0 {
23 if grant, err = st.AccessRole(r.ID, user.ID); err != nil {
24 return nil, err
25 }
26 }
27 if policy.CanRead(user, r, grant) {
28 ids = append(ids, r.ID)
29 }
30 }
31 return ids, nil
32}
33
34// ReadableScope is the set a repository's label and milestone counts
35// span: its org's readable repositories, or just itself when a user owns
36// it. The caller has already been allowed to read repo.
37func ReadableScope(st *store.Store, user store.User, repo store.Repo) ([]int64, error) {
38 if repo.OwnerKind == "org" {
39 return ReadableOrgRepoIDs(st, user, repo.OwnerID)
40 }
41 return []int64{repo.ID}, nil
42}
43
44// orgScopedMsg names the org command that manages a row a repository
45// command was asked to change.
46func orgScopedMsg(repo store.Repo, noun, name, verb string) string {
47 return fmt.Sprintf("%s is an org %s of %s; manage it with org %s %s %s %s", name, noun, repo.OwnerName, noun, verb, repo.OwnerName, name)
48}
internal/httpd/labels.go +8 −2
@@ -5,6 +5,7 @@ import (
55 "net/http"
66 "strings"
77
8 "gitbay.org/gitbay/internal/control"
89 "gitbay.org/gitbay/internal/store"
910)
1011
@@ -17,7 +18,12 @@ func (s *Server) labels(w http.ResponseWriter, r *http.Request) {
1718 return
1819 }
1920 p.Tab = "issues"
20 labels, err := s.st.ListLabels(p.Repo.ID)
21 readable, err := control.ReadableScope(s.st, s.viewer(r), p.Repo)
22 if err != nil {
23 http.Error(w, "internal error", http.StatusInternalServerError)
24 return
25 }
26 labels, err := s.st.ListLabels(p.Repo, readable)
2127 if err != nil {
2228 http.Error(w, "internal error", http.StatusInternalServerError)
2329 return
@@ -28,7 +34,7 @@ func (s *Server) labels(w http.ResponseWriter, r *http.Request) {
2834 LabelColors map[string]template.CSS
2935 CanWrite bool
3036 Notice string
31 }{p, labels, s.labelColors(p.Repo.ID), s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
37 }{p, labels, s.labelColors(p.Repo), s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
3238}
3339
3440// labelSubmit creates a label, sets its colour, or removes it, through
internal/httpd/web.go +23 −6
@@ -702,7 +702,12 @@ func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
702702 if state != "closed" && state != "all" {
703703 state = "open"
704704 }
705 ms, err := s.st.ListMilestones(p.Repo.ID, state)
705 readable, err := control.ReadableScope(s.st, s.viewer(r), p.Repo)
706 if err != nil {
707 http.Error(w, "internal error", http.StatusInternalServerError)
708 return
709 }
710 ms, err := s.st.ListMilestones(p.Repo, state, readable)
706711 if err != nil {
707712 http.Error(w, "internal error", http.StatusInternalServerError)
708713 return
@@ -1603,8 +1608,15 @@ func hexByte(s string) int64 {
16031608// labelColors returns a complete label-name -> chip color map for a repo:
16041609// the stored labels.color when it is a valid hex color, otherwise a
16051610// stable default picked from the palette by name hash.
1606func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1607 stored, _ := s.st.LabelColors(repoID)
1611func (s *Server) labelColors(repo store.Repo) map[string]template.CSS {
1612 stored, _ := s.st.LabelColors(repo)
1613 return colorStyles(stored)
1614}
1615
1616// colorStyles turns a label-name -> stored color map into chip styles: the
1617// stored color when it is a valid hex color, otherwise a stable default
1618// picked from the palette by name hash.
1619func colorStyles(stored map[string]string) map[string]template.CSS {
16081620 out := make(map[string]template.CSS, len(stored))
16091621 for name, color := range stored {
16101622 if !hexColorPat.MatchString(color) {
@@ -1672,7 +1684,7 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
16721684 Older string
16731685 }{p, state, f.Label, f.Search,
16741686 activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1675 issues, s.labelColors(p.Repo.ID), older})
1687 issues, s.labelColors(p.Repo), older})
16761688}
16771689
16781690func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
@@ -1697,7 +1709,12 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
16971709 return
16981710 }
16991711 md := s.ugcFor(r, p.Repo)
1700 milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1712 readable, err := control.ReadableScope(s.st, s.viewer(r), p.Repo)
1713 if err != nil {
1714 http.Error(w, "internal error", http.StatusInternalServerError)
1715 return
1716 }
1717 milestones, _ := s.st.ListMilestones(p.Repo, "open", readable)
17011718 s.render(w, "issue.html", struct {
17021719 repoPage
17031720 Issue store.Issue
@@ -1710,7 +1727,7 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
17101727 LabelColors map[string]template.CSS
17111728 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
17121729 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1713 milestones, s.takeFlash(w, r), s.labelColors(p.Repo.ID)})
1730 milestones, s.takeFlash(w, r), s.labelColors(p.Repo)})
17141731}
17151732
17161733// canEditItem: the author or anyone with write access may edit.