internal/control/issue.go

791 lines · 28821 bytes

  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}