internal/control/milestone.go
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}