internal/control/issue.go

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

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