internal/control/milestone.go
274 lines · 9057 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 return c.failErr(err)
68 }
69 return c.emit(map[string]string{"milestone": title}, func(w io.Writer) {
70 fmt.Fprintf(w, "created milestone %q on %s\n", title, repo.Path())
71 })
72}
73
74func runMilestoneList(c *Ctx, args []string) int {
75 f, err := parseFlags(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "milestone list <owner/name> [--state open|closed|all]"})
76 if err != nil {
77 return c.fail(protocol.ExitUsage, "%v", err)
78 }
79 state, path := "open", f.pos(0)
80 if f.Has("--state") {
81 state = f.Value("--state")
82 }
83 if path == "" || (state != "open" && state != "closed" && state != "all") {
84 return c.fail(protocol.ExitUsage, "usage: milestone list <owner/name> [--state open|closed|all]")
85 }
86 repo, code := resolveRepo(c, path, policy.CanRead)
87 if code >= 0 {
88 return code
89 }
90 readable, err := ReadableScope(c.Store, c.User, repo)
91 if err != nil {
92 return c.fail(protocol.ExitFailure, "%v", err)
93 }
94 ms, err := c.Store.ListMilestones(repo, state, readable)
95 if err != nil {
96 return c.fail(protocol.ExitFailure, "%v", err)
97 }
98 return emitMilestones(c, ms)
99}
100
101// emitMilestones renders a milestone list for the caller, JSON or plain.
102func emitMilestones(c *Ctx, ms []store.Milestone) int {
103 type out struct {
104 Title string `json:"title"`
105 Description string `json:"description,omitempty"`
106 Due string `json:"due,omitempty"`
107 State string `json:"state"`
108 Org bool `json:"org,omitempty"`
109 Open int `json:"open"`
110 Closed int `json:"closed"`
111 }
112 var ds []out
113 for _, m := range ms {
114 ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OrgID != 0, m.OpenItems, m.ClosedItems})
115 }
116 return c.emit(ds, func(w io.Writer) {
117 for _, d := range ds {
118 due := d.Due
119 if due == "" {
120 due = "-"
121 }
122 mark := ""
123 if d.Org {
124 mark = "\torg"
125 }
126 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)
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, args[1])
150 if err != nil {
151 return milestoneErr(c, repo, args[1], err)
152 }
153 if m.OrgID != 0 {
154 return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "milestone", m.Title, verb))
155 }
156 if err := c.Store.SetMilestoneState(m.ID, state); err != nil {
157 return c.fail(protocol.ExitFailure, "%v", err)
158 }
159 return c.emit(map[string]string{"milestone": m.Title, "state": state}, func(w io.Writer) {
160 fmt.Fprintf(w, "%sd milestone %q on %s\n", verb, m.Title, repo.Path())
161 })
162}
163
164func milestoneErr(c *Ctx, repo store.Repo, title string, err error) int {
165 if errors.Is(err, store.ErrNotFound) {
166 return c.fail(protocol.ExitNotFound, "no milestone %q in %s", title, repo.Path())
167 }
168 return c.fail(protocol.ExitFailure, "%v", err)
169}
170
171func runIssueMilestone(c *Ctx, args []string) int {
172 repo, issue, code := issueRef(c, args, policy.CanWrite)
173 if code >= 0 {
174 return code
175 }
176 if code := refuseArchived(c, repo); code >= 0 {
177 return code
178 }
179 if len(args) != 3 {
180 return c.fail(protocol.ExitUsage, "usage: issue milestone <owner/name> <n> <title|none>")
181 }
182 return setItemMilestone(c, repo, "issue", issue.Number, args[2], func(id int64) error {
183 return c.Store.SetIssueMilestone(issue.ID, id)
184 })
185}
186
187func runMRMilestone(c *Ctx, args []string) int {
188 repo, mr, code := mrRef(c, args, policy.CanWrite)
189 if code >= 0 {
190 return code
191 }
192 if code := refuseArchived(c, repo); code >= 0 {
193 return code
194 }
195 if len(args) != 3 {
196 return c.fail(protocol.ExitUsage, "usage: mr milestone <owner/name> <n> <title|none>")
197 }
198 return setItemMilestone(c, repo, "mr", mr.Number, args[2], func(id int64) error {
199 return c.Store.SetMRMilestone(mr.ID, id)
200 })
201}
202
203// noun and number name what the milestone was set on, for the event.
204func setItemMilestone(c *Ctx, repo store.Repo, noun string, number int64, title string, set func(int64) error) int {
205 var id int64
206 if title != "none" {
207 m, err := c.Store.MilestoneByTitle(repo, title)
208 if err != nil {
209 return milestoneErr(c, repo, title, err)
210 }
211 id = m.ID
212 }
213 if err := set(id); err != nil {
214 return c.fail(protocol.ExitFailure, "%v", err)
215 }
216 cleared := title
217 if cleared == "none" {
218 cleared = ""
219 }
220 c.Store.RecordEvent(repo.ID, c.User.ID, noun+".milestoned",
221 fmt.Sprintf(`{"number":%d,"milestone":%q}`, number, cleared))
222 if title == "none" {
223 return c.emit(map[string]string{"milestone": ""}, func(w io.Writer) {
224 fmt.Fprintln(w, "milestone cleared")
225 })
226 }
227 return c.emit(map[string]string{"milestone": title}, func(w io.Writer) {
228 fmt.Fprintf(w, "milestone set to %q\n", title)
229 })
230}
231
232// runIssueTemplates lists .gitbay/issue-template*.md at the default branch.
233func runIssueTemplates(c *Ctx, args []string) int {
234 if len(args) != 1 {
235 return c.fail(protocol.ExitUsage, "usage: issue templates <owner/name>")
236 }
237 repo, code := resolveRepo(c, args[0], policy.CanRead)
238 if code >= 0 {
239 return code
240 }
241 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
242 ts := IssueTemplates(dir, repo.DefaultBranch)
243 return c.emit(ts, func(w io.Writer) {
244 for _, t := range ts {
245 fmt.Fprintln(w, t.Name)
246 }
247 })
248}
249
250type IssueTemplate struct {
251 Name string `json:"name"`
252 Body string `json:"body"`
253}
254
255// IssueTemplates reads .gitbay/issue-template*.md from ref. Missing
256// directory or unreadable files yield an empty list, never an error.
257func IssueTemplates(dir, ref string) []IssueTemplate {
258 entries, err := gitutil.ListTree(dir, ref, ".gitbay")
259 if err != nil {
260 return nil
261 }
262 var out []IssueTemplate
263 for _, e := range entries {
264 if e.Type != "blob" || !strings.HasPrefix(e.Name, "issue-template") || !strings.HasSuffix(e.Name, ".md") {
265 continue
266 }
267 raw, err := gitutil.ReadBlob(dir, ref, path.Join(".gitbay", e.Name), maxBodyBytes)
268 if err != nil {
269 continue
270 }
271 out = append(out, IssueTemplate{Name: e.Name, Body: string(raw)})
272 }
273 return out
274}