Commit 749296f8f3

749296f8f32d6a1ea1a877c923e93c2cc7e27c2e

parent: fed065d4fe

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-11 15:33 UTC

control, cli: org milestone create, list, close, reopen

Ref #203
cmd/gitbay/main.go +6
@@ -657,6 +657,12 @@ func orgCmd() *cobra.Command {
657657 pass("list", "list org labels with use across readable repositories: <org>", passOpts{server: []string{"org", "label", "list"}}),
658658 pass("remove", "remove an org label everywhere: <org> <label>", passOpts{server: []string{"org", "label", "remove"}}),
659659 ),
660 group("milestone", "milestones spanning an org's repositories",
661 pass("create", "create an org milestone: <org> <title> [--description d] [--due YYYY-MM-DD]", passOpts{server: []string{"org", "milestone", "create"}}),
662 pass("list", "list org milestones with progress: <org> [--state open|closed|all]", passOpts{server: []string{"org", "milestone", "list"}}),
663 pass("close", "close an org milestone: <org> <title>", passOpts{server: []string{"org", "milestone", "close"}}),
664 pass("reopen", "reopen an org milestone: <org> <title>", passOpts{server: []string{"org", "milestone", "reopen"}}),
665 ),
660666 group("team", "scope repository access with teams",
661667 pass("create", "create a team: <org> <team>", passOpts{server: []string{"org", "team", "create"}}),
662668 pass("delete", "delete a team: <org> <team>", passOpts{server: []string{"org", "team", "delete"}}),
e2e/readonly_test.go +1
@@ -108,6 +108,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
108108 "org team list": {"theorg"},
109109 "org team show": {"theorg", "core"},
110110 "org label list": {"theorg"},
111 "org milestone list": {"theorg"},
111112 "repo search": {"app"},
112113 "repo show": {"alice/app"},
113114 "repo access list": {"alice/app"},
internal/control/milestone.go +5
@@ -95,6 +95,11 @@ func runMilestoneList(c *Ctx, args []string) int {
9595 if err != nil {
9696 return c.fail(protocol.ExitFailure, "%v", err)
9797 }
98 return emitMilestones(c, ms)
99}
100
101// emitMilestones renders a milestone list for the caller, JSON or plain.
102func emitMilestones(c *Ctx, ms []store.Milestone) int {
98103 type out struct {
99104 Title string `json:"title"`
100105 Description string `json:"description,omitempty"`
internal/control/orglabel.go +98
@@ -20,6 +20,18 @@ func init() {
2020 register(Command{Path: []string{"org", "label", "remove"},
2121 Summary: "remove an org label from the org and from every issue under it",
2222 Usage: "org label remove <org> <label>", Run: runOrgLabelRemove})
23 register(Command{Path: []string{"org", "milestone", "create"},
24 Summary: "create an org milestone spanning every org repository; folds in same-titled repo milestones",
25 Usage: "org milestone create <org> <title> [--description <d>] [--due YYYY-MM-DD]", Run: runOrgMilestoneCreate})
26 register(Command{Path: []string{"org", "milestone", "list"},
27 Summary: "list an org's milestones with progress across the repositories you can read",
28 Usage: "org milestone list <org> [--state open|closed|all]", ReadOnly: true, Run: runOrgMilestoneList})
29 register(Command{Path: []string{"org", "milestone", "close"},
30 Summary: "close an org milestone",
31 Usage: "org milestone close <org> <title>", Run: runOrgMilestoneClose})
32 register(Command{Path: []string{"org", "milestone", "reopen"},
33 Summary: "reopen an org milestone",
34 Usage: "org milestone reopen <org> <title>", Run: runOrgMilestoneReopen})
2335}
2436
2537// orgReader resolves an org for a read of its labels or milestones.
@@ -141,3 +153,89 @@ func runOrgLabelRemove(c *Ctx, args []string) int {
141153 fmt.Fprintf(w, "removed org label %s from %s\n", args[1], org.Name)
142154 })
143155}
156
157func runOrgMilestoneCreate(c *Ctx, args []string) int {
158 const usage = "usage: org milestone create <org> <title> [--description <d>] [--due YYYY-MM-DD]"
159 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2, Usage: usage})
160 if err != nil {
161 return c.fail(protocol.ExitUsage, "%v", err)
162 }
163 orgName, title, description, due := f.pos(0), f.pos(1), f.Value("--description"), f.Value("--due")
164 if orgName == "" || title == "" {
165 return c.fail(protocol.ExitUsage, usage)
166 }
167 if due != "" && !duePat.MatchString(due) {
168 return c.fail(protocol.ExitUsage, "--due must be YYYY-MM-DD")
169 }
170 org, code := orgAdmin(c, orgName)
171 if code >= 0 {
172 return code
173 }
174 _, folded, err := c.Store.CreateOrgMilestone(org.ID, title, description, due)
175 if err != nil {
176 return c.fail(protocol.ExitFailure, "%v", err)
177 }
178 return c.emit(struct {
179 Milestone string `json:"milestone"`
180 Folded int `json:"folded"`
181 }{title, folded}, func(w io.Writer) {
182 fmt.Fprintf(w, "created org milestone %q on %s", title, org.Name)
183 if folded > 0 {
184 fmt.Fprintf(w, "; folded in %d repositor%s", folded, map[bool]string{true: "y", false: "ies"}[folded == 1])
185 }
186 fmt.Fprintln(w)
187 })
188}
189
190func runOrgMilestoneList(c *Ctx, args []string) int {
191 f, err := parseFlags(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "org milestone list <org> [--state open|closed|all]"})
192 if err != nil {
193 return c.fail(protocol.ExitUsage, "%v", err)
194 }
195 state, orgName := "open", f.pos(0)
196 if f.Has("--state") {
197 state = f.Value("--state")
198 }
199 if orgName == "" || (state != "open" && state != "closed" && state != "all") {
200 return c.fail(protocol.ExitUsage, "usage: org milestone list <org> [--state open|closed|all]")
201 }
202 org, readable, code := orgReader(c, orgName)
203 if code >= 0 {
204 return code
205 }
206 ms, err := c.Store.ListOrgMilestones(org.ID, state, readable)
207 if err != nil {
208 return c.fail(protocol.ExitFailure, "%v", err)
209 }
210 return emitMilestones(c, ms)
211}
212
213func runOrgMilestoneClose(c *Ctx, args []string) int { return setOrgMilestoneState(c, args, "closed") }
214func runOrgMilestoneReopen(c *Ctx, args []string) int { return setOrgMilestoneState(c, args, "open") }
215
216func setOrgMilestoneState(c *Ctx, args []string, state string) int {
217 verb := "close"
218 if state == "open" {
219 verb = "reopen"
220 }
221 if len(args) != 2 {
222 return c.fail(protocol.ExitUsage, "usage: org milestone %s <org> <title>", verb)
223 }
224 org, code := orgAdmin(c, args[0])
225 if code >= 0 {
226 return code
227 }
228 m, err := c.Store.OrgMilestoneByTitle(org.ID, args[1])
229 if errors.Is(err, store.ErrNotFound) {
230 return c.fail(protocol.ExitNotFound, "no org milestone %q on %s", args[1], org.Name)
231 }
232 if err != nil {
233 return c.fail(protocol.ExitFailure, "%v", err)
234 }
235 if err := c.Store.SetMilestoneState(m.ID, state); err != nil {
236 return c.fail(protocol.ExitFailure, "%v", err)
237 }
238 return c.emit(map[string]string{"milestone": m.Title, "state": state}, func(w io.Writer) {
239 fmt.Fprintf(w, "%sd org milestone %q on %s\n", verb, m.Title, org.Name)
240 })
241}
internal/control/orglabel_test.go +54
@@ -69,3 +69,57 @@ func TestOrgLabelListVisibility(t *testing.T) {
6969 t.Fatalf("outsider list: exit %d %s", code, out.String())
7070 }
7171}
72
73func TestOrgMilestoneLifecycle(t *testing.T) {
74 f := newOrgFixture(t)
75 f.st.CreateMilestone(f.core, "v1", "", "")
76 c, out := f.ctx(f.alice)
77 if code := runOrgMilestoneCreate(c, []string{"acme", "v1", "--due", "2027-01-01"}); code != protocol.ExitOK ||
78 !strings.Contains(out.String(), `"folded":1`) {
79 t.Fatalf("create: exit %d %s", code, out.String())
80 }
81 out.Reset()
82 if code := runOrgMilestoneCreate(c, []string{"acme", "v1"}); code != protocol.ExitFailure {
83 t.Fatalf("duplicate create: exit %d %s", code, out.String())
84 }
85 out.Reset()
86 if code := runOrgMilestoneCreate(c, []string{"acme", "v2", "--due", "soon"}); code != protocol.ExitUsage {
87 t.Fatalf("bad due: exit %d %s", code, out.String())
88 }
89 out.Reset()
90 // An issue in each repo attaches by title; progress spans both.
91 f.st.CreateIssue(f.core.ID, f.alice, "c1", "", "md")
92 f.st.CreateIssue(f.priv.ID, f.alice, "p1", "", "md")
93 runIssueMilestone(c, []string{"acme/core", "1", "v1"})
94 runIssueMilestone(c, []string{"acme/priv", "1", "v1"})
95 out.Reset()
96 if code := runOrgMilestoneList(c, []string{"acme"}); code != protocol.ExitOK ||
97 !strings.Contains(out.String(), `"open":2`) || !strings.Contains(out.String(), `"due":"2027-01-01"`) {
98 t.Fatalf("list: exit %d %s", code, out.String())
99 }
100 out.Reset()
101 // carol reads only the public repo's count.
102 cc, cout := f.ctx(f.carol)
103 if code := runOrgMilestoneList(cc, []string{"acme"}); code != protocol.ExitOK || !strings.Contains(cout.String(), `"open":1`) {
104 t.Fatalf("outsider list: exit %d %s", code, cout.String())
105 }
106 if code := runOrgMilestoneClose(c, []string{"acme", "v1"}); code != protocol.ExitOK {
107 t.Fatalf("close: exit %d %s", code, out.String())
108 }
109 out.Reset()
110 if code := runOrgMilestoneList(c, []string{"acme"}); code != protocol.ExitOK || strings.Contains(out.String(), `"title":"v1"`) {
111 t.Fatalf("closed still listed as open: %s", out.String())
112 }
113 out.Reset()
114 if code := runOrgMilestoneReopen(c, []string{"acme", "v1"}); code != protocol.ExitOK {
115 t.Fatalf("reopen: exit %d %s", code, out.String())
116 }
117 out.Reset()
118 if code := runOrgMilestoneClose(c, []string{"acme", "nope"}); code != protocol.ExitNotFound {
119 t.Fatalf("close missing: exit %d %s", code, out.String())
120 }
121 bc, bout := f.ctx(f.bob)
122 if code := runOrgMilestoneClose(bc, []string{"acme", "v1"}); code != protocol.ExitDenied {
123 t.Fatalf("member close: exit %d %s", code, bout.String())
124 }
125}