internal/control/milestone.go
337 lines · 10877 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]",
21 Flags: []Flag{
22 {"--description", "<d>", "what the milestone covers", ""},
23 {"--due", "YYYY-MM-DD", "target date", ""},
24 },
25 Examples: []string{`milestone create krz/gitbay v1.31.0 --due 2026-10-01`},
26 Run: runMilestoneCreate})
27 register(Command{Path: []string{"milestone", "list"},
28 Summary: "list milestones with progress",
29 Usage: "milestone list <owner/name> [--state open|closed|all]",
30 Flags: []Flag{
31 {"--state", "open|closed|all", "which milestones", "open"},
32 },
33 Examples: []string{"milestone list krz/gitbay --state all"},
34 ReadOnly: true, Run: runMilestoneList})
35 register(Command{Path: []string{"milestone", "close"},
36 Summary: "close a milestone",
37 Usage: "milestone close <owner/name> <title>",
38 Examples: []string{"milestone close krz/gitbay v1.30.0"},
39 Run: runMilestoneClose})
40 register(Command{Path: []string{"milestone", "reopen"},
41 Summary: "reopen a milestone",
42 Usage: "milestone reopen <owner/name> <title>",
43 Examples: []string{"milestone reopen krz/gitbay v1.30.0"},
44 Run: runMilestoneReopen})
45 register(Command{Path: []string{"issue", "milestone"},
46 Summary: "set or clear an issue's milestone",
47 Usage: "issue milestone <owner/name> <n> <title|none>",
48 Examples: []string{"issue milestone krz/gitbay 42 v1.31.0"},
49 Run: runIssueMilestone})
50 register(Command{Path: []string{"mr", "milestone"},
51 Summary: "set or clear an MR's milestone",
52 Usage: "mr milestone <owner/name> <n> <title|none>",
53 Examples: []string{"mr milestone krz/gitbay 431 v1.31.0"},
54 Run: runMRMilestone})
55 register(Command{Path: []string{"issue", "templates"},
56 Summary: "list issue templates (.gitbay/issue-template*.md)",
57 Usage: "issue templates <owner/name>",
58 Examples: []string{"issue templates krz/gitbay"},
59 ReadOnly: true, Run: runIssueTemplates})
60}
61
62var duePat = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
63
64func runMilestoneCreate(c *Ctx, args []string) int {
65 f, err := c.parseArgs(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2,
66 Usage: "milestone create <owner/name> <title> [--description <d>] [--due YYYY-MM-DD]"})
67 if err != nil {
68 return c.fail(protocol.ExitUsage, "%v", err)
69 }
70 path, title, description, due := f.pos(0), f.pos(1), f.Value("--description"), f.Value("--due")
71 if path == "" || title == "" {
72 return c.usage()
73 }
74 if due != "" && !duePat.MatchString(due) {
75 return c.fail(protocol.ExitUsage, "--due must be YYYY-MM-DD")
76 }
77 repo, code := resolveRepo(c, path, policy.CanWrite)
78 if code >= 0 {
79 return code
80 }
81 if code := refuseArchived(c, repo); code >= 0 {
82 return code
83 }
84 if _, err := c.Store.CreateMilestone(repo, title, description, due); err != nil {
85 if errors.Is(err, store.ErrOrgScoped) {
86 return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "milestone", title, "create"))
87 }
88 return c.failErr(err)
89 }
90 return c.emit(map[string]string{"milestone": title}, func(w io.Writer) {
91 fmt.Fprintf(w, "created milestone %q on %s\n", title, repo.Path())
92 })
93}
94
95func runMilestoneList(c *Ctx, args []string) int {
96 f, err := c.parseArgs(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "milestone list <owner/name> [--state open|closed|all]"})
97 if err != nil {
98 return c.fail(protocol.ExitUsage, "%v", err)
99 }
100 state, path := "open", f.pos(0)
101 if f.Has("--state") {
102 state = f.Value("--state")
103 }
104 if path == "" || (state != "open" && state != "closed" && state != "all") {
105 return c.usage()
106 }
107 repo, code := resolveRepo(c, path, policy.CanRead)
108 if code >= 0 {
109 return code
110 }
111 readable, err := ReadableScope(c.Store, c.User, repo)
112 if err != nil {
113 return c.fail(protocol.ExitFailure, "%v", err)
114 }
115 ms, err := c.Store.ListMilestones(repo, state, readable)
116 if err != nil {
117 return c.fail(protocol.ExitFailure, "%v", err)
118 }
119 return emitMilestones(c, ms,
120 action{"Milestones", []string{"milestone", "create", repo.Path(), "<title>"}},
121 action{"Milestones", []string{"milestone", "list", repo.Path(), "--state", "all"}},
122 )
123}
124
125// emitMilestones renders a milestone list for the caller, JSON or plain.
126func emitMilestones(c *Ctx, ms []store.Milestone, actions ...action) int {
127 type out struct {
128 Title string `json:"title"`
129 Description string `json:"description,omitempty"`
130 Due string `json:"due,omitempty"`
131 State string `json:"state"`
132 Org bool `json:"org,omitempty"`
133 Open int `json:"open"`
134 Closed int `json:"closed"`
135 }
136 var ds []out
137 for _, m := range ms {
138 ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OrgID != 0, m.OpenItems, m.ClosedItems})
139 }
140 return c.emitView(ds, func(w io.Writer) {
141 tb := c.table(w, "TITLE", "STATE", "DUE", "PROGRESS")
142 for _, d := range ds {
143 due := d.Due
144 if due == "" {
145 due = "-"
146 }
147 cells := []cell{cRef(d.Title), cState(d.State), cText("due " + due), cText(fmt.Sprintf("%d open, %d closed", d.Open, d.Closed))}
148 if d.Org {
149 cells = append(cells, cText("org"))
150 }
151 tb.row(cells...)
152 }
153 tb.flush()
154 }, func() screen {
155 rows := make([]row, len(ds))
156 for i, d := range ds {
157 lead, due := cGlyph(d.State), ""
158 if t, ok := parseStamp(d.Due + " 00:00:00"); ok {
159 due = "due " + relAge(d.Due+" 00:00:00", termNow())
160 if d.State == "open" && t.Before(termNow()) {
161 lead, due = cYou(), "overdue "+d.Due
162 }
163 }
164 progress := fmt.Sprintf("%d/%d closed", d.Closed, d.Open+d.Closed)
165 org := ""
166 if d.Org {
167 org = "org"
168 }
169 rows[i] = rowOf(cRef(d.Title), lead, cFlex(d.Description), cMeta(due, progress, org))
170 }
171 return listScreen("Milestones", rows, actions...)
172 })
173}
174
175func runMilestoneClose(c *Ctx, args []string) int { return setMilestoneState(c, args, "closed") }
176func runMilestoneReopen(c *Ctx, args []string) int { return setMilestoneState(c, args, "open") }
177
178func setMilestoneState(c *Ctx, args []string, state string) int {
179 verb := "close"
180 if state == "open" {
181 verb = "reopen"
182 }
183 if len(args) != 2 {
184 return c.usage()
185 }
186 repo, code := resolveRepo(c, args[0], policy.CanWrite)
187 if code >= 0 {
188 return code
189 }
190 if code := refuseArchived(c, repo); code >= 0 {
191 return code
192 }
193 m, err := c.Store.MilestoneByTitle(repo, args[1])
194 if err != nil {
195 return milestoneErr(c, repo, args[1], err)
196 }
197 if m.OrgID != 0 {
198 return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "milestone", m.Title, verb))
199 }
200 if err := c.Store.SetMilestoneState(m.ID, state); err != nil {
201 return c.fail(protocol.ExitFailure, "%v", err)
202 }
203 return c.emit(map[string]string{"milestone": m.Title, "state": state}, func(w io.Writer) {
204 fmt.Fprintf(w, "%sd milestone %q on %s\n", verb, m.Title, repo.Path())
205 })
206}
207
208func milestoneErr(c *Ctx, repo store.Repo, title string, err error) int {
209 if errors.Is(err, store.ErrNotFound) {
210 return c.fail(protocol.ExitNotFound, "no milestone %q in %s", title, repo.Path())
211 }
212 return c.fail(protocol.ExitFailure, "%v", err)
213}
214
215func runIssueMilestone(c *Ctx, args []string) int {
216 repo, issue, code := issueRef(c, args, policy.CanWrite)
217 if code >= 0 {
218 return code
219 }
220 if code := refuseArchived(c, repo); code >= 0 {
221 return code
222 }
223 if len(args) != 3 {
224 return c.usage()
225 }
226 return setItemMilestone(c, repo, "issue", issue.Number, args[2], func(id int64) error {
227 return c.Store.SetIssueMilestone(issue.ID, id)
228 })
229}
230
231func runMRMilestone(c *Ctx, args []string) int {
232 repo, mr, code := mrRef(c, args, policy.CanWrite)
233 if code >= 0 {
234 return code
235 }
236 if code := refuseArchived(c, repo); code >= 0 {
237 return code
238 }
239 if len(args) != 3 {
240 return c.usage()
241 }
242 return setItemMilestone(c, repo, "mr", mr.Number, args[2], func(id int64) error {
243 return c.Store.SetMRMilestone(mr.ID, id)
244 })
245}
246
247// recordItemMilestone sets milestone id (0 clears it) through set and
248// records the milestoned event naming title ("" when cleared).
249func recordItemMilestone(c *Ctx, repo store.Repo, noun string, number, id int64, title string, set func(int64) error) error {
250 if err := set(id); err != nil {
251 return err
252 }
253 c.Store.RecordEvent(repo.ID, c.User.ID, noun+".milestoned",
254 fmt.Sprintf(`{"number":%d,"milestone":%q}`, number, title))
255 return nil
256}
257
258// noun and number name what the milestone was set on, for the event.
259func setItemMilestone(c *Ctx, repo store.Repo, noun string, number int64, title string, set func(int64) error) int {
260 var id int64
261 if title != "none" {
262 m, err := c.Store.MilestoneByTitle(repo, title)
263 if err != nil {
264 return milestoneErr(c, repo, title, err)
265 }
266 id = m.ID
267 }
268 cleared := title
269 if cleared == "none" {
270 cleared = ""
271 }
272 if err := recordItemMilestone(c, repo, noun, number, id, cleared, set); err != nil {
273 return c.fail(protocol.ExitFailure, "%v", err)
274 }
275 if title == "none" {
276 return c.emit(map[string]string{"milestone": ""}, func(w io.Writer) {
277 fmt.Fprintln(w, "milestone cleared")
278 })
279 }
280 return c.emit(map[string]string{"milestone": title}, func(w io.Writer) {
281 fmt.Fprintf(w, "milestone set to %q\n", title)
282 })
283}
284
285// runIssueTemplates lists .gitbay/issue-template*.md at the default branch.
286func runIssueTemplates(c *Ctx, args []string) int {
287 if len(args) != 1 {
288 return c.usage()
289 }
290 repo, code := resolveRepo(c, args[0], policy.CanRead)
291 if code >= 0 {
292 return code
293 }
294 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
295 ts := IssueTemplates(dir, repo.DefaultBranch)
296 return c.emitView(ts, func(w io.Writer) {
297 tb := c.table(w, "NAME")
298 for _, t := range ts {
299 tb.row(cRef(t.Name))
300 }
301 tb.flush()
302 }, func() screen {
303 rows := make([]row, len(ts))
304 for i, tpl := range ts {
305 rows[i] = rowOf(cRef(tpl.Name))
306 }
307 return listScreen("Issue templates", rows,
308 action{"New", []string{"issue", "create", repo.Path(), "--title", "<title>"}},
309 )
310 })
311}
312
313type IssueTemplate struct {
314 Name string `json:"name"`
315 Body string `json:"body"`
316}
317
318// IssueTemplates reads .gitbay/issue-template*.md from ref. Missing
319// directory or unreadable files yield an empty list, never an error.
320func IssueTemplates(dir, ref string) []IssueTemplate {
321 entries, err := gitutil.ListTree(dir, ref, ".gitbay")
322 if err != nil {
323 return nil
324 }
325 var out []IssueTemplate
326 for _, e := range entries {
327 if e.Type != "blob" || !strings.HasPrefix(e.Name, "issue-template") || !strings.HasSuffix(e.Name, ".md") {
328 continue
329 }
330 raw, err := gitutil.ReadBlob(dir, ref, path.Join(".gitbay", e.Name), maxBodyBytes)
331 if err != nil {
332 continue
333 }
334 out = append(out, IssueTemplate{Name: e.Name, Body: string(raw)})
335 }
336 return out
337}