internal/control/milestone.go

337 lines · 10877 bytes

16 symbols in this file
  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}