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