internal/control/milestone.go

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

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