internal/control/issue.go

bd5cf5d7d1f34fa780660fd7562b9ffd9746ee27
gitbay/internal/control/issue.go history · blame · raw

662 lines · 23720 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"strconv"
  8	"strings"
  9
 10	"gitbay.org/gitbay/internal/policy"
 11	"gitbay.org/gitbay/internal/protocol"
 12	"gitbay.org/gitbay/internal/store"
 13)
 14
 15const maxBodyBytes = 64 << 10
 16
 17func init() {
 18	register(Command{Path: []string{"issue", "create"},
 19		Summary: "open an issue",
 20		Usage:   "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org] [--label <l>]... [--milestone <title>] [--assignee <user>]...",
 21		Flags: []Flag{
 22			{"--title", "<t>", "the issue's title", ""},
 23			{"--body", "<b>", "the issue's body", ""},
 24			{"--file", "-", "read the body from stdin", ""},
 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", ""},
 29		},
 30		Examples: []string{
 31			`issue create krz/gitbay --title "crash on empty repo" --body "steps to reproduce..."`,
 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",
 34		},
 35		ReadsStdin: true, Run: runIssueCreate})
 36	register(Command{Path: []string{"issue", "list"},
 37		Summary: "list issues",
 38		Usage:   "issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>] [--author <user>] [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>]",
 39		Flags: []Flag{
 40			{"--state", "open|closed|all", "which issues", "open"},
 41			{"--label", "<l>", "only issues carrying this label", ""},
 42			{"--assignee", "<user>", "only issues assigned to this user", ""},
 43			{"--author", "<user>", "only issues opened by this user", ""},
 44			{"--milestone", "<title>|none", "only issues in this milestone, or in none", ""},
 45			{"--search", "<text>", "match title and body", ""},
 46			{"--limit", "<n>", "rows per page", ""},
 47			{"--cursor", "<c>", "continue from the previous page", ""},
 48		},
 49		Examples: []string{
 50			"issue list krz/gitbay --label bug --state all",
 51			"issue list krz/gitbay --assignee cmc",
 52		},
 53		ReadOnly: true, Run: runIssueList})
 54	register(Command{Path: []string{"issue", "show"},
 55		Summary:  "show an issue with comments",
 56		Usage:    "issue show <owner/name> <n>",
 57		Examples: []string{"issue show krz/gitbay 42"},
 58		ReadOnly: true, Run: runIssueShow})
 59	register(Command{Path: []string{"issue", "edit"},
 60		Summary: "edit title or body",
 61		Usage:   "issue edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]",
 62		Flags: []Flag{
 63			{"--title", "<t>", "the issue's new title", ""},
 64			{"--body", "<b>", "the issue's new body", ""},
 65			{"--file", "-", "read the new body from stdin", ""},
 66			{"--format", "md|org", "the body's markup", ""},
 67		},
 68		Examples: []string{
 69			`issue edit krz/gitbay 42 --title "crash on empty repo, take two"`,
 70			"issue edit krz/gitbay 42 --file - < notes.md",
 71		},
 72		ReadsStdin: true, Run: runIssueEdit})
 73	register(Command{Path: []string{"issue", "comment"},
 74		Summary: "add a comment",
 75		Usage:   "issue comment <owner/name> <n> [--message <m> | --file -] [--format md|org]",
 76		Flags: []Flag{
 77			{"--message", "<m>", "the comment's text", ""},
 78			{"--file", "-", "read the comment from stdin", ""},
 79			{"--format", "md|org", "the comment's markup", "md"},
 80		},
 81		Examples: []string{
 82			`issue comment krz/gitbay 42 --message "can't reproduce on main"`,
 83			"issue comment krz/gitbay 42 --file - < notes.md",
 84		},
 85		ReadsStdin: true, Run: runIssueComment})
 86	register(Command{Path: []string{"issue", "close"},
 87		Summary:  "close an issue",
 88		Usage:    "issue close <owner/name> <n>",
 89		Examples: []string{"issue close krz/gitbay 42"},
 90		Run:      runIssueClose})
 91	register(Command{Path: []string{"issue", "reopen"},
 92		Summary:  "reopen an issue",
 93		Usage:    "issue reopen <owner/name> <n>",
 94		Examples: []string{"issue reopen krz/gitbay 42"},
 95		Run:      runIssueReopen})
 96	register(Command{Path: []string{"issue", "label"},
 97		Summary: "add or remove labels",
 98		Usage:   "issue label <owner/name> <n> [--add <l>]... [--remove <l>]...",
 99		Flags: []Flag{
100			{"--add", "<l>", "label to add, may repeat", ""},
101			{"--remove", "<l>", "label to remove, may repeat", ""},
102		},
103		Examples: []string{"issue label krz/gitbay 42 --add bug --remove needs-triage"},
104		Run:      runIssueLabel})
105	register(Command{Path: []string{"issue", "assign"},
106		Summary: "add or remove assignees",
107		Usage:   "issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...",
108		Flags: []Flag{
109			{"--add", "<user>", "user to assign, may repeat", ""},
110			{"--remove", "<user>", "user to unassign, may repeat", ""},
111		},
112		Examples: []string{"issue assign krz/gitbay 42 --add cmc"},
113		Run:      runIssueAssign})
114}
115
116// issueArgs parses "<owner/name> <n>" plus flags handled by the caller.
117func issueRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.Issue, int) {
118	repo, n, code := refArgs(c, args, perm, "issue")
119	if code >= 0 {
120		return repo, store.Issue{}, code
121	}
122	issue, err := c.Store.IssueByNumber(repo.ID, n)
123	if errors.Is(err, store.ErrNotFound) {
124		return repo, issue, c.fail(protocol.ExitNotFound, "issue #%d not found in %s", n, repo.Path())
125	}
126	if err != nil {
127		return repo, issue, c.fail(protocol.ExitFailure, "%v", err)
128	}
129	return repo, issue, -1
130}
131
132// bodyFrom resolves --body/--message inline text or --file - (stdin).
133func bodyFrom(c *Ctx, inline, file string) (string, error) {
134	if inline != "" && file != "" {
135		return "", errors.New("give either an inline message or --file -, not both")
136	}
137	if file != "" {
138		if file != "-" {
139			return "", errors.New("--file only supports - (stdin) over ssh")
140		}
141		raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxBodyBytes))
142		return string(raw), err
143	}
144	return inline, nil
145}
146
147// markupFormat normalizes a --format value. Empty means the caller did not ask,
148// which the caller turns into "md" on create or "unchanged" on edit.
149func markupFormat(v string) (string, error) {
150	switch strings.ToLower(strings.TrimSpace(v)) {
151	case "":
152		return "", nil
153	case "md", "markdown":
154		return "md", nil
155	case "org", "org-mode":
156		return "org", nil
157	}
158	return "", fmt.Errorf("unknown --format %q (want md or org)", v)
159}
160
161type issueOut struct {
162	Number     int64    `json:"number"`
163	Title      string   `json:"title"`
164	State      string   `json:"state"`
165	Author     string   `json:"author"`
166	Milestone  string   `json:"milestone,omitempty"`
167	Labels     []string `json:"labels,omitempty"`
168	Assignees  []string `json:"assignees,omitempty"`
169	Body       string   `json:"body,omitempty"`
170	BodyFormat string   `json:"body_format,omitempty"`
171	CreatedAt  string   `json:"created_at"`
172}
173
174func issueToOut(i store.Issue, withBody bool) issueOut {
175	o := issueOut{Number: i.Number, Title: i.Title, State: i.State, Author: i.Author,
176		Milestone: i.Milestone, Labels: i.Labels, Assignees: i.Assignees, CreatedAt: i.CreatedAt}
177	if withBody {
178		o.Body = i.Body
179		o.BodyFormat = i.BodyFormat
180	}
181	return o
182}
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.
187func runIssueCreate(c *Ctx, args []string) int {
188	f, err := c.parseArgs(args, flagSpec{
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>]..."})
193	if err != nil {
194		return c.fail(protocol.ExitUsage, "%v", err)
195	}
196	path, title, body, file, format := f.pos(0), f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
197	if path == "" || title == "" {
198		return c.usage()
199	}
200	fmtName, err := markupFormat(format)
201	if err != nil {
202		return c.failInput(err)
203	}
204	if fmtName == "" {
205		fmtName = "md"
206	}
207	// Anyone who can read the repo can file an issue.
208	repo, code := resolveRepo(c, path, policy.CanRead)
209	if code >= 0 {
210		return code
211	}
212	if code := refuseArchived(c, repo); code >= 0 {
213		return code
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	}
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	}
239	b, err := bodyFrom(c, body, file)
240	if err != nil {
241		return c.failInput(err)
242	}
243	n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b, fmtName)
244	if err != nil {
245		return c.fail(protocol.ExitFailure, "%v", err)
246	}
247	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
248	if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
249		notify(c, targets, notice{repo: repo, kind: "issue",
250			subject: issueSubject(repo, n, title),
251			action:  fmt.Sprintf("opened issue #%d", n),
252			excerpt: b, path: fmt.Sprintf("%s/issues/%d", repo.Path(), n)})
253	}
254	issue, err := c.Store.IssueByNumber(repo.ID, n)
255	if err != nil {
256		return c.fail(protocol.ExitFailure, "%v", err)
257	}
258	notifyMentions(c, repo, issueThread, issue.ID, n, title, b)
259	if labels := f.List("--label"); len(labels) > 0 {
260		if _, code := labelIssue(c, repo, issue, labels, nil); code >= 0 {
261			return code
262		}
263	}
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 {
268			return c.fail(protocol.ExitFailure, "%v", err)
269		}
270	}
271	if len(assignees) > 0 {
272		if _, code := assignIssue(c, repo, issue, assignees, nil); code >= 0 {
273			return code
274		}
275	}
276	return c.emit(Created{Number: n}, func(w io.Writer) {
277		fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
278	})
279}
280
281func runIssueList(c *Ctx, args []string) int {
282	args, p, code := parsePageFlags(c, args, "issue", true)
283	if code >= 0 {
284		return code
285	}
286	f := store.IssueFilter{State: "open"}
287	fl, err := c.parseArgs(args, flagSpec{Values: []string{"--state", "--label", "--assignee", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage})
288	if err != nil {
289		return c.fail(protocol.ExitUsage, "%v", err)
290	}
291	path := fl.pos(0)
292	if fl.Has("--state") {
293		f.State = fl.Value("--state")
294	}
295	f.Label, f.Assignee, f.Author, f.Milestone = fl.Value("--label"), fl.Value("--assignee"), fl.Value("--author"), fl.Value("--milestone")
296	f.Search = fl.Value("--search")
297	if fl.Has("--search") {
298		if err := validQuery(f.Search); err != nil {
299			return c.failInput(err)
300		}
301	}
302	if path == "" || (f.State != "open" && f.State != "closed" && f.State != "all") {
303		return c.usage()
304	}
305	repo, code := resolveRepo(c, path, policy.CanRead)
306	if code >= 0 {
307		return code
308	}
309	f.Limit, f.Before = p.queryLimit(), p.keyInt()
310	issues, err := c.Store.QueryIssues(repo.ID, f)
311	if err != nil {
312		return c.fail(protocol.ExitFailure, "%v", err)
313	}
314	issues, next := trimPage(p, issues, "issue", func(i store.Issue) string {
315		return strconv.FormatInt(i.Number, 10)
316	})
317	var ds []issueOut
318	for _, i := range issues {
319		ds = append(ds, issueToOut(i, false))
320	}
321	return c.emitPage(p, ds, next, func(w io.Writer) {
322		tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR")
323		for _, d := range ds {
324			tb.row(cRef(fmt.Sprintf("#%d", d.Number)), cState(d.State), cFlex(d.Title), cText(d.Author))
325		}
326		tb.flush()
327	})
328}
329
330func runIssueShow(c *Ctx, args []string) int {
331	repo, issue, code := issueRef(c, args, policy.CanRead)
332	if code >= 0 {
333		return code
334	}
335	if len(args) != 2 {
336		return c.usage()
337	}
338	comments, err := c.Store.ListIssueComments(issue.ID)
339	if err != nil {
340		return c.fail(protocol.ExitFailure, "%v", err)
341	}
342	var cs []commentOut
343	for _, cm := range comments {
344		cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind})
345	}
346	d := IssueShow{issueOut: issueToOut(issue, true), Comments: cs}
347	return c.emit(d, func(w io.Writer) {
348		v := c.view(w)
349		v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State)
350		v.fields(
351			"author", d.Author+", "+c.when(d.CreatedAt),
352			"assignees", strings.Join(d.Assignees, ", "),
353			"labels", strings.Join(d.Labels, ", "),
354			"milestone", d.Milestone,
355			"url", c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10)),
356		)
357		v.body(d.Body, d.BodyFormat)
358		events := false
359		for _, cm := range cs {
360			if cm.Kind != "system" {
361				continue
362			}
363			if !events {
364				io.WriteString(w, "\n")
365				events = true
366			}
367			v.event(cm.Body, cm.BodyFormat, cm.CreatedAt)
368		}
369		for _, cm := range cs {
370			if cm.Kind == "system" {
371				continue
372			}
373			v.comment(cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat)
374		}
375	})
376}
377
378func runIssueComment(c *Ctx, args []string) int {
379	return runComment(c, args, issueThread, "issue",
380		func(rest []string) (store.Repo, int64, int64, string, int) {
381			repo, issue, code := issueRef(c, rest, policy.CanRead)
382			return repo, issue.ID, issue.Number, issue.Title, code
383		},
384		c.Store.AddIssueComment, c.Store.IssueParticipants)
385}
386
387func setIssueState(c *Ctx, args []string, state string) int {
388	// Author may close/reopen their own issue; otherwise write access.
389	repo, issue, code := issueRef(c, args, policy.CanRead)
390	if code >= 0 {
391		return code
392	}
393	if code := refuseArchived(c, repo); code >= 0 {
394		return code
395	}
396	if len(args) != 2 {
397		return c.usage()
398	}
399	if code := authorOrWrite(c, repo, issue.Author, map[string]string{"open": "reopen", "closed": "close"}[state]+" this issue"); code >= 0 {
400		return code
401	}
402	if issue.State == state {
403		return c.fail(protocol.ExitUsage, "issue #%d is already %s", issue.Number, state)
404	}
405	if err := c.Store.SetIssueState(issue.ID, state); err != nil {
406		return c.fail(protocol.ExitFailure, "%v", err)
407	}
408	c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number))
409	if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
410		verb := map[string]string{"open": "reopened", "closed": "closed"}[state]
411		notify(c, parts, notice{repo: repo, kind: "issue",
412			subject: issueSubject(repo, issue.Number, issue.Title),
413			action:  fmt.Sprintf("%s #%d", verb, issue.Number),
414			path:    fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
415	}
416	return c.emit(map[string]any{"number": issue.Number, "state": state}, func(w io.Writer) {
417		fmt.Fprintf(w, "%s#%d is now %s\n", repo.Path(), issue.Number, state)
418	})
419}
420
421// editText parses --title/--body/--file -/--format and authorizes: author or
422// write. A nil format means the stored markup format stays as it is. extra
423// names further value flags a caller wants (mr edit's --superseded-by):
424// they are accepted and reported in the returned flags, and count toward
425// "at least one edit was given" alongside title/body/format.
426func editText(c *Ctx, args []string, kind string, extra ...string) (rest []string, title, body, format *string, f flags, code int) {
427	f, err := c.parseArgs(args, flagSpec{Values: append([]string{"--title", "--body", "--file", "--format"}, extra...), MaxPos: -1,
428		Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"})
429	if err != nil {
430		return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "%v", err)
431	}
432	rest = f.Pos
433	titleV, bodyV, file, formatV := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
434	haveTitle, haveBody := f.Has("--title"), f.Has("--body")
435	if file != "" {
436		b, err := bodyFrom(c, "", file)
437		if err != nil {
438			return nil, nil, nil, nil, flags{}, c.failInput(err)
439		}
440		bodyV, haveBody = b, true
441	}
442	fmtName, err := markupFormat(formatV)
443	if err != nil {
444		return nil, nil, nil, nil, flags{}, c.failInput(err)
445	}
446	anyExtra := false
447	for _, e := range extra {
448		if f.Has(e) {
449			anyExtra = true
450			break
451		}
452	}
453	if !haveTitle && !haveBody && fmtName == "" && !anyExtra {
454		return nil, nil, nil, nil, flags{}, c.usage()
455	}
456	if haveTitle {
457		if strings.TrimSpace(titleV) == "" {
458			return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "--title must not be empty")
459		}
460		title = &titleV
461	}
462	if haveBody {
463		body = &bodyV
464	}
465	if fmtName != "" {
466		format = &fmtName
467	}
468	return rest, title, body, format, f, -1
469}
470
471func runIssueEdit(c *Ctx, args []string) int {
472	rest, title, body, format, _, code := editText(c, args, "issue")
473	if code >= 0 {
474		return code
475	}
476	repo, issue, code := issueRef(c, rest, policy.CanRead)
477	if code >= 0 {
478		return code
479	}
480	if code := refuseArchived(c, repo); code >= 0 {
481		return code
482	}
483	if code := authorOrWrite(c, repo, issue.Author, "edit this issue"); code >= 0 {
484		return code
485	}
486	if err := c.Store.UpdateIssueText(issue.ID, title, body, format); err != nil {
487		return c.fail(protocol.ExitFailure, "%v", err)
488	}
489	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.edited", fmt.Sprintf(`{"number":%d}`, issue.Number))
490	if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
491		notify(c, parts, notice{repo: repo, kind: "issue",
492			subject: issueSubject(repo, issue.Number, issue.Title),
493			action:  fmt.Sprintf("edited #%d", issue.Number),
494			path:    fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
495	}
496	return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
497		fmt.Fprintf(w, "edited %s#%d\n", repo.Path(), issue.Number)
498	})
499}
500
501func runIssueClose(c *Ctx, args []string) int  { return setIssueState(c, args, "closed") }
502func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") }
503
504// addRemoveFlags parses repeated --add/--remove flags.
505func addRemoveFlags(args []string) (rest, adds, removes []string, err error) {
506	f, err := parseFlags(args, flagSpec{Multi: []string{"--add", "--remove"}, MaxPos: -1})
507	if err != nil {
508		return nil, nil, nil, err
509	}
510	rest, adds, removes = f.Pos, f.List("--add"), f.List("--remove")
511	return rest, adds, removes, nil
512}
513
514func runIssueLabel(c *Ctx, args []string) int {
515	rest, adds, removes, err := addRemoveFlags(args)
516	if err != nil {
517		return c.failInput(err)
518	}
519	if len(adds)+len(removes) == 0 {
520		return c.usage()
521	}
522	repo, issue, code := issueRef(c, rest, policy.CanWrite)
523	if code >= 0 {
524		return code
525	}
526	if code := refuseArchived(c, repo); code >= 0 {
527		return code
528	}
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) {
542	for _, l := range adds {
543		if err := c.Store.SetIssueLabel(repo, issue.ID, l, true); err != nil {
544			return nil, c.fail(protocol.ExitFailure, "%v", err)
545		}
546	}
547	for _, l := range removes {
548		if err := c.Store.SetIssueLabel(repo, issue.ID, l, false); err != nil {
549			if errors.Is(err, store.ErrNotFound) {
550				return nil, c.fail(protocol.ExitNotFound, "%v", err)
551			}
552			return nil, c.fail(protocol.ExitFailure, "%v", err)
553		}
554	}
555	updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
556	if err != nil {
557		return nil, c.fail(protocol.ExitFailure, "%v", err)
558	}
559	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.labeled",
560		fmt.Sprintf(`{"number":%d,"labels":%s}`, issue.Number, jsonStrings(updated.Labels)))
561	return updated.Labels, -1
562}
563
564func runIssueAssign(c *Ctx, args []string) int {
565	rest, adds, removes, err := addRemoveFlags(args)
566	if err != nil {
567		return c.failInput(err)
568	}
569	if len(adds)+len(removes) == 0 {
570		return c.usage()
571	}
572	repo, issue, code := issueRef(c, rest, policy.CanWrite)
573	if code >= 0 {
574		return code
575	}
576	if code := refuseArchived(c, repo); code >= 0 {
577		return code
578	}
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 {
601		u, err := c.Store.UserByUsername(name)
602		if errors.Is(err, store.ErrNotFound) {
603			return nil, c.fail(protocol.ExitNotFound, "no such user %q", name)
604		}
605		if err != nil {
606			return nil, c.fail(protocol.ExitFailure, "%v", err)
607		}
608		users = append(users, u)
609	}
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) {
617	// issue is the read from before the update, so its Assignees are who
618	// was already on it. SetIssueAssignee inserts ON CONFLICT DO NOTHING
619	// and returns nil whether or not it inserted, and the notice below is
620	// for accounts newly added: a client reconciling the list by
621	// re-sending the whole set must not notify on every save.
622	assigned := make(map[string]bool, len(issue.Assignees))
623	for _, name := range issue.Assignees {
624		assigned[name] = true
625	}
626	var added []int64
627	for _, u := range adds {
628		if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil {
629			return nil, c.fail(protocol.ExitFailure, "%v", err)
630		}
631		if assigned[u.Username] {
632			continue
633		}
634		assigned[u.Username] = true
635		added = append(added, u.ID)
636	}
637	for _, u := range removes {
638		if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil {
639			if errors.Is(err, store.ErrNotFound) {
640				return nil, c.fail(protocol.ExitNotFound, "%s is not assigned", u.Username)
641			}
642			return nil, c.fail(protocol.ExitFailure, "%v", err)
643		}
644	}
645	updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
646	if err != nil {
647		return nil, c.fail(protocol.ExitFailure, "%v", err)
648	}
649	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.assigned",
650		fmt.Sprintf(`{"number":%d,"assignees":%s}`, issue.Number, jsonStrings(updated.Assignees)))
651	if len(added) > 0 {
652		// direct, as a mention is: an assignment is addressed to someone,
653		// and widening it to watchers would tell them "assigned you".
654		// Removals file nothing, and notify drops the actor, so assigning
655		// yourself is silent.
656		notify(c, added, notice{repo: repo, kind: "issue", direct: true,
657			subject: issueSubject(repo, issue.Number, issue.Title),
658			action:  fmt.Sprintf("assigned you to #%d", issue.Number),
659			path:    fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
660	}
661	return updated.Assignees, -1
662}