Commit 4fa1930038

4fa1930038e594bcad209d872374fbb4a968aa46

parent: 6bf4591bd7

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 08:32 UTC

issue create: resolve milestone and assignees first, set fields through issue label/milestone/assign

A typo creates nothing, and the assignee gets the assigned-you notice
and the labeled, milestoned and assigned events are recorded.

Ref #268

Layout: unified · split

internal/control/issue.go +79 −47
@@ -224,6 +224,18 @@ func runIssueCreate(c *Ctx, args []string) int {
224224 return c.fail(protocol.ExitDenied, "permission denied on %s; ask its owner for access", path)
225225 }
226226 }
227 // Resolve everything that can be refused before the issue exists, so
228 // a typo in a milestone or an assignee creates nothing.
229 var milestone store.Milestone
230 if m := f.Value("--milestone"); m != "" {
231 if milestone, err = c.Store.MilestoneByTitle(repo, m); err != nil {
232 return milestoneErr(c, repo, m, err)
233 }
234 }
235 assignees, code := resolveUsers(c, f.List("--assignee"))
236 if code >= 0 {
237 return code
238 }
227239 b, err := bodyFrom(c, body, file)
228240 if err != nil {
229241 return c.failInput(err)
@@ -244,30 +256,21 @@ func runIssueCreate(c *Ctx, args []string) int {
244256 return c.fail(protocol.ExitFailure, "%v", err)
245257 }
246258 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)
259 if labels := f.List("--label"); len(labels) > 0 {
260 if _, code := labelIssue(c, repo, issue, labels, nil); code >= 0 {
261 return code
250262 }
251263 }
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 {
264 if milestone.ID != 0 {
265 if err := recordItemMilestone(c, repo, "issue", n, milestone.ID, milestone.Title, func(id int64) error {
266 return c.Store.SetIssueMilestone(issue.ID, id)
267 }); err != nil {
258268 return c.fail(protocol.ExitFailure, "%v", err)
259269 }
260270 }
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 if len(assignees) > 0 {
272 if _, code := assignIssue(c, repo, issue, assignees, nil); code >= 0 {
273 return code
271274 }
272275 }
273276 return c.emit(Created{Number: n}, func(w io.Writer) {
@@ -523,28 +526,39 @@ func runIssueLabel(c *Ctx, args []string) int {
523526 if code := refuseArchived(c, repo); code >= 0 {
524527 return code
525528 }
529 labels, code := labelIssue(c, repo, issue, adds, removes)
530 if code >= 0 {
531 return code
532 }
533 return c.emit(map[string]any{"number": issue.Number, "labels": labels}, func(w io.Writer) {
534 fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(labels, ", "))
535 })
536}
537
538// labelIssue adds and removes labels on issue and records the
539// issue.labeled event, returning the labels it carries afterwards. It
540// backs issue label and issue create --label.
541func labelIssue(c *Ctx, repo store.Repo, issue store.Issue, adds, removes []string) ([]string, int) {
526542 for _, l := range adds {
527543 if err := c.Store.SetIssueLabel(repo, issue.ID, l, true); err != nil {
528 return c.fail(protocol.ExitFailure, "%v", err)
544 return nil, c.fail(protocol.ExitFailure, "%v", err)
529545 }
530546 }
531547 for _, l := range removes {
532548 if err := c.Store.SetIssueLabel(repo, issue.ID, l, false); err != nil {
533549 if errors.Is(err, store.ErrNotFound) {
534 return c.fail(protocol.ExitNotFound, "%v", err)
550 return nil, c.fail(protocol.ExitNotFound, "%v", err)
535551 }
536 return c.fail(protocol.ExitFailure, "%v", err)
552 return nil, c.fail(protocol.ExitFailure, "%v", err)
537553 }
538554 }
539555 updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
540556 if err != nil {
541 return c.fail(protocol.ExitFailure, "%v", err)
557 return nil, c.fail(protocol.ExitFailure, "%v", err)
542558 }
543559 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.labeled",
544560 fmt.Sprintf(`{"number":%d,"labels":%s}`, issue.Number, jsonStrings(updated.Labels)))
545 return c.emit(map[string]any{"number": issue.Number, "labels": updated.Labels}, func(w io.Writer) {
546 fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Labels, ", "))
547 })
561 return updated.Labels, -1
548562}
549563
550564func runIssueAssign(c *Ctx, args []string) int {
@@ -562,16 +576,44 @@ func runIssueAssign(c *Ctx, args []string) int {
562576 if code := refuseArchived(c, repo); code >= 0 {
563577 return code
564578 }
565 resolve := func(name string) (store.User, int) {
579 add, code := resolveUsers(c, adds)
580 if code >= 0 {
581 return code
582 }
583 remove, code := resolveUsers(c, removes)
584 if code >= 0 {
585 return code
586 }
587 assignees, code := assignIssue(c, repo, issue, add, remove)
588 if code >= 0 {
589 return code
590 }
591 return c.emit(map[string]any{"number": issue.Number, "assignees": assignees}, func(w io.Writer) {
592 fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(assignees, ", "))
593 })
594}
595
596// resolveUsers looks up every name, failing on the first that does not
597// exist, so a typo changes nothing.
598func resolveUsers(c *Ctx, names []string) ([]store.User, int) {
599 users := make([]store.User, 0, len(names))
600 for _, name := range names {
566601 u, err := c.Store.UserByUsername(name)
567602 if errors.Is(err, store.ErrNotFound) {
568 return u, c.fail(protocol.ExitNotFound, "no such user %q", name)
603 return nil, c.fail(protocol.ExitNotFound, "no such user %q", name)
569604 }
570605 if err != nil {
571 return u, c.fail(protocol.ExitFailure, "%v", err)
606 return nil, c.fail(protocol.ExitFailure, "%v", err)
572607 }
573 return u, -1
608 users = append(users, u)
574609 }
610 return users, -1
611}
612
613// assignIssue adds and removes assignees on issue, records the
614// issue.assigned event and tells each newly added account, returning the
615// assignees afterwards. It backs issue assign and issue create --assignee.
616func assignIssue(c *Ctx, repo store.Repo, issue store.Issue, adds, removes []store.User) ([]string, int) {
575617 // issue is the read from before the update, so its Assignees are who
576618 // was already on it. SetIssueAssignee inserts ON CONFLICT DO NOTHING
577619 // and returns nil whether or not it inserted, and the notice below is
@@ -582,13 +624,9 @@ func runIssueAssign(c *Ctx, args []string) int {
582624 assigned[name] = true
583625 }
584626 var added []int64
585 for _, name := range adds {
586 u, code := resolve(name)
587 if code >= 0 {
588 return code
589 }
627 for _, u := range adds {
590628 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil {
591 return c.fail(protocol.ExitFailure, "%v", err)
629 return nil, c.fail(protocol.ExitFailure, "%v", err)
592630 }
593631 if assigned[u.Username] {
594632 continue
@@ -596,21 +634,17 @@ func runIssueAssign(c *Ctx, args []string) int {
596634 assigned[u.Username] = true
597635 added = append(added, u.ID)
598636 }
599 for _, name := range removes {
600 u, code := resolve(name)
601 if code >= 0 {
602 return code
603 }
637 for _, u := range removes {
604638 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil {
605639 if errors.Is(err, store.ErrNotFound) {
606 return c.fail(protocol.ExitNotFound, "%s is not assigned", name)
640 return nil, c.fail(protocol.ExitNotFound, "%s is not assigned", u.Username)
607641 }
608 return c.fail(protocol.ExitFailure, "%v", err)
642 return nil, c.fail(protocol.ExitFailure, "%v", err)
609643 }
610644 }
611645 updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
612646 if err != nil {
613 return c.fail(protocol.ExitFailure, "%v", err)
647 return nil, c.fail(protocol.ExitFailure, "%v", err)
614648 }
615649 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.assigned",
616650 fmt.Sprintf(`{"number":%d,"assignees":%s}`, issue.Number, jsonStrings(updated.Assignees)))
@@ -624,7 +658,5 @@ func runIssueAssign(c *Ctx, args []string) int {
624658 action: fmt.Sprintf("assigned you to #%d", issue.Number),
625659 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
626660 }
627 return c.emit(map[string]any{"number": issue.Number, "assignees": updated.Assignees}, func(w io.Writer) {
628 fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Assignees, ", "))
629 })
661 return updated.Assignees, -1
630662}
internal/control/issue_test.go +44 −1
@@ -3,6 +3,7 @@ package control
33import (
44 "bytes"
55 "errors"
6 "slices"
67 "testing"
78
89 "gitbay.org/gitbay/internal/protocol"
@@ -58,7 +59,8 @@ func TestIssueCreateSetsLabelsMilestoneAndAssignee(t *testing.T) {
5859 if _, err := c.Store.CreateMilestone(repo, "m1", "", ""); err != nil {
5960 t.Fatal(err)
6061 }
61 if _, err := c.Store.CreateUser("bob", false); err != nil {
62 bob, err := c.Store.CreateUser("bob", false)
63 if err != nil {
6264 t.Fatal(err)
6365 }
6466
@@ -66,6 +68,24 @@ func TestIssueCreateSetsLabelsMilestoneAndAssignee(t *testing.T) {
6668 "--label", "bug", "--milestone", "m1", "--assignee", "bob"}); code != 0 {
6769 t.Fatalf("exit %d", code)
6870 }
71 rows, _ := c.Store.Inbox(bob, false, 20, 0)
72 if len(rows) != 1 || rows[0].Summary != "assigned you to #1" {
73 t.Errorf("bob's inbox = %+v, want one assigned-you notice", rows)
74 }
75 var kinds []string
76 ev, err := c.Store.DB.Query("SELECT kind FROM events WHERE repo_id = ? ORDER BY id", repo.ID)
77 if err != nil {
78 t.Fatal(err)
79 }
80 for ev.Next() {
81 var k string
82 ev.Scan(&k)
83 kinds = append(kinds, k)
84 }
85 ev.Close()
86 if want := []string{"issue.created", "issue.labeled", "issue.milestoned", "issue.assigned"}; !slices.Equal(kinds, want) {
87 t.Errorf("events = %v, want %v", kinds, want)
88 }
6989 issue, err := c.Store.IssueByNumber(repo.ID, 1)
7090 if err != nil {
7191 t.Fatal(err)
@@ -81,6 +101,29 @@ func TestIssueCreateSetsLabelsMilestoneAndAssignee(t *testing.T) {
81101 }
82102}
83103
104// A milestone or assignee that does not resolve is refused before the
105// issue exists, so a typo creates nothing.
106func TestIssueCreateRefusesUnknownMilestoneOrAssigneeFirst(t *testing.T) {
107 c := notifTestCtx(t, "alice")
108 repoID, err := c.Store.CreateRepo("user", c.User.ID, "app", "public")
109 if err != nil {
110 t.Fatal(err)
111 }
112 repo, err := c.Store.RepoByID(repoID)
113 if err != nil {
114 t.Fatal(err)
115 }
116 for _, extra := range [][]string{{"--milestone", "nope"}, {"--assignee", "nobody"}} {
117 args := append([]string{repo.Path(), "--title", "t", "--body", "b", "--label", "bug"}, extra...)
118 if code := runIssueCreate(c, args); code != protocol.ExitNotFound {
119 t.Errorf("%v: exit %d, want %d", extra, code, protocol.ExitNotFound)
120 }
121 if _, err := c.Store.IssueByNumber(repo.ID, 1); !errors.Is(err, store.ErrNotFound) {
122 t.Fatalf("%v: an issue was created: %v", extra, err)
123 }
124 }
125}
126
84127func TestIssueAssignNotifiesTheAssignee(t *testing.T) {
85128 c, repo, bob := testRepoWithWatcher(t)
86129
internal/control/milestone.go +14 −5
@@ -223,6 +223,17 @@ func runMRMilestone(c *Ctx, args []string) int {
223223 })
224224}
225225
226// recordItemMilestone sets milestone id (0 clears it) through set and
227// records the milestoned event naming title ("" when cleared).
228func recordItemMilestone(c *Ctx, repo store.Repo, noun string, number, id int64, title string, set func(int64) error) error {
229 if err := set(id); err != nil {
230 return err
231 }
232 c.Store.RecordEvent(repo.ID, c.User.ID, noun+".milestoned",
233 fmt.Sprintf(`{"number":%d,"milestone":%q}`, number, title))
234 return nil
235}
236
226237// noun and number name what the milestone was set on, for the event.
227238func setItemMilestone(c *Ctx, repo store.Repo, noun string, number int64, title string, set func(int64) error) int {
228239 var id int64
@@ -233,15 +244,13 @@ func setItemMilestone(c *Ctx, repo store.Repo, noun string, number int64, title
233244 }
234245 id = m.ID
235246 }
236 if err := set(id); err != nil {
237 return c.fail(protocol.ExitFailure, "%v", err)
238 }
239247 cleared := title
240248 if cleared == "none" {
241249 cleared = ""
242250 }
243 c.Store.RecordEvent(repo.ID, c.User.ID, noun+".milestoned",
244 fmt.Sprintf(`{"number":%d,"milestone":%q}`, number, cleared))
251 if err := recordItemMilestone(c, repo, noun, number, id, cleared, set); err != nil {
252 return c.fail(protocol.ExitFailure, "%v", err)
253 }
245254 if title == "none" {
246255 return c.emit(map[string]string{"milestone": ""}, func(w io.Writer) {
247256 fmt.Fprintln(w, "milestone cleared")