package control import ( "errors" "fmt" "io" "path" "regexp" "strings" "gitbay.org/gitbay/internal/gitutil" "gitbay.org/gitbay/internal/policy" "gitbay.org/gitbay/internal/protocol" "gitbay.org/gitbay/internal/store" ) func init() { register(Command{Path: []string{"milestone", "create"}, Summary: "create a milestone", Usage: "milestone create [--description <d>] [--due YYYY-MM-DD]", Flags: []Flag{ {"--description", "<d>", "what the milestone covers", ""}, {"--due", "YYYY-MM-DD", "target date", ""}, }, Examples: []string{`milestone create krz/gitbay v1.31.0 --due 2026-10-01`}, Run: runMilestoneCreate}) register(Command{Path: []string{"milestone", "list"}, Summary: "list milestones with progress", Usage: "milestone list <owner/name> [--state open|closed|all]", Flags: []Flag{ {"--state", "open|closed|all", "which milestones", "open"}, }, Examples: []string{"milestone list krz/gitbay --state all"}, ReadOnly: true, Run: runMilestoneList}) register(Command{Path: []string{"milestone", "close"}, Summary: "close a milestone", Usage: "milestone close <owner/name> <title>", Examples: []string{"milestone close krz/gitbay v1.30.0"}, Run: runMilestoneClose}) register(Command{Path: []string{"milestone", "reopen"}, Summary: "reopen a milestone", Usage: "milestone reopen <owner/name> <title>", Examples: []string{"milestone reopen krz/gitbay v1.30.0"}, Run: runMilestoneReopen}) register(Command{Path: []string{"issue", "milestone"}, Summary: "set or clear an issue's milestone", Usage: "issue milestone <owner/name> <n> <title|none>", Examples: []string{"issue milestone krz/gitbay 42 v1.31.0"}, Run: runIssueMilestone}) register(Command{Path: []string{"mr", "milestone"}, Summary: "set or clear an MR's milestone", Usage: "mr milestone <owner/name> <n> <title|none>", Examples: []string{"mr milestone krz/gitbay 431 v1.31.0"}, Run: runMRMilestone}) register(Command{Path: []string{"issue", "templates"}, Summary: "list issue templates (.gitbay/issue-template*.md)", Usage: "issue templates <owner/name>", Examples: []string{"issue templates krz/gitbay"}, ReadOnly: true, Run: runIssueTemplates}) } var duePat = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) func runMilestoneCreate(c *Ctx, args []string) int { f, err := c.parseArgs(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2, Usage: "milestone create <owner/name> <title> [--description <d>] [--due YYYY-MM-DD]"}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } path, title, description, due := f.pos(0), f.pos(1), f.Value("--description"), f.Value("--due") if path == "" || title == "" { return c.usage() } if due != "" && !duePat.MatchString(due) { return c.fail(protocol.ExitUsage, "--due must be YYYY-MM-DD") } repo, code := resolveRepo(c, path, policy.CanWrite) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } if _, err := c.Store.CreateMilestone(repo, title, description, due); err != nil { if errors.Is(err, store.ErrOrgScoped) { return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "milestone", title, "create")) } return c.failErr(err) } return c.emit(map[string]string{"milestone": title}, func(w io.Writer) { fmt.Fprintf(w, "created milestone %q on %s\n", title, repo.Path()) }) } func runMilestoneList(c *Ctx, args []string) int { f, err := c.parseArgs(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "milestone list <owner/name> [--state open|closed|all]"}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } state, path := "open", f.pos(0) if f.Has("--state") { state = f.Value("--state") } if path == "" || (state != "open" && state != "closed" && state != "all") { return c.usage() } repo, code := resolveRepo(c, path, policy.CanRead) if code >= 0 { return code } readable, err := ReadableScope(c.Store, c.User, repo) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } ms, err := c.Store.ListMilestones(repo, state, readable) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return emitMilestones(c, ms) } // emitMilestones renders a milestone list for the caller, JSON or plain. func emitMilestones(c *Ctx, ms []store.Milestone) int { type out struct { Title string `json:"title"` Description string `json:"description,omitempty"` Due string `json:"due,omitempty"` State string `json:"state"` Org bool `json:"org,omitempty"` Open int `json:"open"` Closed int `json:"closed"` } var ds []out for _, m := range ms { ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OrgID != 0, m.OpenItems, m.ClosedItems}) } return c.emit(ds, func(w io.Writer) { if c.Term.Cols > 0 { tb := c.table(w, "TITLE", "STATE", "DUE", "PROGRESS", "SCOPE") for _, d := range ds { due := cText("") if t, ok := parseStamp(d.Due + " 00:00:00"); ok { due = cText(relAge(d.Due+" 00:00:00", termNow())) if d.State == "open" && t.Before(termNow()) { due = cMark("overdue "+d.Due, sgrRed) } } progress := "" if total := d.Open + d.Closed; total > 0 { progress = fmt.Sprintf("%d/%d closed (%d%%)", d.Closed, total, d.Closed*100/total) } scope := "" if d.Org { scope = "org" } tb.row(cRef(d.Title), cState(d.State), due, cText(progress), cText(scope)) } tb.flush() return } tb := c.table(w, "TITLE", "STATE", "DUE", "PROGRESS") for _, d := range ds { due := d.Due if due == "" { due = "-" } cells := []cell{cRef(d.Title), cState(d.State), cText("due " + due), cText(fmt.Sprintf("%d open, %d closed", d.Open, d.Closed))} if d.Org { cells = append(cells, cText("org")) } tb.row(cells...) } tb.flush() }) } func runMilestoneClose(c *Ctx, args []string) int { return setMilestoneState(c, args, "closed") } func runMilestoneReopen(c *Ctx, args []string) int { return setMilestoneState(c, args, "open") } func setMilestoneState(c *Ctx, args []string, state string) int { verb := "close" if state == "open" { verb = "reopen" } if len(args) != 2 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanWrite) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } m, err := c.Store.MilestoneByTitle(repo, args[1]) if err != nil { return milestoneErr(c, repo, args[1], err) } if m.OrgID != 0 { return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "milestone", m.Title, verb)) } if err := c.Store.SetMilestoneState(m.ID, state); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(map[string]string{"milestone": m.Title, "state": state}, func(w io.Writer) { fmt.Fprintf(w, "%sd milestone %q on %s\n", verb, m.Title, repo.Path()) }) } func milestoneErr(c *Ctx, repo store.Repo, title string, err error) int { if errors.Is(err, store.ErrNotFound) { return c.fail(protocol.ExitNotFound, "no milestone %q in %s", title, repo.Path()) } return c.fail(protocol.ExitFailure, "%v", err) } func runIssueMilestone(c *Ctx, args []string) int { repo, issue, code := issueRef(c, args, policy.CanWrite) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } if len(args) != 3 { return c.usage() } return setItemMilestone(c, repo, "issue", issue.Number, args[2], func(id int64) error { return c.Store.SetIssueMilestone(issue.ID, id) }) } func runMRMilestone(c *Ctx, args []string) int { repo, mr, code := mrRef(c, args, policy.CanWrite) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } if len(args) != 3 { return c.usage() } return setItemMilestone(c, repo, "mr", mr.Number, args[2], func(id int64) error { return c.Store.SetMRMilestone(mr.ID, id) }) } // recordItemMilestone sets milestone id (0 clears it) through set and // records the milestoned event naming title ("" when cleared). func recordItemMilestone(c *Ctx, repo store.Repo, noun string, number, id int64, title string, set func(int64) error) error { if err := set(id); err != nil { return err } c.Store.RecordEvent(repo.ID, c.User.ID, noun+".milestoned", fmt.Sprintf(`{"number":%d,"milestone":%q}`, number, title)) return nil } // noun and number name what the milestone was set on, for the event. func setItemMilestone(c *Ctx, repo store.Repo, noun string, number int64, title string, set func(int64) error) int { var id int64 if title != "none" { m, err := c.Store.MilestoneByTitle(repo, title) if err != nil { return milestoneErr(c, repo, title, err) } id = m.ID } cleared := title if cleared == "none" { cleared = "" } if err := recordItemMilestone(c, repo, noun, number, id, cleared, set); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if title == "none" { return c.emit(map[string]string{"milestone": ""}, func(w io.Writer) { fmt.Fprintln(w, "milestone cleared") }) } return c.emit(map[string]string{"milestone": title}, func(w io.Writer) { fmt.Fprintf(w, "milestone set to %q\n", title) }) } // runIssueTemplates lists .gitbay/issue-template*.md at the default branch. func runIssueTemplates(c *Ctx, args []string) int { if len(args) != 1 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanRead) if code >= 0 { return code } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) ts := IssueTemplates(dir, repo.DefaultBranch) return c.emit(ts, func(w io.Writer) { tb := c.table(w, "NAME") for _, t := range ts { tb.row(cRef(t.Name)) } tb.flush() }) } type IssueTemplate struct { Name string `json:"name"` Body string `json:"body"` } // IssueTemplates reads .gitbay/issue-template*.md from ref. Missing // directory or unreadable files yield an empty list, never an error. func IssueTemplates(dir, ref string) []IssueTemplate { entries, err := gitutil.ListTree(dir, ref, ".gitbay") if err != nil { return nil } var out []IssueTemplate for _, e := range entries { if e.Type != "blob" || !strings.HasPrefix(e.Name, "issue-template") || !strings.HasSuffix(e.Name, ".md") { continue } raw, err := gitutil.ReadBlob(dir, ref, path.Join(".gitbay", e.Name), maxBodyBytes) if err != nil { continue } out = append(out, IssueTemplate{Name: e.Name, Body: string(raw)}) } return out }