internal/control/milestone.go

v1.19.0
gitbay/internal/control/milestone.go history · blame · raw

276 lines · 9222 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"path"
  8	"regexp"
  9	"strings"
 10
 11	"gitbay.org/gitbay/internal/gitutil"
 12	"gitbay.org/gitbay/internal/policy"
 13	"gitbay.org/gitbay/internal/protocol"
 14	"gitbay.org/gitbay/internal/store"
 15)
 16
 17func init() {
 18	register(Command{Path: []string{"milestone", "create"},
 19		Summary: "create a milestone",
 20		Usage:   "milestone create <owner/name> <title> [--description <d>] [--due YYYY-MM-DD]", Run: runMilestoneCreate})
 21	register(Command{Path: []string{"milestone", "list"},
 22		Summary: "list milestones with progress",
 23		Usage:   "milestone list <owner/name> [--state open|closed|all]", ReadOnly: true, Run: runMilestoneList})
 24	register(Command{Path: []string{"milestone", "close"},
 25		Summary: "close a milestone",
 26		Usage:   "milestone close <owner/name> <title>", Run: runMilestoneClose})
 27	register(Command{Path: []string{"milestone", "reopen"},
 28		Summary: "reopen a milestone",
 29		Usage:   "milestone reopen <owner/name> <title>", Run: runMilestoneReopen})
 30	register(Command{Path: []string{"issue", "milestone"},
 31		Summary: "set or clear an issue's milestone",
 32		Usage:   "issue milestone <owner/name> <n> <title|none>", Run: runIssueMilestone})
 33	register(Command{Path: []string{"mr", "milestone"},
 34		Summary: "set or clear an MR's milestone",
 35		Usage:   "mr milestone <owner/name> <n> <title|none>", Run: runMRMilestone})
 36	register(Command{Path: []string{"issue", "templates"},
 37		Summary: "list issue templates (.gitbay/issue-template*.md)",
 38		Usage:   "issue templates <owner/name>", ReadOnly: true, Run: runIssueTemplates})
 39}
 40
 41var duePat = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
 42
 43func runMilestoneCreate(c *Ctx, args []string) int {
 44	f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2,
 45		Usage: "milestone create <owner/name> <title> [--description <d>] [--due YYYY-MM-DD]"})
 46	if err != nil {
 47		return c.fail(protocol.ExitUsage, "%v", err)
 48	}
 49	path, title, description, due := f.pos(0), f.pos(1), f.Value("--description"), f.Value("--due")
 50	if path == "" || title == "" {
 51		return c.fail(protocol.ExitUsage, "usage: milestone create <owner/name> <title> [--description <d>] [--due YYYY-MM-DD]")
 52	}
 53	if due != "" && !duePat.MatchString(due) {
 54		return c.fail(protocol.ExitUsage, "--due must be YYYY-MM-DD")
 55	}
 56	repo, code := resolveRepo(c, path, policy.CanWrite)
 57	if code >= 0 {
 58		return code
 59	}
 60	if code := refuseArchived(c, repo); code >= 0 {
 61		return code
 62	}
 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		}
 67		// A duplicate title is a failure, not a usage error, which is what
 68		// failErr would make of it; org milestone create answers the same.
 69		return c.fail(protocol.ExitFailure, "%v", err)
 70	}
 71	return c.emit(map[string]string{"milestone": title}, func(w io.Writer) {
 72		fmt.Fprintf(w, "created milestone %q on %s\n", title, repo.Path())
 73	})
 74}
 75
 76func runMilestoneList(c *Ctx, args []string) int {
 77	f, err := parseFlags(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "milestone list <owner/name> [--state open|closed|all]"})
 78	if err != nil {
 79		return c.fail(protocol.ExitUsage, "%v", err)
 80	}
 81	state, path := "open", f.pos(0)
 82	if f.Has("--state") {
 83		state = f.Value("--state")
 84	}
 85	if path == "" || (state != "open" && state != "closed" && state != "all") {
 86		return c.fail(protocol.ExitUsage, "usage: milestone list <owner/name> [--state open|closed|all]")
 87	}
 88	repo, code := resolveRepo(c, path, policy.CanRead)
 89	if code >= 0 {
 90		return code
 91	}
 92	readable, err := ReadableScope(c.Store, c.User, repo)
 93	if err != nil {
 94		return c.fail(protocol.ExitFailure, "%v", err)
 95	}
 96	ms, err := c.Store.ListMilestones(repo, state, readable)
 97	if err != nil {
 98		return c.fail(protocol.ExitFailure, "%v", err)
 99	}
100	return emitMilestones(c, ms)
101}
102
103// emitMilestones renders a milestone list for the caller, JSON or plain.
104func emitMilestones(c *Ctx, ms []store.Milestone) int {
105	type out struct {
106		Title       string `json:"title"`
107		Description string `json:"description,omitempty"`
108		Due         string `json:"due,omitempty"`
109		State       string `json:"state"`
110		Org         bool   `json:"org,omitempty"`
111		Open        int    `json:"open"`
112		Closed      int    `json:"closed"`
113	}
114	var ds []out
115	for _, m := range ms {
116		ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OrgID != 0, m.OpenItems, m.ClosedItems})
117	}
118	return c.emit(ds, func(w io.Writer) {
119		for _, d := range ds {
120			due := d.Due
121			if due == "" {
122				due = "-"
123			}
124			mark := ""
125			if d.Org {
126				mark = "\torg"
127			}
128			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)
129		}
130	})
131}
132
133func runMilestoneClose(c *Ctx, args []string) int  { return setMilestoneState(c, args, "closed") }
134func runMilestoneReopen(c *Ctx, args []string) int { return setMilestoneState(c, args, "open") }
135
136func setMilestoneState(c *Ctx, args []string, state string) int {
137	verb := "close"
138	if state == "open" {
139		verb = "reopen"
140	}
141	if len(args) != 2 {
142		return c.fail(protocol.ExitUsage, "usage: milestone %s <owner/name> <title>", verb)
143	}
144	repo, code := resolveRepo(c, args[0], policy.CanWrite)
145	if code >= 0 {
146		return code
147	}
148	if code := refuseArchived(c, repo); code >= 0 {
149		return code
150	}
151	m, err := c.Store.MilestoneByTitle(repo, args[1])
152	if err != nil {
153		return milestoneErr(c, repo, args[1], err)
154	}
155	if m.OrgID != 0 {
156		return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "milestone", m.Title, verb))
157	}
158	if err := c.Store.SetMilestoneState(m.ID, state); err != nil {
159		return c.fail(protocol.ExitFailure, "%v", err)
160	}
161	return c.emit(map[string]string{"milestone": m.Title, "state": state}, func(w io.Writer) {
162		fmt.Fprintf(w, "%sd milestone %q on %s\n", verb, m.Title, repo.Path())
163	})
164}
165
166func milestoneErr(c *Ctx, repo store.Repo, title string, err error) int {
167	if errors.Is(err, store.ErrNotFound) {
168		return c.fail(protocol.ExitNotFound, "no milestone %q in %s", title, repo.Path())
169	}
170	return c.fail(protocol.ExitFailure, "%v", err)
171}
172
173func runIssueMilestone(c *Ctx, args []string) int {
174	repo, issue, code := issueRef(c, args, policy.CanWrite)
175	if code >= 0 {
176		return code
177	}
178	if code := refuseArchived(c, repo); code >= 0 {
179		return code
180	}
181	if len(args) != 3 {
182		return c.fail(protocol.ExitUsage, "usage: issue milestone <owner/name> <n> <title|none>")
183	}
184	return setItemMilestone(c, repo, "issue", issue.Number, args[2], func(id int64) error {
185		return c.Store.SetIssueMilestone(issue.ID, id)
186	})
187}
188
189func runMRMilestone(c *Ctx, args []string) int {
190	repo, mr, code := mrRef(c, args, policy.CanWrite)
191	if code >= 0 {
192		return code
193	}
194	if code := refuseArchived(c, repo); code >= 0 {
195		return code
196	}
197	if len(args) != 3 {
198		return c.fail(protocol.ExitUsage, "usage: mr milestone <owner/name> <n> <title|none>")
199	}
200	return setItemMilestone(c, repo, "mr", mr.Number, args[2], func(id int64) error {
201		return c.Store.SetMRMilestone(mr.ID, id)
202	})
203}
204
205// noun and number name what the milestone was set on, for the event.
206func setItemMilestone(c *Ctx, repo store.Repo, noun string, number int64, title string, set func(int64) error) int {
207	var id int64
208	if title != "none" {
209		m, err := c.Store.MilestoneByTitle(repo, title)
210		if err != nil {
211			return milestoneErr(c, repo, title, err)
212		}
213		id = m.ID
214	}
215	if err := set(id); err != nil {
216		return c.fail(protocol.ExitFailure, "%v", err)
217	}
218	cleared := title
219	if cleared == "none" {
220		cleared = ""
221	}
222	c.Store.RecordEvent(repo.ID, c.User.ID, noun+".milestoned",
223		fmt.Sprintf(`{"number":%d,"milestone":%q}`, number, cleared))
224	if title == "none" {
225		return c.emit(map[string]string{"milestone": ""}, func(w io.Writer) {
226			fmt.Fprintln(w, "milestone cleared")
227		})
228	}
229	return c.emit(map[string]string{"milestone": title}, func(w io.Writer) {
230		fmt.Fprintf(w, "milestone set to %q\n", title)
231	})
232}
233
234// runIssueTemplates lists .gitbay/issue-template*.md at the default branch.
235func runIssueTemplates(c *Ctx, args []string) int {
236	if len(args) != 1 {
237		return c.fail(protocol.ExitUsage, "usage: issue templates <owner/name>")
238	}
239	repo, code := resolveRepo(c, args[0], policy.CanRead)
240	if code >= 0 {
241		return code
242	}
243	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
244	ts := IssueTemplates(dir, repo.DefaultBranch)
245	return c.emit(ts, func(w io.Writer) {
246		for _, t := range ts {
247			fmt.Fprintln(w, t.Name)
248		}
249	})
250}
251
252type IssueTemplate struct {
253	Name string `json:"name"`
254	Body string `json:"body"`
255}
256
257// IssueTemplates reads .gitbay/issue-template*.md from ref. Missing
258// directory or unreadable files yield an empty list, never an error.
259func IssueTemplates(dir, ref string) []IssueTemplate {
260	entries, err := gitutil.ListTree(dir, ref, ".gitbay")
261	if err != nil {
262		return nil
263	}
264	var out []IssueTemplate
265	for _, e := range entries {
266		if e.Type != "blob" || !strings.HasPrefix(e.Name, "issue-template") || !strings.HasSuffix(e.Name, ".md") {
267			continue
268		}
269		raw, err := gitutil.ReadBlob(dir, ref, path.Join(".gitbay", e.Name), maxBodyBytes)
270		if err != nil {
271			continue
272		}
273		out = append(out, IssueTemplate{Name: e.Name, Body: string(raw)})
274	}
275	return out
276}