internal/control/milestone.go
269 lines · 8894 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 type out struct {
99 Title string `json:"title"`
100 Description string `json:"description,omitempty"`
101 Due string `json:"due,omitempty"`
102 State string `json:"state"`
103 Org bool `json:"org,omitempty"`
104 Open int `json:"open"`
105 Closed int `json:"closed"`
106 }
107 var ds []out
108 for _, m := range ms {
109 ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OrgID != 0, m.OpenItems, m.ClosedItems})
110 }
111 return c.emit(ds, func(w io.Writer) {
112 for _, d := range ds {
113 due := d.Due
114 if due == "" {
115 due = "-"
116 }
117 mark := ""
118 if d.Org {
119 mark = "\torg"
120 }
121 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)
122 }
123 })
124}
125
126func runMilestoneClose(c *Ctx, args []string) int { return setMilestoneState(c, args, "closed") }
127func runMilestoneReopen(c *Ctx, args []string) int { return setMilestoneState(c, args, "open") }
128
129func setMilestoneState(c *Ctx, args []string, state string) int {
130 verb := "close"
131 if state == "open" {
132 verb = "reopen"
133 }
134 if len(args) != 2 {
135 return c.fail(protocol.ExitUsage, "usage: milestone %s <owner/name> <title>", verb)
136 }
137 repo, code := resolveRepo(c, args[0], policy.CanWrite)
138 if code >= 0 {
139 return code
140 }
141 if code := refuseArchived(c, repo); code >= 0 {
142 return code
143 }
144 m, err := c.Store.MilestoneByTitle(repo, args[1])
145 if err != nil {
146 return milestoneErr(c, repo, args[1], err)
147 }
148 if m.OrgID != 0 {
149 return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "milestone", m.Title, verb))
150 }
151 if err := c.Store.SetMilestoneState(m.ID, state); err != nil {
152 return c.fail(protocol.ExitFailure, "%v", err)
153 }
154 return c.emit(map[string]string{"milestone": m.Title, "state": state}, func(w io.Writer) {
155 fmt.Fprintf(w, "%sd milestone %q on %s\n", verb, m.Title, repo.Path())
156 })
157}
158
159func milestoneErr(c *Ctx, repo store.Repo, title string, err error) int {
160 if errors.Is(err, store.ErrNotFound) {
161 return c.fail(protocol.ExitNotFound, "no milestone %q in %s", title, repo.Path())
162 }
163 return c.fail(protocol.ExitFailure, "%v", err)
164}
165
166func runIssueMilestone(c *Ctx, args []string) int {
167 repo, issue, code := issueRef(c, args, policy.CanWrite)
168 if code >= 0 {
169 return code
170 }
171 if code := refuseArchived(c, repo); code >= 0 {
172 return code
173 }
174 if len(args) != 3 {
175 return c.fail(protocol.ExitUsage, "usage: issue milestone <owner/name> <n> <title|none>")
176 }
177 return setItemMilestone(c, repo, "issue", issue.Number, args[2], func(id int64) error {
178 return c.Store.SetIssueMilestone(issue.ID, id)
179 })
180}
181
182func runMRMilestone(c *Ctx, args []string) int {
183 repo, mr, code := mrRef(c, args, policy.CanWrite)
184 if code >= 0 {
185 return code
186 }
187 if code := refuseArchived(c, repo); code >= 0 {
188 return code
189 }
190 if len(args) != 3 {
191 return c.fail(protocol.ExitUsage, "usage: mr milestone <owner/name> <n> <title|none>")
192 }
193 return setItemMilestone(c, repo, "mr", mr.Number, args[2], func(id int64) error {
194 return c.Store.SetMRMilestone(mr.ID, id)
195 })
196}
197
198// noun and number name what the milestone was set on, for the event.
199func setItemMilestone(c *Ctx, repo store.Repo, noun string, number int64, title string, set func(int64) error) int {
200 var id int64
201 if title != "none" {
202 m, err := c.Store.MilestoneByTitle(repo, title)
203 if err != nil {
204 return milestoneErr(c, repo, title, err)
205 }
206 id = m.ID
207 }
208 if err := set(id); err != nil {
209 return c.fail(protocol.ExitFailure, "%v", err)
210 }
211 cleared := title
212 if cleared == "none" {
213 cleared = ""
214 }
215 c.Store.RecordEvent(repo.ID, c.User.ID, noun+".milestoned",
216 fmt.Sprintf(`{"number":%d,"milestone":%q}`, number, cleared))
217 if title == "none" {
218 return c.emit(map[string]string{"milestone": ""}, func(w io.Writer) {
219 fmt.Fprintln(w, "milestone cleared")
220 })
221 }
222 return c.emit(map[string]string{"milestone": title}, func(w io.Writer) {
223 fmt.Fprintf(w, "milestone set to %q\n", title)
224 })
225}
226
227// runIssueTemplates lists .gitbay/issue-template*.md at the default branch.
228func runIssueTemplates(c *Ctx, args []string) int {
229 if len(args) != 1 {
230 return c.fail(protocol.ExitUsage, "usage: issue templates <owner/name>")
231 }
232 repo, code := resolveRepo(c, args[0], policy.CanRead)
233 if code >= 0 {
234 return code
235 }
236 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
237 ts := IssueTemplates(dir, repo.DefaultBranch)
238 return c.emit(ts, func(w io.Writer) {
239 for _, t := range ts {
240 fmt.Fprintln(w, t.Name)
241 }
242 })
243}
244
245type IssueTemplate struct {
246 Name string `json:"name"`
247 Body string `json:"body"`
248}
249
250// IssueTemplates reads .gitbay/issue-template*.md from ref. Missing
251// directory or unreadable files yield an empty list, never an error.
252func IssueTemplates(dir, ref string) []IssueTemplate {
253 entries, err := gitutil.ListTree(dir, ref, ".gitbay")
254 if err != nil {
255 return nil
256 }
257 var out []IssueTemplate
258 for _, e := range entries {
259 if e.Type != "blob" || !strings.HasPrefix(e.Name, "issue-template") || !strings.HasSuffix(e.Name, ".md") {
260 continue
261 }
262 raw, err := gitutil.ReadBlob(dir, ref, path.Join(".gitbay", e.Name), maxBodyBytes)
263 if err != nil {
264 continue
265 }
266 out = append(out, IssueTemplate{Name: e.Name, Body: string(raw)})
267 }
268 return out
269}