Commit e3ba4251c5

e3ba4251c56d3c86047bff848bb7be1ecf405d31

parent: 35954391f3

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 07:48 UTC

issue create: --label, --milestone, --assignee

Ref #268

Layout: unified · split

CHANGELOG.org +3
@@ -89,6 +89,9 @@ for the eighteen commands whose CLI path differs from the registry's
89 including the error text of the web UI and the JSON API (#267). 89 including the error text of the web UI and the JSON API (#267).
90- An unregistered SSH key is refused with its own fingerprint and the 90- An unregistered SSH key is refused with its own fingerprint and the
91 real host, and both the web and ssh paths to register (#268). 91 real host, and both the web and ssh paths to register (#268).
92- =issue create= takes =--label= (repeatable), =--milestone= and
93 =--assignee= (repeatable), setting them in the same call instead of
94 a separate one per field (#268).
92 95
93* v1.36.0 — 2026-09-23 96* v1.36.0 — 2026-09-23
94 97
internal/control/issue.go +55 −5
@@ -17,16 +17,20 @@ const maxBodyBytes = 64 << 10
17func init() { 17func init() {
18 register(Command{Path: []string{"issue", "create"}, 18 register(Command{Path: []string{"issue", "create"},
19 Summary: "open an issue", 19 Summary: "open an issue",
20 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]", 20 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org] [--label <l>]... [--milestone <title>] [--assignee <user>]...",
21 Flags: []Flag{ 21 Flags: []Flag{
22 {"--title", "<t>", "the issue's title", ""}, 22 {"--title", "<t>", "the issue's title", ""},
23 {"--body", "<b>", "the issue's body", ""}, 23 {"--body", "<b>", "the issue's body", ""},
24 {"--file", "-", "read the body from stdin", ""}, 24 {"--file", "-", "read the body from stdin", ""},
25 {"--format", "md|org", "the body's markup", "md"}, 25 {"--format", "md|org", "the body's markup", "md"},
26 {"--label", "<l>", "label to add, may repeat", ""},
27 {"--milestone", "<title>", "milestone to set", ""},
28 {"--assignee", "<user>", "user to assign, may repeat", ""},
26 }, 29 },
27 Examples: []string{ 30 Examples: []string{
28 `issue create krz/gitbay --title "crash on empty repo" --body "steps to reproduce..."`, 31 `issue create krz/gitbay --title "crash on empty repo" --body "steps to reproduce..."`,
29 "issue create krz/gitbay --title notes --file - < notes.md", 32 "issue create krz/gitbay --title notes --file - < notes.md",
33 "issue create krz/gitbay --title bug --label bug --label priority --milestone v1 --assignee cmc",
30 }, 34 },
31 ReadsStdin: true, Run: runIssueCreate}) 35 ReadsStdin: true, Run: runIssueCreate})
32 register(Command{Path: []string{"issue", "list"}, 36 register(Command{Path: []string{"issue", "list"},
@@ -177,9 +181,15 @@ func issueToOut(i store.Issue, withBody bool) issueOut {
177 return o 181 return o
178} 182}
179 183
184// runIssueCreate opens an issue. The CLI opens $EDITOR for the body
185// when neither --body nor --file is given (cmd/gitbay's issueCmd,
186// editor: "issue"); over stock ssh the body must be one of the two.
180func runIssueCreate(c *Ctx, args []string) int { 187func runIssueCreate(c *Ctx, args []string) int {
181 f, err := c.parseArgs(args, flagSpec{Values: []string{"--format", "--title", "--body", "--file"}, MaxPos: 1, 188 f, err := c.parseArgs(args, flagSpec{
182 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]"}) 189 Values: []string{"--format", "--title", "--body", "--file", "--milestone"},
190 Multi: []string{"--label", "--assignee"},
191 MaxPos: 1,
192 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org] [--label <l>]... [--milestone <title>] [--assignee <user>]..."})
183 if err != nil { 193 if err != nil {
184 return c.fail(protocol.ExitUsage, "%v", err) 194 return c.fail(protocol.ExitUsage, "%v", err)
185 } 195 }
@@ -202,6 +212,18 @@ func runIssueCreate(c *Ctx, args []string) int {
202 if code := refuseArchived(c, repo); code >= 0 { 212 if code := refuseArchived(c, repo); code >= 0 {
203 return code 213 return code
204 } 214 }
215 // Filing an issue only needs read access; setting a label, milestone
216 // or assignee on it needs the same write access issue label/issue
217 // milestone/issue assign require.
218 if len(f.List("--label")) > 0 || f.Value("--milestone") != "" || len(f.List("--assignee")) > 0 {
219 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
220 if err != nil {
221 return c.fail(protocol.ExitFailure, "checking access: %v", err)
222 }
223 if !policy.CanWrite(c.User, repo, grant) {
224 return c.fail(protocol.ExitDenied, "permission denied on %s; ask its owner for access", path)
225 }
226 }
205 b, err := bodyFrom(c, body, file) 227 b, err := bodyFrom(c, body, file)
206 if err != nil { 228 if err != nil {
207 return c.failInput(err) 229 return c.failInput(err)
@@ -217,8 +239,36 @@ func runIssueCreate(c *Ctx, args []string) int {
217 action: fmt.Sprintf("opened issue #%d", n), 239 action: fmt.Sprintf("opened issue #%d", n),
218 excerpt: b, path: fmt.Sprintf("%s/issues/%d", repo.Path(), n)}) 240 excerpt: b, path: fmt.Sprintf("%s/issues/%d", repo.Path(), n)})
219 } 241 }
220 if issue, err := c.Store.IssueByNumber(repo.ID, n); err == nil { 242 issue, err := c.Store.IssueByNumber(repo.ID, n)
221 notifyMentions(c, repo, issueThread, issue.ID, n, title, b) 243 if err != nil {
244 return c.fail(protocol.ExitFailure, "%v", err)
245 }
246 notifyMentions(c, repo, issueThread, issue.ID, n, title, b)
247 for _, l := range f.List("--label") {
248 if err := c.Store.SetIssueLabel(repo, issue.ID, l, true); err != nil {
249 return c.fail(protocol.ExitFailure, "%v", err)
250 }
251 }
252 if m := f.Value("--milestone"); m != "" {
253 ms, err := c.Store.MilestoneByTitle(repo, m)
254 if err != nil {
255 return milestoneErr(c, repo, m, err)
256 }
257 if err := c.Store.SetIssueMilestone(issue.ID, ms.ID); err != nil {
258 return c.fail(protocol.ExitFailure, "%v", err)
259 }
260 }
261 for _, name := range f.List("--assignee") {
262 u, err := c.Store.UserByUsername(name)
263 if errors.Is(err, store.ErrNotFound) {
264 return c.fail(protocol.ExitNotFound, "no such user %q", name)
265 }
266 if err != nil {
267 return c.fail(protocol.ExitFailure, "%v", err)
268 }
269 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil {
270 return c.fail(protocol.ExitFailure, "%v", err)
271 }
222 } 272 }
223 return c.emit(Created{Number: n}, func(w io.Writer) { 273 return c.emit(Created{Number: n}, func(w io.Writer) {
224 fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n) 274 fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
internal/control/issue_test.go +80 −1
@@ -1,6 +1,85 @@
1package control 1package control
2 2
3import "testing" 3import (
4 "bytes"
5 "errors"
6 "testing"
7
8 "gitbay.org/gitbay/internal/protocol"
9 "gitbay.org/gitbay/internal/store"
10)
11
12// TestIssueCreateWithLabelRequiresWrite checks that attaching a label at
13// create time needs the same write access issue label requires, not just
14// the read access that lets anyone file the issue in the first place.
15func TestIssueCreateWithLabelRequiresWrite(t *testing.T) {
16 c := notifTestCtx(t, "alice")
17 repoID, err := c.Store.CreateRepo("user", c.User.ID, "app", "public")
18 if err != nil {
19 t.Fatal(err)
20 }
21 repo, err := c.Store.RepoByID(repoID)
22 if err != nil {
23 t.Fatal(err)
24 }
25 if err := c.Store.SetLabel(repo, "bug", "ff0000"); err != nil {
26 t.Fatal(err)
27 }
28 bobID, err := c.Store.CreateUser("bob", false)
29 if err != nil {
30 t.Fatal(err)
31 }
32 bob := *c
33 bob.User = store.User{ID: bobID, Username: "bob"}
34 var out bytes.Buffer
35 bob.Stdout, bob.Stderr = &out, &out
36
37 if code := runIssueCreate(&bob, []string{repo.Path(), "--title", "t", "--label", "bug"}); code != protocol.ExitDenied {
38 t.Fatalf("exit %d, want %d", code, protocol.ExitDenied)
39 }
40 if _, err := c.Store.IssueByNumber(repo.ID, 1); !errors.Is(err, store.ErrNotFound) {
41 t.Fatalf("issue was created despite the denial: %v", err)
42 }
43}
44
45func TestIssueCreateSetsLabelsMilestoneAndAssignee(t *testing.T) {
46 c := notifTestCtx(t, "alice")
47 repoID, err := c.Store.CreateRepo("user", c.User.ID, "app", "public")
48 if err != nil {
49 t.Fatal(err)
50 }
51 repo, err := c.Store.RepoByID(repoID)
52 if err != nil {
53 t.Fatal(err)
54 }
55 if err := c.Store.SetLabel(repo, "bug", "ff0000"); err != nil {
56 t.Fatal(err)
57 }
58 if _, err := c.Store.CreateMilestone(repo, "m1", "", ""); err != nil {
59 t.Fatal(err)
60 }
61 if _, err := c.Store.CreateUser("bob", false); err != nil {
62 t.Fatal(err)
63 }
64
65 if code := runIssueCreate(c, []string{repo.Path(), "--title", "t",
66 "--label", "bug", "--milestone", "m1", "--assignee", "bob"}); code != 0 {
67 t.Fatalf("exit %d", code)
68 }
69 issue, err := c.Store.IssueByNumber(repo.ID, 1)
70 if err != nil {
71 t.Fatal(err)
72 }
73 if len(issue.Labels) != 1 || issue.Labels[0] != "bug" {
74 t.Errorf("labels = %v", issue.Labels)
75 }
76 if issue.Milestone != "m1" {
77 t.Errorf("milestone = %q", issue.Milestone)
78 }
79 if len(issue.Assignees) != 1 || issue.Assignees[0] != "bob" {
80 t.Errorf("assignees = %v", issue.Assignees)
81 }
82}
4 83
5func TestIssueAssignNotifiesTheAssignee(t *testing.T) { 84func TestIssueAssignNotifiesTheAssignee(t *testing.T) {
6 c, repo, bob := testRepoWithWatcher(t) 85 c, repo, bob := testRepoWithWatcher(t)