Commit fed065d4fe

fed065d4fee3e82f2b80087691e9d9eca17083da

parent: 784b5dfad3

Verified · cmc

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

control, cli: org label set, list, remove

Ref #203
cmd/gitbay/main.go +5
@@ -652,6 +652,11 @@ func orgCmd() *cobra.Command {
652652 pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}),
653653 pass("list", "list members: <org>", passOpts{server: []string{"org", "members", "list"}}),
654654 ),
655 group("label", "labels every org repository sees",
656 pass("set", "create an org label or set its colour: <org> <label> [--color rrggbb|'']", passOpts{server: []string{"org", "label", "set"}}),
657 pass("list", "list org labels with use across readable repositories: <org>", passOpts{server: []string{"org", "label", "list"}}),
658 pass("remove", "remove an org label everywhere: <org> <label>", passOpts{server: []string{"org", "label", "remove"}}),
659 ),
655660 group("team", "scope repository access with teams",
656661 pass("create", "create a team: <org> <team>", passOpts{server: []string{"org", "team", "create"}}),
657662 pass("delete", "delete a team: <org> <team>", passOpts{server: []string{"org", "team", "delete"}}),
e2e/readonly_test.go +1
@@ -107,6 +107,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
107107 "org members list": {"theorg"},
108108 "org team list": {"theorg"},
109109 "org team show": {"theorg", "core"},
110 "org label list": {"theorg"},
110111 "repo search": {"app"},
111112 "repo show": {"alice/app"},
112113 "repo access list": {"alice/app"},
internal/control/orglabel.go added +143
@@ -0,0 +1,143 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "strings"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func init() {
14 register(Command{Path: []string{"org", "label", "set"},
15 Summary: "create an org label every org repository sees, or set its colour; folds in same-named repo labels",
16 Usage: "org label set <org> <label> [--color rrggbb|'']", Run: runOrgLabelSet})
17 register(Command{Path: []string{"org", "label", "list"},
18 Summary: "list an org's labels with use across the repositories you can read",
19 Usage: "org label list <org>", ReadOnly: true, Run: runOrgLabelList})
20 register(Command{Path: []string{"org", "label", "remove"},
21 Summary: "remove an org label from the org and from every issue under it",
22 Usage: "org label remove <org> <label>", Run: runOrgLabelRemove})
23}
24
25// orgReader resolves an org for a read of its labels or milestones.
26// Members read; an outsider reads when some repository under the org is
27// readable, and is refused rather than told the org is missing otherwise,
28// since an org's existence is public anyway. The readable ids come back
29// because every read counts over them.
30func orgReader(c *Ctx, name string) (store.Org, []int64, int) {
31 org, err := c.Store.OrgByName(name)
32 if errors.Is(err, store.ErrNotFound) {
33 return org, nil, c.fail(protocol.ExitNotFound, "no organization %q", name)
34 }
35 if err != nil {
36 return org, nil, c.fail(protocol.ExitFailure, "%v", err)
37 }
38 readable, err := ReadableOrgRepoIDs(c.Store, c.User, org.ID)
39 if err != nil {
40 return org, nil, c.fail(protocol.ExitFailure, "%v", err)
41 }
42 role, err := c.Store.OrgRole(org.ID, c.User.ID)
43 if err != nil {
44 return org, nil, c.fail(protocol.ExitFailure, "%v", err)
45 }
46 if role == "" && len(readable) == 0 {
47 return org, nil, c.fail(protocol.ExitDenied, "labels and milestones of %s are visible to its members", name)
48 }
49 return org, readable, -1
50}
51
52func runOrgLabelSet(c *Ctx, args []string) int {
53 const usage = "usage: org label set <org> <label> [--color rrggbb|'']"
54 f, err := parseFlags(args, flagSpec{Values: []string{"--color"}, MaxPos: 2, Usage: usage})
55 if err != nil {
56 return c.fail(protocol.ExitUsage, "%v", err)
57 }
58 orgName, name := f.pos(0), f.pos(1)
59 color, colorSet := strings.ToLower(f.Value("--color")), f.Has("--color")
60 if orgName == "" || name == "" {
61 return c.fail(protocol.ExitUsage, usage)
62 }
63 if name == "" || len(name) > 50 {
64 return c.fail(protocol.ExitUsage, "a label is 1 to 50 characters")
65 }
66 if colorSet && color != "" {
67 if !labelColorPat.MatchString(color) {
68 return c.fail(protocol.ExitUsage, "--color takes rrggbb (with or without #), or '' to clear")
69 }
70 color = "#" + strings.TrimPrefix(color, "#")
71 }
72 org, code := orgAdmin(c, orgName)
73 if code >= 0 {
74 return code
75 }
76 if !colorSet {
77 // Keep the colour it has, if any; this is "make sure it exists".
78 if labels, err := c.Store.ListOrgLabels(org.ID, nil); err == nil {
79 for _, l := range labels {
80 if l.Name == name {
81 color = l.Color
82 }
83 }
84 }
85 }
86 folded, err := c.Store.SetOrgLabel(org.ID, name, color)
87 if err != nil {
88 return c.fail(protocol.ExitFailure, "%v", err)
89 }
90 return c.emit(struct {
91 Name string `json:"name"`
92 Color string `json:"color,omitempty"`
93 Folded int `json:"folded"`
94 }{name, color, folded}, func(w io.Writer) {
95 if color == "" {
96 fmt.Fprintf(w, "org label %s on %s, no colour set", name, org.Name)
97 } else {
98 fmt.Fprintf(w, "org label %s on %s is %s", name, org.Name, color)
99 }
100 if folded > 0 {
101 fmt.Fprintf(w, "; folded in %d repositor%s", folded, map[bool]string{true: "y", false: "ies"}[folded == 1])
102 }
103 fmt.Fprintln(w)
104 })
105}
106
107func runOrgLabelList(c *Ctx, args []string) int {
108 if len(args) != 1 {
109 return c.fail(protocol.ExitUsage, "usage: org label list <org>")
110 }
111 org, readable, code := orgReader(c, args[0])
112 if code >= 0 {
113 return code
114 }
115 labels, err := c.Store.ListOrgLabels(org.ID, readable)
116 if err != nil {
117 return c.fail(protocol.ExitFailure, "%v", err)
118 }
119 return c.emit(labels, func(w io.Writer) {
120 for _, l := range labels {
121 fmt.Fprintf(w, "%s\t%s\t%d\n", l.Name, l.Color, l.Issues)
122 }
123 })
124}
125
126func runOrgLabelRemove(c *Ctx, args []string) int {
127 if len(args) != 2 {
128 return c.fail(protocol.ExitUsage, "usage: org label remove <org> <label>")
129 }
130 org, code := orgAdmin(c, args[0])
131 if code >= 0 {
132 return code
133 }
134 if err := c.Store.DeleteOrgLabel(org.ID, args[1]); err != nil {
135 if errors.Is(err, store.ErrNotFound) {
136 return c.fail(protocol.ExitNotFound, "no org label %q on %s", args[1], org.Name)
137 }
138 return c.fail(protocol.ExitFailure, "%v", err)
139 }
140 return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
141 fmt.Fprintf(w, "removed org label %s from %s\n", args[1], org.Name)
142 })
143}
internal/control/orglabel_test.go added +71
@@ -0,0 +1,71 @@
1package control
2
3import (
4 "strings"
5 "testing"
6
7 "gitbay.org/gitbay/internal/protocol"
8)
9
10func TestOrgLabelSetListRemove(t *testing.T) {
11 f := newOrgFixture(t)
12 // Two repos already hold bug; the org set folds them in.
13 f.st.SetLabel(f.core, "bug", "")
14 f.st.SetLabel(f.priv, "bug", "")
15 c, out := f.ctx(f.alice)
16 if code := runOrgLabelSet(c, []string{"acme", "bug", "--color", "ff0000"}); code != protocol.ExitOK ||
17 !strings.Contains(out.String(), `"folded":2`) {
18 t.Fatalf("set: exit %d %s", code, out.String())
19 }
20 out.Reset()
21 if code := runOrgLabelList(c, []string{"acme"}); code != protocol.ExitOK ||
22 !strings.Contains(out.String(), `"name":"bug"`) || !strings.Contains(out.String(), `"color":"#ff0000"`) {
23 t.Fatalf("list: exit %d %s", code, out.String())
24 }
25 out.Reset()
26 if code := runOrgLabelRemove(c, []string{"acme", "bug"}); code != protocol.ExitOK {
27 t.Fatalf("remove: exit %d %s", code, out.String())
28 }
29 out.Reset()
30 if code := runOrgLabelRemove(c, []string{"acme", "bug"}); code != protocol.ExitNotFound {
31 t.Fatalf("second remove: exit %d %s", code, out.String())
32 }
33}
34
35func TestOrgLabelWritesNeedOrgAdmin(t *testing.T) {
36 f := newOrgFixture(t)
37 c, out := f.ctx(f.bob)
38 if code := runOrgLabelSet(c, []string{"acme", "bug"}); code != protocol.ExitDenied {
39 t.Fatalf("member set: exit %d %s", code, out.String())
40 }
41 if code := runOrgLabelRemove(c, []string{"acme", "bug"}); code != protocol.ExitDenied {
42 t.Fatalf("member remove: exit %d %s", code, out.String())
43 }
44 c, out = f.ctx(f.alice)
45 if code := runOrgLabelSet(c, []string{"nope", "bug"}); code != protocol.ExitNotFound {
46 t.Fatalf("missing org: exit %d %s", code, out.String())
47 }
48 if code := runOrgLabelSet(c, []string{"acme", "bug", "--color", "zz"}); code != protocol.ExitUsage {
49 t.Fatalf("bad colour: exit %d %s", code, out.String())
50 }
51}
52
53func TestOrgLabelListVisibility(t *testing.T) {
54 f := newOrgFixture(t)
55 f.st.SetOrgLabel(f.org, "bug", "")
56 // Members read; an outsider reads because acme/core is public.
57 for _, uid := range []int64{f.bob, f.carol} {
58 c, out := f.ctx(uid)
59 if code := runOrgLabelList(c, []string{"acme"}); code != protocol.ExitOK {
60 t.Fatalf("user %d list: exit %d %s", uid, code, out.String())
61 }
62 }
63 // With every repo private, the outsider is refused, not told the org
64 // is missing.
65 f.st.SetRepoVisibility(f.core.ID, "private")
66 c, out := f.ctx(f.carol)
67 if code := runOrgLabelList(c, []string{"acme"}); code != protocol.ExitDenied ||
68 !strings.Contains(out.String(), "visible to its members") {
69 t.Fatalf("outsider list: exit %d %s", code, out.String())
70 }
71}