Commit 749296f8f3
Verified · cmc
cmd/gitbay/main.go +6
| @@ -657,6 +657,12 @@ func orgCmd() *cobra.Command { | ||
| 657 | 657 | pass("list", "list org labels with use across readable repositories: <org>", passOpts{server: []string{"org", "label", "list"}}), |
| 658 | 658 | pass("remove", "remove an org label everywhere: <org> <label>", passOpts{server: []string{"org", "label", "remove"}}), |
| 659 | 659 | ), |
| 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 | ), | |
| 660 | 666 | group("team", "scope repository access with teams", |
| 661 | 667 | pass("create", "create a team: <org> <team>", passOpts{server: []string{"org", "team", "create"}}), |
| 662 | 668 | 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) { | ||
| 108 | 108 | "org team list": {"theorg"}, |
| 109 | 109 | "org team show": {"theorg", "core"}, |
| 110 | 110 | "org label list": {"theorg"}, |
| 111 | "org milestone list": {"theorg"}, | |
| 111 | 112 | "repo search": {"app"}, |
| 112 | 113 | "repo show": {"alice/app"}, |
| 113 | 114 | "repo access list": {"alice/app"}, |
internal/control/milestone.go +5
| @@ -95,6 +95,11 @@ func runMilestoneList(c *Ctx, args []string) int { | ||
| 95 | 95 | if err != nil { |
| 96 | 96 | return c.fail(protocol.ExitFailure, "%v", err) |
| 97 | 97 | } |
| 98 | return emitMilestones(c, ms) | |
| 99 | } | |
| 100 | ||
| 101 | // emitMilestones renders a milestone list for the caller, JSON or plain. | |
| 102 | func emitMilestones(c *Ctx, ms []store.Milestone) int { | |
| 98 | 103 | type out struct { |
| 99 | 104 | Title string `json:"title"` |
| 100 | 105 | Description string `json:"description,omitempty"` |
internal/control/orglabel.go +98
| @@ -20,6 +20,18 @@ func init() { | ||
| 20 | 20 | register(Command{Path: []string{"org", "label", "remove"}, |
| 21 | 21 | Summary: "remove an org label from the org and from every issue under it", |
| 22 | 22 | 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}) | |
| 23 | 35 | } |
| 24 | 36 | |
| 25 | 37 | // orgReader resolves an org for a read of its labels or milestones. |
| @@ -141,3 +153,89 @@ func runOrgLabelRemove(c *Ctx, args []string) int { | ||
| 141 | 153 | fmt.Fprintf(w, "removed org label %s from %s\n", args[1], org.Name) |
| 142 | 154 | }) |
| 143 | 155 | } |
| 156 | ||
| 157 | func 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 | ||
| 190 | func 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 | ||
| 213 | func runOrgMilestoneClose(c *Ctx, args []string) int { return setOrgMilestoneState(c, args, "closed") } | |
| 214 | func runOrgMilestoneReopen(c *Ctx, args []string) int { return setOrgMilestoneState(c, args, "open") } | |
| 215 | ||
| 216 | func 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) { | ||
| 69 | 69 | t.Fatalf("outsider list: exit %d %s", code, out.String()) |
| 70 | 70 | } |
| 71 | 71 | } |
| 72 | ||
| 73 | func 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 | } | |