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