internal/control/orglabel.go

ff759b53942049b2043e132f5482b442a7265b98
gitbay/internal/control/orglabel.go history · blame · raw

265 lines · 9107 bytes

  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.emit(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), cText(l.Color), cNum(l.Issues), cNum(l.MRs))
158		}
159		tb.flush()
160	})
161}
162
163func runOrgLabelRemove(c *Ctx, args []string) int {
164	if len(args) != 2 {
165		return c.usage()
166	}
167	org, code := orgAdmin(c, args[0])
168	if code >= 0 {
169		return code
170	}
171	if err := c.Store.DeleteOrgLabel(org.ID, args[1]); err != nil {
172		if errors.Is(err, store.ErrNotFound) {
173			return c.fail(protocol.ExitNotFound, "no org label %q on %s", args[1], org.Name)
174		}
175		return c.fail(protocol.ExitFailure, "%v", err)
176	}
177	return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
178		fmt.Fprintf(w, "removed org label %s from %s\n", args[1], org.Name)
179	})
180}
181
182func runOrgMilestoneCreate(c *Ctx, args []string) int {
183	f, err := c.parseArgs(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2, Usage: c.Cmd.Usage})
184	if err != nil {
185		return c.fail(protocol.ExitUsage, "%v", err)
186	}
187	orgName, title, description, due := f.pos(0), f.pos(1), f.Value("--description"), f.Value("--due")
188	if orgName == "" || title == "" {
189		return c.usage()
190	}
191	if due != "" && !duePat.MatchString(due) {
192		return c.fail(protocol.ExitUsage, "--due must be YYYY-MM-DD")
193	}
194	org, code := orgAdmin(c, orgName)
195	if code >= 0 {
196		return code
197	}
198	_, folded, err := c.Store.CreateOrgMilestone(org.ID, title, description, due)
199	if err != nil {
200		return c.fail(protocol.ExitFailure, "%v", err)
201	}
202	return c.emit(struct {
203		Milestone string `json:"milestone"`
204		Folded    int    `json:"folded"`
205	}{title, folded}, func(w io.Writer) {
206		fmt.Fprintf(w, "created org milestone %q on %s", title, org.Name)
207		if folded > 0 {
208			fmt.Fprintf(w, "; folded in %d repositor%s", folded, map[bool]string{true: "y", false: "ies"}[folded == 1])
209		}
210		fmt.Fprintln(w)
211	})
212}
213
214func runOrgMilestoneList(c *Ctx, args []string) int {
215	f, err := c.parseArgs(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "org milestone list <org> [--state open|closed|all]"})
216	if err != nil {
217		return c.fail(protocol.ExitUsage, "%v", err)
218	}
219	state, orgName := "open", f.pos(0)
220	if f.Has("--state") {
221		state = f.Value("--state")
222	}
223	if orgName == "" || (state != "open" && state != "closed" && state != "all") {
224		return c.usage()
225	}
226	org, readable, code := orgReader(c, orgName)
227	if code >= 0 {
228		return code
229	}
230	ms, err := c.Store.ListOrgMilestones(org.ID, state, readable)
231	if err != nil {
232		return c.fail(protocol.ExitFailure, "%v", err)
233	}
234	return emitMilestones(c, ms)
235}
236
237func runOrgMilestoneClose(c *Ctx, args []string) int  { return setOrgMilestoneState(c, args, "closed") }
238func runOrgMilestoneReopen(c *Ctx, args []string) int { return setOrgMilestoneState(c, args, "open") }
239
240func setOrgMilestoneState(c *Ctx, args []string, state string) int {
241	verb := "close"
242	if state == "open" {
243		verb = "reopen"
244	}
245	if len(args) != 2 {
246		return c.usage()
247	}
248	org, code := orgAdmin(c, args[0])
249	if code >= 0 {
250		return code
251	}
252	m, err := c.Store.OrgMilestoneByTitle(org.ID, args[1])
253	if errors.Is(err, store.ErrNotFound) {
254		return c.fail(protocol.ExitNotFound, "no org milestone %q on %s", args[1], org.Name)
255	}
256	if err != nil {
257		return c.fail(protocol.ExitFailure, "%v", err)
258	}
259	if err := c.Store.SetMilestoneState(m.ID, state); err != nil {
260		return c.fail(protocol.ExitFailure, "%v", err)
261	}
262	return c.emit(map[string]string{"milestone": m.Title, "state": state}, func(w io.Writer) {
263		fmt.Fprintf(w, "%sd org milestone %q on %s\n", verb, m.Title, org.Name)
264	})
265}