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