internal/control/issue.go
686 lines · 24910 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 })
286}
287
288func runIssueList(c *Ctx, args []string) int {
289 if usesQuery(args) {
290 args, p, code := parsePageFlags(c, args, "query", false)
291 if code >= 0 {
292 return code
293 }
294 fl, err := c.parseArgs(args, flagSpec{Values: []string{"--query", "--q", "--state", "--label", "--assignee", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage})
295 if err != nil {
296 return c.fail(protocol.ExitUsage, "%v", err)
297 }
298 return listByQuery(c, fl, "issue", p)
299 }
300 args, p, code := parsePageFlags(c, args, "issue", true)
301 if code >= 0 {
302 return code
303 }
304 f := store.IssueFilter{State: "open"}
305 fl, err := c.parseArgs(args, flagSpec{Values: []string{"--state", "--label", "--assignee", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage})
306 if err != nil {
307 return c.fail(protocol.ExitUsage, "%v", err)
308 }
309 path := fl.pos(0)
310 if fl.Has("--state") {
311 f.State = fl.Value("--state")
312 }
313 f.Label, f.Assignee, f.Author, f.Milestone = fl.Value("--label"), fl.Value("--assignee"), fl.Value("--author"), fl.Value("--milestone")
314 f.Search = fl.Value("--search")
315 if fl.Has("--search") {
316 if err := validQuery(f.Search); err != nil {
317 return c.failInput(err)
318 }
319 }
320 if path == "" || (f.State != "open" && f.State != "closed" && f.State != "all") {
321 return c.usage()
322 }
323 repo, code := resolveRepo(c, path, policy.CanRead)
324 if code >= 0 {
325 return code
326 }
327 f.Limit, f.Before = p.queryLimit(), p.keyInt()
328 issues, err := c.Store.QueryIssues(repo.ID, f)
329 if err != nil {
330 return c.fail(protocol.ExitFailure, "%v", err)
331 }
332 issues, next := trimPage(p, issues, "issue", func(i store.Issue) string {
333 return strconv.FormatInt(i.Number, 10)
334 })
335 var ds []issueOut
336 for _, i := range issues {
337 ds = append(ds, issueToOut(i, false))
338 }
339 return c.emitPage(p, ds, next, func(w io.Writer) {
340 tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR")
341 for _, d := range ds {
342 tb.row(cRef(fmt.Sprintf("#%d", d.Number)), cState(d.State), cFlex(d.Title), cText(d.Author))
343 }
344 tb.flush()
345 })
346}
347
348func runIssueShow(c *Ctx, args []string) int {
349 repo, issue, code := issueRef(c, args, policy.CanRead)
350 if code >= 0 {
351 return code
352 }
353 if len(args) != 2 {
354 return c.usage()
355 }
356 comments, err := c.Store.ListIssueComments(issue.ID)
357 if err != nil {
358 return c.fail(protocol.ExitFailure, "%v", err)
359 }
360 rx, err := c.Store.ReactionCounts("issue", issue.ID, c.User.ID)
361 if err != nil {
362 return c.fail(protocol.ExitFailure, "%v", err)
363 }
364 var cs []commentOut
365 for _, cm := range comments {
366 cs = append(cs, commentOut{cm.ID, cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind, reactionsOut(rx[cm.ID])})
367 }
368 d := IssueShow{issueOut: issueToOut(issue, true), Reactions: reactionsOut(rx[0]), Comments: cs}
369 return c.emit(d, func(w io.Writer) {
370 v := c.view(w)
371 v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State)
372 v.fields(
373 "author", d.Author+", "+c.when(d.CreatedAt),
374 "assignees", strings.Join(d.Assignees, ", "),
375 "labels", strings.Join(d.Labels, ", "),
376 "milestone", d.Milestone,
377 "url", c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10)),
378 )
379 v.body(d.Body, d.BodyFormat)
380 v.reactions(d.Reactions)
381 events := false
382 for _, cm := range cs {
383 if cm.Kind != "system" {
384 continue
385 }
386 if !events {
387 io.WriteString(w, "\n")
388 events = true
389 }
390 v.event(cm.Body, cm.BodyFormat, cm.CreatedAt)
391 }
392 for _, cm := range cs {
393 if cm.Kind == "system" {
394 continue
395 }
396 v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat)
397 v.reactions(cm.Reactions)
398 }
399 })
400}
401
402func runIssueComment(c *Ctx, args []string) int {
403 return runComment(c, args, issueThread, "issue",
404 func(rest []string) (store.Repo, int64, int64, string, int) {
405 repo, issue, code := issueRef(c, rest, policy.CanRead)
406 return repo, issue.ID, issue.Number, issue.Title, code
407 },
408 c.Store.AddIssueComment, c.Store.IssueParticipants)
409}
410
411func setIssueState(c *Ctx, args []string, state string) int {
412 // Author may close/reopen their own issue; otherwise write access.
413 repo, issue, code := issueRef(c, args, policy.CanRead)
414 if code >= 0 {
415 return code
416 }
417 if code := refuseArchived(c, repo); code >= 0 {
418 return code
419 }
420 if len(args) != 2 {
421 return c.usage()
422 }
423 if code := authorOrWrite(c, repo, issue.Author, map[string]string{"open": "reopen", "closed": "close"}[state]+" this issue"); code >= 0 {
424 return code
425 }
426 if issue.State == state {
427 return c.fail(protocol.ExitUsage, "issue #%d is already %s", issue.Number, state)
428 }
429 if err := c.Store.SetIssueState(issue.ID, state); err != nil {
430 return c.fail(protocol.ExitFailure, "%v", err)
431 }
432 c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number))
433 if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
434 verb := map[string]string{"open": "reopened", "closed": "closed"}[state]
435 notify(c, parts, notice{repo: repo, kind: "issue", number: issue.Number,
436 subject: issueSubject(repo, issue.Number, issue.Title),
437 action: fmt.Sprintf("%s #%d", verb, issue.Number),
438 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
439 }
440 return c.emit(map[string]any{"number": issue.Number, "state": state}, func(w io.Writer) {
441 fmt.Fprintf(w, "%s#%d is now %s\n", repo.Path(), issue.Number, state)
442 })
443}
444
445// editText parses --title/--body/--file -/--format and authorizes: author or
446// write. A nil format means the stored markup format stays as it is. extra
447// names further value flags a caller wants (mr edit's --superseded-by):
448// they are accepted and reported in the returned flags, and count toward
449// "at least one edit was given" alongside title/body/format.
450func editText(c *Ctx, args []string, kind string, extra ...string) (rest []string, title, body, format *string, f flags, code int) {
451 f, err := c.parseArgs(args, flagSpec{Values: append([]string{"--title", "--body", "--file", "--format"}, extra...), MaxPos: -1,
452 Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"})
453 if err != nil {
454 return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "%v", err)
455 }
456 rest = f.Pos
457 titleV, bodyV, file, formatV := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
458 haveTitle, haveBody := f.Has("--title"), f.Has("--body")
459 if file != "" {
460 b, err := bodyFrom(c, "", file)
461 if err != nil {
462 return nil, nil, nil, nil, flags{}, c.failInput(err)
463 }
464 bodyV, haveBody = b, true
465 }
466 fmtName, err := markupFormat(formatV)
467 if err != nil {
468 return nil, nil, nil, nil, flags{}, c.failInput(err)
469 }
470 anyExtra := false
471 for _, e := range extra {
472 if f.Has(e) {
473 anyExtra = true
474 break
475 }
476 }
477 if !haveTitle && !haveBody && fmtName == "" && !anyExtra {
478 return nil, nil, nil, nil, flags{}, c.usage()
479 }
480 if haveTitle {
481 if strings.TrimSpace(titleV) == "" {
482 return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "--title must not be empty")
483 }
484 title = &titleV
485 }
486 if haveBody {
487 body = &bodyV
488 }
489 if fmtName != "" {
490 format = &fmtName
491 }
492 return rest, title, body, format, f, -1
493}
494
495func runIssueEdit(c *Ctx, args []string) int {
496 rest, title, body, format, _, code := editText(c, args, "issue")
497 if code >= 0 {
498 return code
499 }
500 repo, issue, code := issueRef(c, rest, policy.CanRead)
501 if code >= 0 {
502 return code
503 }
504 if code := refuseArchived(c, repo); code >= 0 {
505 return code
506 }
507 if code := authorOrWrite(c, repo, issue.Author, "edit this issue"); code >= 0 {
508 return code
509 }
510 if err := c.Store.UpdateIssueText(issue.ID, title, body, format); err != nil {
511 return c.fail(protocol.ExitFailure, "%v", err)
512 }
513 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.edited", fmt.Sprintf(`{"number":%d}`, issue.Number))
514 if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
515 notify(c, parts, notice{repo: repo, kind: "issue", number: issue.Number,
516 subject: issueSubject(repo, issue.Number, issue.Title),
517 action: fmt.Sprintf("edited #%d", issue.Number),
518 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
519 }
520 return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
521 fmt.Fprintf(w, "edited %s#%d\n", repo.Path(), issue.Number)
522 })
523}
524
525func runIssueClose(c *Ctx, args []string) int { return setIssueState(c, args, "closed") }
526func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") }
527
528// addRemoveFlags parses repeated --add/--remove flags.
529func addRemoveFlags(args []string) (rest, adds, removes []string, err error) {
530 f, err := parseFlags(args, flagSpec{Multi: []string{"--add", "--remove"}, MaxPos: -1})
531 if err != nil {
532 return nil, nil, nil, err
533 }
534 rest, adds, removes = f.Pos, f.List("--add"), f.List("--remove")
535 return rest, adds, removes, nil
536}
537
538func runIssueLabel(c *Ctx, args []string) int {
539 rest, adds, removes, err := addRemoveFlags(args)
540 if err != nil {
541 return c.failInput(err)
542 }
543 if len(adds)+len(removes) == 0 {
544 return c.usage()
545 }
546 repo, issue, code := issueRef(c, rest, policy.CanWrite)
547 if code >= 0 {
548 return code
549 }
550 if code := refuseArchived(c, repo); code >= 0 {
551 return code
552 }
553 labels, code := labelIssue(c, repo, issue, adds, removes)
554 if code >= 0 {
555 return code
556 }
557 return c.emit(map[string]any{"number": issue.Number, "labels": labels}, func(w io.Writer) {
558 fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(labels, ", "))
559 })
560}
561
562// labelIssue adds and removes labels on issue and records the
563// issue.labeled event, returning the labels it carries afterwards. It
564// backs issue label and issue create --label.
565func labelIssue(c *Ctx, repo store.Repo, issue store.Issue, adds, removes []string) ([]string, int) {
566 for _, l := range adds {
567 if err := c.Store.SetIssueLabel(repo, issue.ID, l, true); err != nil {
568 return nil, c.fail(protocol.ExitFailure, "%v", err)
569 }
570 }
571 for _, l := range removes {
572 if err := c.Store.SetIssueLabel(repo, issue.ID, l, false); err != nil {
573 if errors.Is(err, store.ErrNotFound) {
574 return nil, c.fail(protocol.ExitNotFound, "%v", err)
575 }
576 return nil, c.fail(protocol.ExitFailure, "%v", err)
577 }
578 }
579 updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
580 if err != nil {
581 return nil, c.fail(protocol.ExitFailure, "%v", err)
582 }
583 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.labeled",
584 fmt.Sprintf(`{"number":%d,"labels":%s}`, issue.Number, jsonStrings(updated.Labels)))
585 return updated.Labels, -1
586}
587
588func runIssueAssign(c *Ctx, args []string) int {
589 rest, adds, removes, err := addRemoveFlags(args)
590 if err != nil {
591 return c.failInput(err)
592 }
593 if len(adds)+len(removes) == 0 {
594 return c.usage()
595 }
596 repo, issue, code := issueRef(c, rest, policy.CanWrite)
597 if code >= 0 {
598 return code
599 }
600 if code := refuseArchived(c, repo); code >= 0 {
601 return code
602 }
603 add, code := resolveUsers(c, adds)
604 if code >= 0 {
605 return code
606 }
607 remove, code := resolveUsers(c, removes)
608 if code >= 0 {
609 return code
610 }
611 assignees, code := assignIssue(c, repo, issue, add, remove)
612 if code >= 0 {
613 return code
614 }
615 return c.emit(map[string]any{"number": issue.Number, "assignees": assignees}, func(w io.Writer) {
616 fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(assignees, ", "))
617 })
618}
619
620// resolveUsers looks up every name, failing on the first that does not
621// exist, so a typo changes nothing.
622func resolveUsers(c *Ctx, names []string) ([]store.User, int) {
623 users := make([]store.User, 0, len(names))
624 for _, name := range names {
625 u, err := c.Store.UserByUsername(name)
626 if errors.Is(err, store.ErrNotFound) {
627 return nil, c.fail(protocol.ExitNotFound, "no such user %q", name)
628 }
629 if err != nil {
630 return nil, c.fail(protocol.ExitFailure, "%v", err)
631 }
632 users = append(users, u)
633 }
634 return users, -1
635}
636
637// assignIssue adds and removes assignees on issue, records the
638// issue.assigned event and tells each newly added account, returning the
639// assignees afterwards. It backs issue assign and issue create --assignee.
640func assignIssue(c *Ctx, repo store.Repo, issue store.Issue, adds, removes []store.User) ([]string, int) {
641 // issue is the read from before the update, so its Assignees are who
642 // was already on it. SetIssueAssignee inserts ON CONFLICT DO NOTHING
643 // and returns nil whether or not it inserted, and the notice below is
644 // for accounts newly added: a client reconciling the list by
645 // re-sending the whole set must not notify on every save.
646 assigned := make(map[string]bool, len(issue.Assignees))
647 for _, name := range issue.Assignees {
648 assigned[name] = true
649 }
650 var added []int64
651 for _, u := range adds {
652 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil {
653 return nil, c.fail(protocol.ExitFailure, "%v", err)
654 }
655 if assigned[u.Username] {
656 continue
657 }
658 assigned[u.Username] = true
659 added = append(added, u.ID)
660 }
661 for _, u := range removes {
662 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil {
663 if errors.Is(err, store.ErrNotFound) {
664 return nil, c.fail(protocol.ExitNotFound, "%s is not assigned", u.Username)
665 }
666 return nil, c.fail(protocol.ExitFailure, "%v", err)
667 }
668 }
669 updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
670 if err != nil {
671 return nil, c.fail(protocol.ExitFailure, "%v", err)
672 }
673 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.assigned",
674 fmt.Sprintf(`{"number":%d,"assignees":%s}`, issue.Number, jsonStrings(updated.Assignees)))
675 if len(added) > 0 {
676 // direct, as a mention is: an assignment is addressed to someone,
677 // and widening it to watchers would tell them "assigned you".
678 // Removals file nothing, and notify drops the actor, so assigning
679 // yourself is silent.
680 notify(c, added, notice{repo: repo, kind: "issue", number: issue.Number, direct: true,
681 subject: issueSubject(repo, issue.Number, issue.Title),
682 action: fmt.Sprintf("assigned you to #%d", issue.Number),
683 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
684 }
685 return updated.Assignees, -1
686}