internal/control/orglabel.go
270 lines · 9434 bytes
10 symbols in this file
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|'']",
17 Flags: []Flag{
18 {"--color", "rrggbb|''", "the label's colour, or '' to clear it", ""},
19 },
20 Examples: []string{"org label set krz bug --color d73a4a"},
21 Run: runOrgLabelSet})
22 register(Command{Path: []string{"org", "label", "list"},
23 Summary: "list an org's labels with use across the repositories you can read",
24 Usage: "org label list <org>",
25 Examples: []string{"org label list krz"},
26 ReadOnly: true, Run: runOrgLabelList})
27 register(Command{Path: []string{"org", "label", "remove"},
28 Summary: "remove an org label from the org and from every issue under it",
29 Usage: "org label remove <org> <label>",
30 Examples: []string{"org label remove krz wontfix"},
31 Run: runOrgLabelRemove})
32 register(Command{Path: []string{"org", "milestone", "create"},
33 Summary: "create an org milestone spanning every org repository; folds in same-titled repo milestones",
34 Usage: "org milestone create <org> <title> [--description <d>] [--due YYYY-MM-DD]",
35 Flags: []Flag{
36 {"--description", "<d>", "what the milestone covers", ""},
37 {"--due", "YYYY-MM-DD", "target date", ""},
38 },
39 Examples: []string{"org milestone create krz mobile --due 2026-12-01"},
40 Run: runOrgMilestoneCreate})
41 register(Command{Path: []string{"org", "milestone", "list"},
42 Summary: "list an org's milestones with progress across the repositories you can read",
43 Usage: "org milestone list <org> [--state open|closed|all]",
44 Flags: []Flag{
45 {"--state", "open|closed|all", "which milestones", "open"},
46 },
47 Examples: []string{"org milestone list krz --state all"},
48 ReadOnly: true, Run: runOrgMilestoneList})
49 register(Command{Path: []string{"org", "milestone", "close"},
50 Summary: "close an org milestone",
51 Usage: "org milestone close <org> <title>",
52 Examples: []string{"org milestone close krz mobile"},
53 Run: runOrgMilestoneClose})
54 register(Command{Path: []string{"org", "milestone", "reopen"},
55 Summary: "reopen an org milestone",
56 Usage: "org milestone reopen <org> <title>",
57 Examples: []string{"org milestone reopen krz mobile"},
58 Run: runOrgMilestoneReopen})
59}
60
61// orgReader resolves an org for a read of its labels or milestones.
62// Members read; an outsider reads when some repository under the org is
63// readable, and is refused rather than told the org is missing otherwise,
64// since an org's existence is public anyway. The readable ids come back
65// because every read counts over them.
66func orgReader(c *Ctx, name string) (store.Org, []int64, int) {
67 org, err := c.Store.OrgByName(name)
68 if errors.Is(err, store.ErrNotFound) {
69 return org, nil, c.fail(protocol.ExitNotFound, "no organization %q", name)
70 }
71 if err != nil {
72 return org, nil, c.fail(protocol.ExitFailure, "%v", err)
73 }
74 readable, err := ReadableOrgRepoIDs(c.Store, c.User, org.ID)
75 if err != nil {
76 return org, nil, c.fail(protocol.ExitFailure, "%v", err)
77 }
78 role, err := c.Store.OrgRole(org.ID, c.User.ID)
79 if err != nil {
80 return org, nil, c.fail(protocol.ExitFailure, "%v", err)
81 }
82 if role == "" && len(readable) == 0 {
83 return org, nil, c.fail(protocol.ExitDenied, "labels and milestones of %s are visible to its members; ask an admin to add you", name)
84 }
85 return org, readable, -1
86}
87
88func runOrgLabelSet(c *Ctx, args []string) int {
89 f, err := c.parseArgs(args, flagSpec{Values: []string{"--color"}, MaxPos: 2, Usage: c.Cmd.Usage})
90 if err != nil {
91 return c.fail(protocol.ExitUsage, "%v", err)
92 }
93 orgName, name := f.pos(0), f.pos(1)
94 color, colorSet := strings.ToLower(f.Value("--color")), f.Has("--color")
95 if orgName == "" || name == "" {
96 return c.usage()
97 }
98 if name == "" || len(name) > 50 {
99 return c.fail(protocol.ExitUsage, "a label is 1 to 50 characters")
100 }
101 if colorSet && color != "" {
102 if !labelColorPat.MatchString(color) {
103 return c.fail(protocol.ExitUsage, "--color takes rrggbb (with or without #), or '' to clear")
104 }
105 color = "#" + strings.TrimPrefix(color, "#")
106 }
107 org, code := orgAdmin(c, orgName)
108 if code >= 0 {
109 return code
110 }
111 if !colorSet {
112 // Keep the colour it has, if any; this is "make sure it exists".
113 if labels, err := c.Store.ListOrgLabels(org.ID, nil); err == nil {
114 for _, l := range labels {
115 if l.Name == name {
116 color = l.Color
117 }
118 }
119 }
120 }
121 folded, err := c.Store.SetOrgLabel(org.ID, name, color)
122 if err != nil {
123 return c.fail(protocol.ExitFailure, "%v", err)
124 }
125 return c.emit(struct {
126 Name string `json:"name"`
127 Color string `json:"color,omitempty"`
128 Folded int `json:"folded"`
129 }{name, color, folded}, func(w io.Writer) {
130 if color == "" {
131 fmt.Fprintf(w, "org label %s on %s, no colour set", name, org.Name)
132 } else {
133 fmt.Fprintf(w, "org label %s on %s is %s", name, org.Name, color)
134 }
135 if folded > 0 {
136 fmt.Fprintf(w, "; folded in %d repositor%s", folded, map[bool]string{true: "y", false: "ies"}[folded == 1])
137 }
138 fmt.Fprintln(w)
139 })
140}
141
142func runOrgLabelList(c *Ctx, args []string) int {
143 if len(args) != 1 {
144 return c.usage()
145 }
146 org, readable, code := orgReader(c, args[0])
147 if code >= 0 {
148 return code
149 }
150 labels, err := c.Store.ListOrgLabels(org.ID, readable)
151 if err != nil {
152 return c.fail(protocol.ExitFailure, "%v", err)
153 }
154 return c.emitView(labels, func(w io.Writer) {
155 tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS")
156 for _, l := range labels {
157 tb.row(cRef(l.Name), cSwatch(l.Color), cNum(l.Issues), cNum(l.MRs))
158 }
159 tb.flush()
160 }, func() screen {
161 return labelsScreen(labels, action{"Labels", []string{"org", "label", "set", org.Name, "<label>", "--color", "rrggbb"}})
162 })
163}
164
165func runOrgLabelRemove(c *Ctx, args []string) int {
166 if len(args) != 2 {
167 return c.usage()
168 }
169 org, code := orgAdmin(c, args[0])
170 if code >= 0 {
171 return code
172 }
173 if err := c.Store.DeleteOrgLabel(org.ID, args[1]); err != nil {
174 if errors.Is(err, store.ErrNotFound) {
175 return c.fail(protocol.ExitNotFound, "no org label %q on %s", args[1], org.Name)
176 }
177 return c.fail(protocol.ExitFailure, "%v", err)
178 }
179 return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
180 fmt.Fprintf(w, "removed org label %s from %s\n", args[1], org.Name)
181 })
182}
183
184func runOrgMilestoneCreate(c *Ctx, args []string) int {
185 f, err := c.parseArgs(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2, Usage: c.Cmd.Usage})
186 if err != nil {
187 return c.fail(protocol.ExitUsage, "%v", err)
188 }
189 orgName, title, description, due := f.pos(0), f.pos(1), f.Value("--description"), f.Value("--due")
190 if orgName == "" || title == "" {
191 return c.usage()
192 }
193 if due != "" && !duePat.MatchString(due) {
194 return c.fail(protocol.ExitUsage, "--due must be YYYY-MM-DD")
195 }
196 org, code := orgAdmin(c, orgName)
197 if code >= 0 {
198 return code
199 }
200 _, folded, err := c.Store.CreateOrgMilestone(org.ID, title, description, due)
201 if err != nil {
202 return c.fail(protocol.ExitFailure, "%v", err)
203 }
204 return c.emit(struct {
205 Milestone string `json:"milestone"`
206 Folded int `json:"folded"`
207 }{title, folded}, func(w io.Writer) {
208 fmt.Fprintf(w, "created org milestone %q on %s", title, org.Name)
209 if folded > 0 {
210 fmt.Fprintf(w, "; folded in %d repositor%s", folded, map[bool]string{true: "y", false: "ies"}[folded == 1])
211 }
212 fmt.Fprintln(w)
213 })
214}
215
216func runOrgMilestoneList(c *Ctx, args []string) int {
217 f, err := c.parseArgs(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "org milestone list <org> [--state open|closed|all]"})
218 if err != nil {
219 return c.fail(protocol.ExitUsage, "%v", err)
220 }
221 state, orgName := "open", f.pos(0)
222 if f.Has("--state") {
223 state = f.Value("--state")
224 }
225 if orgName == "" || (state != "open" && state != "closed" && state != "all") {
226 return c.usage()
227 }
228 org, readable, code := orgReader(c, orgName)
229 if code >= 0 {
230 return code
231 }
232 ms, err := c.Store.ListOrgMilestones(org.ID, state, readable)
233 if err != nil {
234 return c.fail(protocol.ExitFailure, "%v", err)
235 }
236 return emitMilestones(c, ms,
237 action{"Milestones", []string{"org", "milestone", "create", org.Name, "<title>"}},
238 action{"Milestones", []string{"org", "milestone", "list", org.Name, "--state", "all"}},
239 )
240}
241
242func runOrgMilestoneClose(c *Ctx, args []string) int { return setOrgMilestoneState(c, args, "closed") }
243func runOrgMilestoneReopen(c *Ctx, args []string) int { return setOrgMilestoneState(c, args, "open") }
244
245func setOrgMilestoneState(c *Ctx, args []string, state string) int {
246 verb := "close"
247 if state == "open" {
248 verb = "reopen"
249 }
250 if len(args) != 2 {
251 return c.usage()
252 }
253 org, code := orgAdmin(c, args[0])
254 if code >= 0 {
255 return code
256 }
257 m, err := c.Store.OrgMilestoneByTitle(org.ID, args[1])
258 if errors.Is(err, store.ErrNotFound) {
259 return c.fail(protocol.ExitNotFound, "no org milestone %q on %s", args[1], org.Name)
260 }
261 if err != nil {
262 return c.fail(protocol.ExitFailure, "%v", err)
263 }
264 if err := c.Store.SetMilestoneState(m.ID, state); err != nil {
265 return c.fail(protocol.ExitFailure, "%v", err)
266 }
267 return c.emit(map[string]string{"milestone": m.Title, "state": state}, func(w io.Writer) {
268 fmt.Fprintf(w, "%sd org milestone %q on %s\n", verb, m.Title, org.Name)
269 })
270}