internal/control/issue.go

e9566eed86ebcd185c4b85f63d667b5e671fe787
gitbay/internal/control/issue.go history · blame · raw

503 lines · 17633 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"strconv"
  8	"strings"
  9
 10	"gitbay.org/gitbay/internal/policy"
 11	"gitbay.org/gitbay/internal/protocol"
 12	"gitbay.org/gitbay/internal/store"
 13)
 14
 15const maxBodyBytes = 64 << 10
 16
 17func init() {
 18	register(Command{Path: []string{"issue", "create"},
 19		Summary:    "open an issue",
 20		Usage:      "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]",
 21		ReadsStdin: true, Run: runIssueCreate})
 22	register(Command{Path: []string{"issue", "list"},
 23		Summary: "list issues",
 24		Usage:   "issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>] [--author <user>] [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runIssueList})
 25	register(Command{Path: []string{"issue", "show"},
 26		Summary: "show an issue with comments",
 27		Usage:   "issue show <owner/name> <n>", ReadOnly: true, Run: runIssueShow})
 28	register(Command{Path: []string{"issue", "edit"},
 29		Summary:    "edit title or body",
 30		Usage:      "issue edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]",
 31		ReadsStdin: true, Run: runIssueEdit})
 32	register(Command{Path: []string{"issue", "comment"},
 33		Summary:    "comment",
 34		Usage:      "issue comment <owner/name> <n> [--message <m> | --file -] [--format md|org]",
 35		ReadsStdin: true, Run: runIssueComment})
 36	register(Command{Path: []string{"issue", "close"},
 37		Summary: "close an issue",
 38		Usage:   "issue close <owner/name> <n>", Run: runIssueClose})
 39	register(Command{Path: []string{"issue", "reopen"},
 40		Summary: "reopen an issue",
 41		Usage:   "issue reopen <owner/name> <n>", Run: runIssueReopen})
 42	register(Command{Path: []string{"issue", "label"},
 43		Summary: "labels",
 44		Usage:   "issue label <owner/name> <n> [--add <l>]... [--remove <l>]...", Run: runIssueLabel})
 45	register(Command{Path: []string{"issue", "assign"},
 46		Summary: "assignees",
 47		Usage:   "issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...", Run: runIssueAssign})
 48}
 49
 50// issueArgs parses "<owner/name> <n>" plus flags handled by the caller.
 51func issueRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.Issue, int) {
 52	repo, n, code := refArgs(c, args, perm, "issue")
 53	if code >= 0 {
 54		return repo, store.Issue{}, code
 55	}
 56	issue, err := c.Store.IssueByNumber(repo.ID, n)
 57	if errors.Is(err, store.ErrNotFound) {
 58		return repo, issue, c.fail(protocol.ExitNotFound, "issue #%d not found in %s", n, repo.Path())
 59	}
 60	if err != nil {
 61		return repo, issue, c.fail(protocol.ExitFailure, "%v", err)
 62	}
 63	return repo, issue, -1
 64}
 65
 66// bodyFrom resolves --body/--message inline text or --file - (stdin).
 67func bodyFrom(c *Ctx, inline, file string) (string, error) {
 68	if inline != "" && file != "" {
 69		return "", errors.New("give either an inline message or --file -, not both")
 70	}
 71	if file != "" {
 72		if file != "-" {
 73			return "", errors.New("--file only supports - (stdin) over ssh")
 74		}
 75		raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxBodyBytes))
 76		return string(raw), err
 77	}
 78	return inline, nil
 79}
 80
 81// markupFormat normalizes a --format value. Empty means the caller did not ask,
 82// which the caller turns into "md" on create or "unchanged" on edit.
 83func markupFormat(v string) (string, error) {
 84	switch strings.ToLower(strings.TrimSpace(v)) {
 85	case "":
 86		return "", nil
 87	case "md", "markdown":
 88		return "md", nil
 89	case "org", "org-mode":
 90		return "org", nil
 91	}
 92	return "", fmt.Errorf("unknown --format %q (want md or org)", v)
 93}
 94
 95type issueOut struct {
 96	Number     int64    `json:"number"`
 97	Title      string   `json:"title"`
 98	State      string   `json:"state"`
 99	Author     string   `json:"author"`
100	Milestone  string   `json:"milestone,omitempty"`
101	Labels     []string `json:"labels,omitempty"`
102	Assignees  []string `json:"assignees,omitempty"`
103	Body       string   `json:"body,omitempty"`
104	BodyFormat string   `json:"body_format,omitempty"`
105	CreatedAt  string   `json:"created_at"`
106}
107
108func issueToOut(i store.Issue, withBody bool) issueOut {
109	o := issueOut{Number: i.Number, Title: i.Title, State: i.State, Author: i.Author,
110		Milestone: i.Milestone, Labels: i.Labels, Assignees: i.Assignees, CreatedAt: i.CreatedAt}
111	if withBody {
112		o.Body = i.Body
113		o.BodyFormat = i.BodyFormat
114	}
115	return o
116}
117
118func runIssueCreate(c *Ctx, args []string) int {
119	f, err := parseFlags(args, flagSpec{Values: []string{"--format", "--title", "--body", "--file"}, MaxPos: 1,
120		Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]"})
121	if err != nil {
122		return c.fail(protocol.ExitUsage, "%v", err)
123	}
124	path, title, body, file, format := f.pos(0), f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
125	if path == "" || title == "" {
126		return c.usage()
127	}
128	fmtName, err := markupFormat(format)
129	if err != nil {
130		return c.failInput(err)
131	}
132	if fmtName == "" {
133		fmtName = "md"
134	}
135	// Anyone who can read the repo can file an issue.
136	repo, code := resolveRepo(c, path, policy.CanRead)
137	if code >= 0 {
138		return code
139	}
140	if code := refuseArchived(c, repo); code >= 0 {
141		return code
142	}
143	b, err := bodyFrom(c, body, file)
144	if err != nil {
145		return c.failInput(err)
146	}
147	n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b, fmtName)
148	if err != nil {
149		return c.fail(protocol.ExitFailure, "%v", err)
150	}
151	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
152	if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
153		notify(c, targets, notice{repo: repo, kind: "issue",
154			subject: issueSubject(repo, n, title),
155			action:  fmt.Sprintf("opened issue #%d", n),
156			excerpt: b, path: fmt.Sprintf("%s/issues/%d", repo.Path(), n)})
157	}
158	if issue, err := c.Store.IssueByNumber(repo.ID, n); err == nil {
159		notifyMentions(c, repo, issueThread, issue.ID, n, title, b)
160	}
161	return c.emit(Created{Number: n}, func(w io.Writer) {
162		fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
163	})
164}
165
166func runIssueList(c *Ctx, args []string) int {
167	args, p, code := parsePageFlags(c, args, "issue", true)
168	if code >= 0 {
169		return code
170	}
171	f := store.IssueFilter{State: "open"}
172	fl, err := parseFlags(args, flagSpec{Values: []string{"--state", "--label", "--assignee", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage})
173	if err != nil {
174		return c.fail(protocol.ExitUsage, "%v", err)
175	}
176	path := fl.pos(0)
177	if fl.Has("--state") {
178		f.State = fl.Value("--state")
179	}
180	f.Label, f.Assignee, f.Author, f.Milestone = fl.Value("--label"), fl.Value("--assignee"), fl.Value("--author"), fl.Value("--milestone")
181	f.Search = fl.Value("--search")
182	if fl.Has("--search") {
183		if err := validQuery(f.Search); err != nil {
184			return c.failInput(err)
185		}
186	}
187	if path == "" || (f.State != "open" && f.State != "closed" && f.State != "all") {
188		return c.usage()
189	}
190	repo, code := resolveRepo(c, path, policy.CanRead)
191	if code >= 0 {
192		return code
193	}
194	f.Limit, f.Before = p.queryLimit(), p.keyInt()
195	issues, err := c.Store.QueryIssues(repo.ID, f)
196	if err != nil {
197		return c.fail(protocol.ExitFailure, "%v", err)
198	}
199	issues, next := trimPage(p, issues, "issue", func(i store.Issue) string {
200		return strconv.FormatInt(i.Number, 10)
201	})
202	var ds []issueOut
203	for _, i := range issues {
204		ds = append(ds, issueToOut(i, false))
205	}
206	return c.emitPage(p, ds, next, func(w io.Writer) {
207		for _, d := range ds {
208			fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", d.Number, d.State, d.Title, d.Author)
209		}
210	})
211}
212
213func runIssueShow(c *Ctx, args []string) int {
214	repo, issue, code := issueRef(c, args, policy.CanRead)
215	if code >= 0 {
216		return code
217	}
218	if len(args) != 2 {
219		return c.usage()
220	}
221	comments, err := c.Store.ListIssueComments(issue.ID)
222	if err != nil {
223		return c.fail(protocol.ExitFailure, "%v", err)
224	}
225	var cs []commentOut
226	for _, cm := range comments {
227		cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
228	}
229	d := IssueShow{issueOut: issueToOut(issue, true), Comments: cs}
230	_ = repo
231	return c.emit(d, func(w io.Writer) {
232		fmt.Fprintf(w, "#%d %s [%s] by %s\n", d.Number, d.Title, d.State, d.Author)
233		if len(d.Labels) > 0 {
234			fmt.Fprintf(w, "labels: %s\n", strings.Join(d.Labels, ", "))
235		}
236		if len(d.Assignees) > 0 {
237			fmt.Fprintf(w, "assignees: %s\n", strings.Join(d.Assignees, ", "))
238		}
239		if d.Body != "" {
240			fmt.Fprintf(w, "\n%s\n", d.Body)
241		}
242		for _, cm := range cs {
243			fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
244		}
245	})
246}
247
248func runIssueComment(c *Ctx, args []string) int {
249	return runComment(c, args, issueThread, "issue",
250		func(rest []string) (store.Repo, int64, int64, string, int) {
251			repo, issue, code := issueRef(c, rest, policy.CanRead)
252			return repo, issue.ID, issue.Number, issue.Title, code
253		},
254		c.Store.AddIssueComment, c.Store.IssueParticipants)
255}
256
257func setIssueState(c *Ctx, args []string, state string) int {
258	// Author may close/reopen their own issue; otherwise write access.
259	repo, issue, code := issueRef(c, args, policy.CanRead)
260	if code >= 0 {
261		return code
262	}
263	if code := refuseArchived(c, repo); code >= 0 {
264		return code
265	}
266	if len(args) != 2 {
267		return c.usage()
268	}
269	if code := authorOrWrite(c, repo, issue.Author, map[string]string{"open": "reopen", "closed": "close"}[state]+" this issue"); code >= 0 {
270		return code
271	}
272	if issue.State == state {
273		return c.fail(protocol.ExitUsage, "issue #%d is already %s", issue.Number, state)
274	}
275	if err := c.Store.SetIssueState(issue.ID, state); err != nil {
276		return c.fail(protocol.ExitFailure, "%v", err)
277	}
278	c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number))
279	if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
280		verb := map[string]string{"open": "reopened", "closed": "closed"}[state]
281		notify(c, parts, notice{repo: repo, kind: "issue",
282			subject: issueSubject(repo, issue.Number, issue.Title),
283			action:  fmt.Sprintf("%s #%d", verb, issue.Number),
284			path:    fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
285	}
286	return c.emit(map[string]any{"number": issue.Number, "state": state}, func(w io.Writer) {
287		fmt.Fprintf(w, "%s#%d is now %s\n", repo.Path(), issue.Number, state)
288	})
289}
290
291// editText parses --title/--body/--file -/--format and authorizes: author or
292// write. A nil format means the stored markup format stays as it is. extra
293// names further value flags a caller wants (mr edit's --superseded-by):
294// they are accepted and reported in the returned flags, and count toward
295// "at least one edit was given" alongside title/body/format.
296func editText(c *Ctx, args []string, kind string, extra ...string) (rest []string, title, body, format *string, f flags, code int) {
297	f, err := parseFlags(args, flagSpec{Values: append([]string{"--title", "--body", "--file", "--format"}, extra...), MaxPos: -1,
298		Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"})
299	if err != nil {
300		return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "%v", err)
301	}
302	rest = f.Pos
303	titleV, bodyV, file, formatV := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
304	haveTitle, haveBody := f.Has("--title"), f.Has("--body")
305	if file != "" {
306		b, err := bodyFrom(c, "", file)
307		if err != nil {
308			return nil, nil, nil, nil, flags{}, c.failInput(err)
309		}
310		bodyV, haveBody = b, true
311	}
312	fmtName, err := markupFormat(formatV)
313	if err != nil {
314		return nil, nil, nil, nil, flags{}, c.failInput(err)
315	}
316	anyExtra := false
317	for _, e := range extra {
318		if f.Has(e) {
319			anyExtra = true
320			break
321		}
322	}
323	if !haveTitle && !haveBody && fmtName == "" && !anyExtra {
324		return nil, nil, nil, nil, flags{}, c.usage()
325	}
326	if haveTitle {
327		if strings.TrimSpace(titleV) == "" {
328			return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "--title must not be empty")
329		}
330		title = &titleV
331	}
332	if haveBody {
333		body = &bodyV
334	}
335	if fmtName != "" {
336		format = &fmtName
337	}
338	return rest, title, body, format, f, -1
339}
340
341func runIssueEdit(c *Ctx, args []string) int {
342	rest, title, body, format, _, code := editText(c, args, "issue")
343	if code >= 0 {
344		return code
345	}
346	repo, issue, code := issueRef(c, rest, policy.CanRead)
347	if code >= 0 {
348		return code
349	}
350	if code := refuseArchived(c, repo); code >= 0 {
351		return code
352	}
353	if code := authorOrWrite(c, repo, issue.Author, "edit this issue"); code >= 0 {
354		return code
355	}
356	if err := c.Store.UpdateIssueText(issue.ID, title, body, format); err != nil {
357		return c.fail(protocol.ExitFailure, "%v", err)
358	}
359	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.edited", fmt.Sprintf(`{"number":%d}`, issue.Number))
360	if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
361		notify(c, parts, notice{repo: repo, kind: "issue",
362			subject: issueSubject(repo, issue.Number, issue.Title),
363			action:  fmt.Sprintf("edited #%d", issue.Number),
364			path:    fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
365	}
366	return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
367		fmt.Fprintf(w, "edited %s#%d\n", repo.Path(), issue.Number)
368	})
369}
370
371func runIssueClose(c *Ctx, args []string) int  { return setIssueState(c, args, "closed") }
372func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") }
373
374// addRemoveFlags parses repeated --add/--remove flags.
375func addRemoveFlags(args []string) (rest, adds, removes []string, err error) {
376	f, err := parseFlags(args, flagSpec{Multi: []string{"--add", "--remove"}, MaxPos: -1})
377	if err != nil {
378		return nil, nil, nil, err
379	}
380	rest, adds, removes = f.Pos, f.List("--add"), f.List("--remove")
381	return rest, adds, removes, nil
382}
383
384func runIssueLabel(c *Ctx, args []string) int {
385	rest, adds, removes, err := addRemoveFlags(args)
386	if err != nil {
387		return c.failInput(err)
388	}
389	if len(adds)+len(removes) == 0 {
390		return c.usage()
391	}
392	repo, issue, code := issueRef(c, rest, policy.CanWrite)
393	if code >= 0 {
394		return code
395	}
396	if code := refuseArchived(c, repo); code >= 0 {
397		return code
398	}
399	for _, l := range adds {
400		if err := c.Store.SetIssueLabel(repo, issue.ID, l, true); err != nil {
401			return c.fail(protocol.ExitFailure, "%v", err)
402		}
403	}
404	for _, l := range removes {
405		if err := c.Store.SetIssueLabel(repo, issue.ID, l, false); err != nil {
406			if errors.Is(err, store.ErrNotFound) {
407				return c.fail(protocol.ExitNotFound, "%v", err)
408			}
409			return c.fail(protocol.ExitFailure, "%v", err)
410		}
411	}
412	updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
413	if err != nil {
414		return c.fail(protocol.ExitFailure, "%v", err)
415	}
416	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.labeled",
417		fmt.Sprintf(`{"number":%d,"labels":%s}`, issue.Number, jsonStrings(updated.Labels)))
418	return c.emit(map[string]any{"number": issue.Number, "labels": updated.Labels}, func(w io.Writer) {
419		fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Labels, ", "))
420	})
421}
422
423func runIssueAssign(c *Ctx, args []string) int {
424	rest, adds, removes, err := addRemoveFlags(args)
425	if err != nil {
426		return c.failInput(err)
427	}
428	if len(adds)+len(removes) == 0 {
429		return c.usage()
430	}
431	repo, issue, code := issueRef(c, rest, policy.CanWrite)
432	if code >= 0 {
433		return code
434	}
435	if code := refuseArchived(c, repo); code >= 0 {
436		return code
437	}
438	resolve := func(name string) (store.User, int) {
439		u, err := c.Store.UserByUsername(name)
440		if errors.Is(err, store.ErrNotFound) {
441			return u, c.fail(protocol.ExitNotFound, "no such user %q", name)
442		}
443		if err != nil {
444			return u, c.fail(protocol.ExitFailure, "%v", err)
445		}
446		return u, -1
447	}
448	// issue is the read from before the update, so its Assignees are who
449	// was already on it. SetIssueAssignee inserts ON CONFLICT DO NOTHING
450	// and returns nil whether or not it inserted, and the notice below is
451	// for accounts newly added: a client reconciling the list by
452	// re-sending the whole set must not notify on every save.
453	assigned := make(map[string]bool, len(issue.Assignees))
454	for _, name := range issue.Assignees {
455		assigned[name] = true
456	}
457	var added []int64
458	for _, name := range adds {
459		u, code := resolve(name)
460		if code >= 0 {
461			return code
462		}
463		if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil {
464			return c.fail(protocol.ExitFailure, "%v", err)
465		}
466		if assigned[u.Username] {
467			continue
468		}
469		assigned[u.Username] = true
470		added = append(added, u.ID)
471	}
472	for _, name := range removes {
473		u, code := resolve(name)
474		if code >= 0 {
475			return code
476		}
477		if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil {
478			if errors.Is(err, store.ErrNotFound) {
479				return c.fail(protocol.ExitNotFound, "%s is not assigned", name)
480			}
481			return c.fail(protocol.ExitFailure, "%v", err)
482		}
483	}
484	updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
485	if err != nil {
486		return c.fail(protocol.ExitFailure, "%v", err)
487	}
488	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.assigned",
489		fmt.Sprintf(`{"number":%d,"assignees":%s}`, issue.Number, jsonStrings(updated.Assignees)))
490	if len(added) > 0 {
491		// direct, as a mention is: an assignment is addressed to someone,
492		// and widening it to watchers would tell them "assigned you".
493		// Removals file nothing, and notify drops the actor, so assigning
494		// yourself is silent.
495		notify(c, added, notice{repo: repo, kind: "issue", direct: true,
496			subject: issueSubject(repo, issue.Number, issue.Title),
497			action:  fmt.Sprintf("assigned you to #%d", issue.Number),
498			path:    fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
499	}
500	return c.emit(map[string]any{"number": issue.Number, "assignees": updated.Assignees}, func(w io.Writer) {
501		fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Assignees, ", "))
502	})
503}