internal/control/issue.go
580 lines · 20394 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: "add a 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: "add or remove 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: "add or remove 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 := c.parseArgs(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 := c.parseArgs(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 := c.parseArgs(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}