internal/control/issue.go
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}