internal/control/milestone.go

20b06b791bf4bdd23b633bbfde819d5d8564751a
gitbay/internal/control/milestone.go history · blame · raw

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