internal/control/issue.go

719 lines · 26104 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
 17// MaxCommentBytes is the most of a comment body a command reads.
 18const MaxCommentBytes = maxBodyBytes
 19
 20func init() {
 21	register(Command{Path: []string{"issue", "create"},
 22		Summary: "open an issue",
 23		Usage:   "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org] [--label <l>]... [--milestone <title>] [--assignee <user>]...",
 24		Flags: []Flag{
 25			{"--title", "<t>", "the issue's title", ""},
 26			{"--body", "<b>", "the issue's body", ""},
 27			{"--file", "-", "read the body from stdin", ""},
 28			{"--format", "md|org", "the body's markup", "md"},
 29			{"--label", "<l>", "label to add, may repeat", ""},
 30			{"--milestone", "<title>", "milestone to set", ""},
 31			{"--assignee", "<user>", "user to assign, may repeat", ""},
 32		},
 33		Examples: []string{
 34			`issue create krz/gitbay --title "crash on empty repo" --body "steps to reproduce..."`,
 35			"issue create krz/gitbay --title notes --file - < notes.md",
 36			"issue create krz/gitbay --title bug --label bug --label priority --milestone v1 --assignee cmc",
 37		},
 38		ReadsStdin: true, Run: runIssueCreate})
 39	register(Command{Path: []string{"issue", "list"},
 40		Summary: "list issues",
 41		Usage:   "issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>] [--author <user>] [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>] | issue list --query <name> | --q <query> [--limit <n>] [--cursor <c>]",
 42		Flags: []Flag{
 43			{"--state", "open|closed|all", "which issues", "open"},
 44			{"--label", "<l>", "only issues carrying this label", ""},
 45			{"--assignee", "<user>", "only issues assigned to this user", ""},
 46			{"--author", "<user>", "only issues opened by this user", ""},
 47			{"--milestone", "<title>|none", "only issues in this milestone, or in none", ""},
 48			{"--search", "<text>", "match title and body", ""},
 49			{"--query", "<name>", "a saved query, across repositories, in place of a repository and filters", ""},
 50			{"--q", "<query>", "a query written out, as query save takes it", ""},
 51			{"--limit", "<n>", "rows per page", ""},
 52			{"--cursor", "<c>", "continue from the previous page", ""},
 53		},
 54		Examples: []string{
 55			"issue list krz/gitbay --label bug --state all",
 56			"issue list krz/gitbay --assignee cmc",
 57			"issue list --query mine",
 58			`issue list --q "owner:krz is:open assignee:@me"`,
 59		},
 60		ReadOnly: true, Run: runIssueList})
 61	register(Command{Path: []string{"issue", "show"},
 62		Summary:  "show an issue with comments",
 63		Usage:    "issue show <owner/name> <n>",
 64		Examples: []string{"issue show krz/gitbay 42"},
 65		ReadOnly: true, Run: runIssueShow})
 66	register(Command{Path: []string{"issue", "edit"},
 67		Summary: "edit title or body",
 68		Usage:   "issue edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]",
 69		Flags: []Flag{
 70			{"--title", "<t>", "the issue's new title", ""},
 71			{"--body", "<b>", "the issue's new body", ""},
 72			{"--file", "-", "read the new body from stdin", ""},
 73			{"--format", "md|org", "the body's markup", ""},
 74		},
 75		Examples: []string{
 76			`issue edit krz/gitbay 42 --title "crash on empty repo, take two"`,
 77			"issue edit krz/gitbay 42 --file - < notes.md",
 78		},
 79		ReadsStdin: true, Run: runIssueEdit})
 80	register(Command{Path: []string{"issue", "comment"},
 81		Summary: "add a comment",
 82		Usage:   "issue comment <owner/name> <n> [--message <m> | --file -] [--format md|org]",
 83		Flags: []Flag{
 84			{"--message", "<m>", "the comment's text", ""},
 85			{"--file", "-", "read the comment from stdin", ""},
 86			{"--format", "md|org", "the comment's markup", "md"},
 87		},
 88		Examples: []string{
 89			`issue comment krz/gitbay 42 --message "can't reproduce on main"`,
 90			"issue comment krz/gitbay 42 --file - < notes.md",
 91		},
 92		ReadsStdin: true, Run: runIssueComment})
 93	register(Command{Path: []string{"issue", "close"},
 94		Summary:  "close an issue",
 95		Usage:    "issue close <owner/name> <n>",
 96		Examples: []string{"issue close krz/gitbay 42"},
 97		Run:      runIssueClose})
 98	register(Command{Path: []string{"issue", "reopen"},
 99		Summary:  "reopen an issue",
100		Usage:    "issue reopen <owner/name> <n>",
101		Examples: []string{"issue reopen krz/gitbay 42"},
102		Run:      runIssueReopen})
103	register(Command{Path: []string{"issue", "label"},
104		Summary: "add or remove labels",
105		Usage:   "issue label <owner/name> <n> [--add <l>]... [--remove <l>]...",
106		Flags: []Flag{
107			{"--add", "<l>", "label to add, may repeat", ""},
108			{"--remove", "<l>", "label to remove, may repeat", ""},
109		},
110		Examples: []string{"issue label krz/gitbay 42 --add bug --remove needs-triage"},
111		Run:      runIssueLabel})
112	register(Command{Path: []string{"issue", "assign"},
113		Summary: "add or remove assignees",
114		Usage:   "issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...",
115		Flags: []Flag{
116			{"--add", "<user>", "user to assign, may repeat", ""},
117			{"--remove", "<user>", "user to unassign, may repeat", ""},
118		},
119		Examples: []string{"issue assign krz/gitbay 42 --add cmc"},
120		Run:      runIssueAssign})
121}
122
123// issueArgs parses "<owner/name> <n>" plus flags handled by the caller.
124func issueRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.Issue, int) {
125	repo, n, code := refArgs(c, args, perm, "issue")
126	if code >= 0 {
127		return repo, store.Issue{}, code
128	}
129	issue, err := c.Store.IssueByNumber(repo.ID, n)
130	if errors.Is(err, store.ErrNotFound) {
131		return repo, issue, c.fail(protocol.ExitNotFound, "issue #%d not found in %s", n, repo.Path())
132	}
133	if err != nil {
134		return repo, issue, c.fail(protocol.ExitFailure, "%v", err)
135	}
136	return repo, issue, -1
137}
138
139// bodyFrom resolves --body/--message inline text or --file - (stdin).
140func bodyFrom(c *Ctx, inline, file string) (string, error) {
141	if inline != "" && file != "" {
142		return "", errors.New("give either an inline message or --file -, not both")
143	}
144	if file != "" {
145		if file != "-" {
146			return "", errors.New("--file only supports - (stdin) over ssh")
147		}
148		raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxBodyBytes))
149		return string(raw), err
150	}
151	return inline, nil
152}
153
154// markupFormat normalizes a --format value. Empty means the caller did not ask,
155// which the caller turns into "md" on create or "unchanged" on edit.
156func markupFormat(v string) (string, error) {
157	switch strings.ToLower(strings.TrimSpace(v)) {
158	case "":
159		return "", nil
160	case "md", "markdown":
161		return "md", nil
162	case "org", "org-mode":
163		return "org", nil
164	}
165	return "", fmt.Errorf("unknown --format %q (want md or org)", v)
166}
167
168type issueOut struct {
169	Number     int64    `json:"number"`
170	Title      string   `json:"title"`
171	State      string   `json:"state"`
172	Author     string   `json:"author"`
173	Milestone  string   `json:"milestone,omitempty"`
174	Labels     []string `json:"labels,omitempty"`
175	Assignees  []string `json:"assignees,omitempty"`
176	Body       string   `json:"body,omitempty"`
177	BodyFormat string   `json:"body_format,omitempty"`
178	CreatedAt  string   `json:"created_at"`
179}
180
181func issueToOut(i store.Issue, withBody bool) issueOut {
182	o := issueOut{Number: i.Number, Title: i.Title, State: i.State, Author: i.Author,
183		Milestone: i.Milestone, Labels: i.Labels, Assignees: i.Assignees, CreatedAt: i.CreatedAt}
184	if withBody {
185		o.Body = i.Body
186		o.BodyFormat = i.BodyFormat
187	}
188	return o
189}
190
191// runIssueCreate opens an issue. The CLI opens $EDITOR for the body
192// when neither --body nor --file is given (cmd/gitbay's issueCmd,
193// editor: "issue"); over stock ssh the body must be one of the two.
194func runIssueCreate(c *Ctx, args []string) int {
195	f, err := c.parseArgs(args, flagSpec{
196		Values: []string{"--format", "--title", "--body", "--file", "--milestone"},
197		Multi:  []string{"--label", "--assignee"},
198		MaxPos: 1,
199		Usage:  "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org] [--label <l>]... [--milestone <title>] [--assignee <user>]..."})
200	if err != nil {
201		return c.fail(protocol.ExitUsage, "%v", err)
202	}
203	path, title, body, file, format := f.pos(0), f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
204	if path == "" || title == "" {
205		return c.usage()
206	}
207	fmtName, err := markupFormat(format)
208	if err != nil {
209		return c.failInput(err)
210	}
211	if fmtName == "" {
212		fmtName = "md"
213	}
214	// Anyone who can read the repo can file an issue.
215	repo, code := resolveRepo(c, path, policy.CanRead)
216	if code >= 0 {
217		return code
218	}
219	if code := refuseArchived(c, repo); code >= 0 {
220		return code
221	}
222	// Filing an issue only needs read access; setting a label, milestone
223	// or assignee on it needs the same write access issue label/issue
224	// milestone/issue assign require.
225	if len(f.List("--label")) > 0 || f.Value("--milestone") != "" || len(f.List("--assignee")) > 0 {
226		grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
227		if err != nil {
228			return c.fail(protocol.ExitFailure, "checking access: %v", err)
229		}
230		if !policy.CanWrite(c.User, repo, grant) {
231			return c.fail(protocol.ExitDenied, "permission denied on %s; ask its owner for access", path)
232		}
233	}
234	// Resolve everything that can be refused before the issue exists, so
235	// a typo in a milestone or an assignee creates nothing.
236	var milestone store.Milestone
237	if m := f.Value("--milestone"); m != "" {
238		if milestone, err = c.Store.MilestoneByTitle(repo, m); err != nil {
239			return milestoneErr(c, repo, m, err)
240		}
241	}
242	assignees, code := resolveUsers(c, f.List("--assignee"))
243	if code >= 0 {
244		return code
245	}
246	b, err := bodyFrom(c, body, file)
247	if err != nil {
248		return c.failInput(err)
249	}
250	n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b, fmtName)
251	if err != nil {
252		return c.fail(protocol.ExitFailure, "%v", err)
253	}
254	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
255	if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
256		notify(c, targets, notice{repo: repo, kind: "issue", number: n,
257			subject: issueSubject(repo, n, title),
258			action:  fmt.Sprintf("opened issue #%d", n),
259			excerpt: b, path: fmt.Sprintf("%s/issues/%d", repo.Path(), n)})
260	}
261	issue, err := c.Store.IssueByNumber(repo.ID, n)
262	if err != nil {
263		return c.fail(protocol.ExitFailure, "%v", err)
264	}
265	notifyMentions(c, repo, issueThread, issue.ID, n, title, b)
266	if labels := f.List("--label"); len(labels) > 0 {
267		if _, code := labelIssue(c, repo, issue, labels, nil); code >= 0 {
268			return code
269		}
270	}
271	if milestone.ID != 0 {
272		if err := recordItemMilestone(c, repo, "issue", n, milestone.ID, milestone.Title, func(id int64) error {
273			return c.Store.SetIssueMilestone(issue.ID, id)
274		}); err != nil {
275			return c.fail(protocol.ExitFailure, "%v", err)
276		}
277	}
278	if len(assignees) > 0 {
279		if _, code := assignIssue(c, repo, issue, assignees, nil); code >= 0 {
280			return code
281		}
282	}
283	return c.emit(Created{Number: n}, func(w io.Writer) {
284		fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
285		if c.Term.Cols > 0 {
286			fmt.Fprintln(w, c.siteURL(repo.Path(), "issues", strconv.FormatInt(n, 10)))
287		}
288	})
289}
290
291func runIssueList(c *Ctx, args []string) int {
292	if usesQuery(args) {
293		args, p, code := parsePageFlags(c, args, "query", false)
294		if code >= 0 {
295			return code
296		}
297		fl, err := c.parseArgs(args, flagSpec{Values: []string{"--query", "--q", "--state", "--label", "--assignee", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage})
298		if err != nil {
299			return c.fail(protocol.ExitUsage, "%v", err)
300		}
301		return listByQuery(c, fl, "issue", p)
302	}
303	args, p, code := parsePageFlags(c, args, "issue", true)
304	if code >= 0 {
305		return code
306	}
307	f := store.IssueFilter{State: "open"}
308	fl, err := c.parseArgs(args, flagSpec{Values: []string{"--state", "--label", "--assignee", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage})
309	if err != nil {
310		return c.fail(protocol.ExitUsage, "%v", err)
311	}
312	path := fl.pos(0)
313	if fl.Has("--state") {
314		f.State = fl.Value("--state")
315	}
316	f.Label, f.Assignee, f.Author, f.Milestone = fl.Value("--label"), fl.Value("--assignee"), fl.Value("--author"), fl.Value("--milestone")
317	f.Search = fl.Value("--search")
318	if fl.Has("--search") {
319		if err := validQuery(f.Search); err != nil {
320			return c.failInput(err)
321		}
322	}
323	if path == "" || (f.State != "open" && f.State != "closed" && f.State != "all") {
324		return c.usage()
325	}
326	repo, code := resolveRepo(c, path, policy.CanRead)
327	if code >= 0 {
328		return code
329	}
330	f.Limit, f.Before = p.queryLimit(), p.keyInt()
331	issues, err := c.Store.QueryIssues(repo.ID, f)
332	if err != nil {
333		return c.fail(protocol.ExitFailure, "%v", err)
334	}
335	issues, next := trimPage(p, issues, "issue", func(i store.Issue) string {
336		return strconv.FormatInt(i.Number, 10)
337	})
338	var ds []issueOut
339	for _, i := range issues {
340		ds = append(ds, issueToOut(i, false))
341	}
342	var comments map[int64]int
343	var labels, assignees map[int64][]string
344	if c.Term.Cols > 0 && !c.JSON {
345		ids := make([]int64, len(issues))
346		for i, is := range issues {
347			ids[i] = is.ID
348		}
349		if comments, err = c.Store.IssueCommentCounts(ids); err != nil {
350			return c.fail(protocol.ExitFailure, "%v", err)
351		}
352		if labels, assignees, err = c.Store.IssueLabelsFor(ids); err != nil {
353			return c.fail(protocol.ExitFailure, "%v", err)
354		}
355	}
356	return c.emitPage(p, ds, next, func(w io.Writer) {
357		if c.Term.Cols > 0 {
358			// At a terminal: who it waits on and how much it has moved,
359			// in place of who opened it.
360			tb := c.table(w, "#", "STATE", "TITLE", "LABELS", "ASSIGNEE", "COMMENTS", "UPDATED")
361			for i, d := range ds {
362				n := ""
363				if k := comments[issues[i].ID]; k > 0 {
364					n = strconv.Itoa(k)
365				}
366				tb.row(cLink(fmt.Sprintf("#%d", d.Number), c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10))), cState(d.State), cFlex(d.Title),
367					cText(labelsMark(labels[issues[i].ID])), assigneesMark(assignees[issues[i].ID], c.User.Username),
368					cText(n), cAge(issues[i].UpdatedAt))
369			}
370			tb.flush()
371			return
372		}
373		tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR")
374		for _, d := range ds {
375			tb.row(cRef(fmt.Sprintf("#%d", d.Number)), cState(d.State), cFlex(d.Title), cText(d.Author))
376		}
377		tb.flush()
378	})
379}
380
381func runIssueShow(c *Ctx, args []string) int {
382	repo, issue, code := issueRef(c, args, policy.CanRead)
383	if code >= 0 {
384		return code
385	}
386	if len(args) != 2 {
387		return c.usage()
388	}
389	comments, err := c.Store.ListIssueComments(issue.ID)
390	if err != nil {
391		return c.fail(protocol.ExitFailure, "%v", err)
392	}
393	rx, err := c.Store.ReactionCounts("issue", issue.ID, c.User.ID)
394	if err != nil {
395		return c.fail(protocol.ExitFailure, "%v", err)
396	}
397	var cs []commentOut
398	for _, cm := range comments {
399		cs = append(cs, commentOut{cm.ID, cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind, reactionsOut(rx[cm.ID])})
400	}
401	d := IssueShow{issueOut: issueToOut(issue, true), Reactions: reactionsOut(rx[0]), Comments: cs}
402	return c.emit(d, func(w io.Writer) {
403		v := c.view(w)
404		v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State)
405		v.fields(
406			"author", d.Author+", "+c.when(d.CreatedAt),
407			"assignees", strings.Join(d.Assignees, ", "),
408			"labels", strings.Join(d.Labels, ", "),
409			"milestone", d.Milestone,
410			"url", c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10)),
411		)
412		v.body(d.Body, d.BodyFormat)
413		v.reactions(d.Reactions)
414		events := false
415		for _, cm := range cs {
416			if cm.Kind != "system" {
417				continue
418			}
419			if !events {
420				io.WriteString(w, "\n")
421				events = true
422			}
423			v.event(cm.Body, cm.BodyFormat, cm.CreatedAt)
424		}
425		for _, cm := range cs {
426			if cm.Kind == "system" {
427				continue
428			}
429			v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat)
430			v.reactions(cm.Reactions)
431		}
432	})
433}
434
435func runIssueComment(c *Ctx, args []string) int {
436	return runComment(c, args, issueThread, "issue",
437		func(rest []string) (store.Repo, int64, int64, string, int) {
438			repo, issue, code := issueRef(c, rest, policy.CanRead)
439			return repo, issue.ID, issue.Number, issue.Title, code
440		},
441		c.Store.AddIssueComment, c.Store.IssueParticipants)
442}
443
444func setIssueState(c *Ctx, args []string, state string) int {
445	// Author may close/reopen their own issue; otherwise write access.
446	repo, issue, code := issueRef(c, args, policy.CanRead)
447	if code >= 0 {
448		return code
449	}
450	if code := refuseArchived(c, repo); code >= 0 {
451		return code
452	}
453	if len(args) != 2 {
454		return c.usage()
455	}
456	if code := authorOrWrite(c, repo, issue.Author, map[string]string{"open": "reopen", "closed": "close"}[state]+" this issue"); code >= 0 {
457		return code
458	}
459	if issue.State == state {
460		return c.fail(protocol.ExitUsage, "issue #%d is already %s", issue.Number, state)
461	}
462	if err := c.Store.SetIssueState(issue.ID, state); err != nil {
463		return c.fail(protocol.ExitFailure, "%v", err)
464	}
465	c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number))
466	if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
467		verb := map[string]string{"open": "reopened", "closed": "closed"}[state]
468		notify(c, parts, notice{repo: repo, kind: "issue", number: issue.Number,
469			subject: issueSubject(repo, issue.Number, issue.Title),
470			action:  fmt.Sprintf("%s #%d", verb, issue.Number),
471			path:    fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
472	}
473	return c.emit(map[string]any{"number": issue.Number, "state": state}, func(w io.Writer) {
474		fmt.Fprintf(w, "%s#%d is now %s\n", repo.Path(), issue.Number, state)
475	})
476}
477
478// editText parses --title/--body/--file -/--format and authorizes: author or
479// write. A nil format means the stored markup format stays as it is. extra
480// names further value flags a caller wants (mr edit's --superseded-by):
481// they are accepted and reported in the returned flags, and count toward
482// "at least one edit was given" alongside title/body/format.
483func editText(c *Ctx, args []string, kind string, extra ...string) (rest []string, title, body, format *string, f flags, code int) {
484	f, err := c.parseArgs(args, flagSpec{Values: append([]string{"--title", "--body", "--file", "--format"}, extra...), MaxPos: -1,
485		Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"})
486	if err != nil {
487		return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "%v", err)
488	}
489	rest = f.Pos
490	titleV, bodyV, file, formatV := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
491	haveTitle, haveBody := f.Has("--title"), f.Has("--body")
492	if file != "" {
493		b, err := bodyFrom(c, "", file)
494		if err != nil {
495			return nil, nil, nil, nil, flags{}, c.failInput(err)
496		}
497		bodyV, haveBody = b, true
498	}
499	fmtName, err := markupFormat(formatV)
500	if err != nil {
501		return nil, nil, nil, nil, flags{}, c.failInput(err)
502	}
503	anyExtra := false
504	for _, e := range extra {
505		if f.Has(e) {
506			anyExtra = true
507			break
508		}
509	}
510	if !haveTitle && !haveBody && fmtName == "" && !anyExtra {
511		return nil, nil, nil, nil, flags{}, c.usage()
512	}
513	if haveTitle {
514		if strings.TrimSpace(titleV) == "" {
515			return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "--title must not be empty")
516		}
517		title = &titleV
518	}
519	if haveBody {
520		body = &bodyV
521	}
522	if fmtName != "" {
523		format = &fmtName
524	}
525	return rest, title, body, format, f, -1
526}
527
528func runIssueEdit(c *Ctx, args []string) int {
529	rest, title, body, format, _, code := editText(c, args, "issue")
530	if code >= 0 {
531		return code
532	}
533	repo, issue, code := issueRef(c, rest, policy.CanRead)
534	if code >= 0 {
535		return code
536	}
537	if code := refuseArchived(c, repo); code >= 0 {
538		return code
539	}
540	if code := authorOrWrite(c, repo, issue.Author, "edit this issue"); code >= 0 {
541		return code
542	}
543	if err := c.Store.UpdateIssueText(issue.ID, title, body, format); err != nil {
544		return c.fail(protocol.ExitFailure, "%v", err)
545	}
546	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.edited", fmt.Sprintf(`{"number":%d}`, issue.Number))
547	if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
548		notify(c, parts, notice{repo: repo, kind: "issue", number: issue.Number,
549			subject: issueSubject(repo, issue.Number, issue.Title),
550			action:  fmt.Sprintf("edited #%d", issue.Number),
551			path:    fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
552	}
553	return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
554		fmt.Fprintf(w, "edited %s#%d\n", repo.Path(), issue.Number)
555	})
556}
557
558func runIssueClose(c *Ctx, args []string) int  { return setIssueState(c, args, "closed") }
559func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") }
560
561// addRemoveFlags parses repeated --add/--remove flags.
562func addRemoveFlags(args []string) (rest, adds, removes []string, err error) {
563	f, err := parseFlags(args, flagSpec{Multi: []string{"--add", "--remove"}, MaxPos: -1})
564	if err != nil {
565		return nil, nil, nil, err
566	}
567	rest, adds, removes = f.Pos, f.List("--add"), f.List("--remove")
568	return rest, adds, removes, nil
569}
570
571func runIssueLabel(c *Ctx, args []string) int {
572	rest, adds, removes, err := addRemoveFlags(args)
573	if err != nil {
574		return c.failInput(err)
575	}
576	if len(adds)+len(removes) == 0 {
577		return c.usage()
578	}
579	repo, issue, code := issueRef(c, rest, policy.CanWrite)
580	if code >= 0 {
581		return code
582	}
583	if code := refuseArchived(c, repo); code >= 0 {
584		return code
585	}
586	labels, code := labelIssue(c, repo, issue, adds, removes)
587	if code >= 0 {
588		return code
589	}
590	return c.emit(map[string]any{"number": issue.Number, "labels": labels}, func(w io.Writer) {
591		fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(labels, ", "))
592	})
593}
594
595// labelIssue adds and removes labels on issue and records the
596// issue.labeled event, returning the labels it carries afterwards. It
597// backs issue label and issue create --label.
598func labelIssue(c *Ctx, repo store.Repo, issue store.Issue, adds, removes []string) ([]string, int) {
599	for _, l := range adds {
600		if err := c.Store.SetIssueLabel(repo, issue.ID, l, true); err != nil {
601			return nil, c.fail(protocol.ExitFailure, "%v", err)
602		}
603	}
604	for _, l := range removes {
605		if err := c.Store.SetIssueLabel(repo, issue.ID, l, false); err != nil {
606			if errors.Is(err, store.ErrNotFound) {
607				return nil, c.fail(protocol.ExitNotFound, "%v", err)
608			}
609			return nil, c.fail(protocol.ExitFailure, "%v", err)
610		}
611	}
612	updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
613	if err != nil {
614		return nil, c.fail(protocol.ExitFailure, "%v", err)
615	}
616	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.labeled",
617		fmt.Sprintf(`{"number":%d,"labels":%s}`, issue.Number, jsonStrings(updated.Labels)))
618	return updated.Labels, -1
619}
620
621func runIssueAssign(c *Ctx, args []string) int {
622	rest, adds, removes, err := addRemoveFlags(args)
623	if err != nil {
624		return c.failInput(err)
625	}
626	if len(adds)+len(removes) == 0 {
627		return c.usage()
628	}
629	repo, issue, code := issueRef(c, rest, policy.CanWrite)
630	if code >= 0 {
631		return code
632	}
633	if code := refuseArchived(c, repo); code >= 0 {
634		return code
635	}
636	add, code := resolveUsers(c, adds)
637	if code >= 0 {
638		return code
639	}
640	remove, code := resolveUsers(c, removes)
641	if code >= 0 {
642		return code
643	}
644	assignees, code := assignIssue(c, repo, issue, add, remove)
645	if code >= 0 {
646		return code
647	}
648	return c.emit(map[string]any{"number": issue.Number, "assignees": assignees}, func(w io.Writer) {
649		fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(assignees, ", "))
650	})
651}
652
653// resolveUsers looks up every name, failing on the first that does not
654// exist, so a typo changes nothing.
655func resolveUsers(c *Ctx, names []string) ([]store.User, int) {
656	users := make([]store.User, 0, len(names))
657	for _, name := range names {
658		u, err := c.Store.UserByUsername(name)
659		if errors.Is(err, store.ErrNotFound) {
660			return nil, c.fail(protocol.ExitNotFound, "no such user %q", name)
661		}
662		if err != nil {
663			return nil, c.fail(protocol.ExitFailure, "%v", err)
664		}
665		users = append(users, u)
666	}
667	return users, -1
668}
669
670// assignIssue adds and removes assignees on issue, records the
671// issue.assigned event and tells each newly added account, returning the
672// assignees afterwards. It backs issue assign and issue create --assignee.
673func assignIssue(c *Ctx, repo store.Repo, issue store.Issue, adds, removes []store.User) ([]string, int) {
674	// issue is the read from before the update, so its Assignees are who
675	// was already on it. SetIssueAssignee inserts ON CONFLICT DO NOTHING
676	// and returns nil whether or not it inserted, and the notice below is
677	// for accounts newly added: a client reconciling the list by
678	// re-sending the whole set must not notify on every save.
679	assigned := make(map[string]bool, len(issue.Assignees))
680	for _, name := range issue.Assignees {
681		assigned[name] = true
682	}
683	var added []int64
684	for _, u := range adds {
685		if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil {
686			return nil, c.fail(protocol.ExitFailure, "%v", err)
687		}
688		if assigned[u.Username] {
689			continue
690		}
691		assigned[u.Username] = true
692		added = append(added, u.ID)
693	}
694	for _, u := range removes {
695		if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil {
696			if errors.Is(err, store.ErrNotFound) {
697				return nil, c.fail(protocol.ExitNotFound, "%s is not assigned", u.Username)
698			}
699			return nil, c.fail(protocol.ExitFailure, "%v", err)
700		}
701	}
702	updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
703	if err != nil {
704		return nil, c.fail(protocol.ExitFailure, "%v", err)
705	}
706	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.assigned",
707		fmt.Sprintf(`{"number":%d,"assignees":%s}`, issue.Number, jsonStrings(updated.Assignees)))
708	if len(added) > 0 {
709		// direct, as a mention is: an assignment is addressed to someone,
710		// and widening it to watchers would tell them "assigned you".
711		// Removals file nothing, and notify drops the actor, so assigning
712		// yourself is silent.
713		notify(c, added, notice{repo: repo, kind: "issue", number: issue.Number, direct: true,
714			subject: issueSubject(repo, issue.Number, issue.Title),
715			action:  fmt.Sprintf("assigned you to #%d", issue.Number),
716			path:    fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
717	}
718	return updated.Assignees, -1
719}