internal/control/issue.go

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

596 lines · 19205 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] [--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	if len(args) < 2 {
 53		return store.Repo{}, store.Issue{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>")
 54	}
 55	repo, code := resolveRepo(c, args[0], perm)
 56	if code >= 0 {
 57		return repo, store.Issue{}, code
 58	}
 59	n, err := strconv.ParseInt(args[1], 10, 64)
 60	if err != nil {
 61		return repo, store.Issue{}, c.fail(protocol.ExitUsage, "bad issue number %q", args[1])
 62	}
 63	issue, err := c.Store.IssueByNumber(repo.ID, n)
 64	if errors.Is(err, store.ErrNotFound) {
 65		return repo, issue, c.fail(protocol.ExitNotFound, "issue #%d not found in %s", n, repo.Path())
 66	}
 67	if err != nil {
 68		return repo, issue, c.fail(protocol.ExitFailure, "%v", err)
 69	}
 70	return repo, issue, -1
 71}
 72
 73// bodyFrom resolves --body/--message inline text or --file - (stdin).
 74func bodyFrom(c *Ctx, inline, file string) (string, error) {
 75	if inline != "" && file != "" {
 76		return "", errors.New("give either an inline message or --file -, not both")
 77	}
 78	if file != "" {
 79		if file != "-" {
 80			return "", errors.New("--file only supports - (stdin) over ssh")
 81		}
 82		raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxBodyBytes))
 83		return string(raw), err
 84	}
 85	return inline, nil
 86}
 87
 88// markupFormat normalizes a --format value. Empty means the caller did not ask,
 89// which the caller turns into "md" on create or "unchanged" on edit.
 90func markupFormat(v string) (string, error) {
 91	switch strings.ToLower(strings.TrimSpace(v)) {
 92	case "":
 93		return "", nil
 94	case "md", "markdown":
 95		return "md", nil
 96	case "org", "org-mode":
 97		return "org", nil
 98	}
 99	return "", fmt.Errorf("unknown --format %q (want md or org)", v)
100}
101
102type issueOut struct {
103	Number     int64    `json:"number"`
104	Title      string   `json:"title"`
105	State      string   `json:"state"`
106	Author     string   `json:"author"`
107	Milestone  string   `json:"milestone,omitempty"`
108	Labels     []string `json:"labels,omitempty"`
109	Assignees  []string `json:"assignees,omitempty"`
110	Body       string   `json:"body,omitempty"`
111	BodyFormat string   `json:"body_format,omitempty"`
112	CreatedAt  string   `json:"created_at"`
113}
114
115func issueToOut(i store.Issue, withBody bool) issueOut {
116	o := issueOut{Number: i.Number, Title: i.Title, State: i.State, Author: i.Author,
117		Milestone: i.Milestone, Labels: i.Labels, Assignees: i.Assignees, CreatedAt: i.CreatedAt}
118	if withBody {
119		o.Body = i.Body
120		o.BodyFormat = i.BodyFormat
121	}
122	return o
123}
124
125func runIssueCreate(c *Ctx, args []string) int {
126	var path, title, body, file, format string
127	for i := 0; i < len(args); i++ {
128		switch args[i] {
129		case "--format":
130			if i+1 >= len(args) {
131				return c.fail(protocol.ExitUsage, "--format requires a value")
132			}
133			format = args[i+1]
134			i++
135		case "--title":
136			if i+1 >= len(args) {
137				return c.fail(protocol.ExitUsage, "--title requires a value")
138			}
139			title = args[i+1]
140			i++
141		case "--body":
142			if i+1 >= len(args) {
143				return c.fail(protocol.ExitUsage, "--body requires a value")
144			}
145			body = args[i+1]
146			i++
147		case "--file":
148			if i+1 >= len(args) {
149				return c.fail(protocol.ExitUsage, "--file requires a value")
150			}
151			file = args[i+1]
152			i++
153		default:
154			if path != "" {
155				return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
156			}
157			path = args[i]
158		}
159	}
160	if path == "" || title == "" {
161		return c.fail(protocol.ExitUsage, "usage: issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]")
162	}
163	fmtName, err := markupFormat(format)
164	if err != nil {
165		return c.fail(protocol.ExitUsage, "%v", err)
166	}
167	if fmtName == "" {
168		fmtName = "md"
169	}
170	// Anyone who can read the repo can file an issue.
171	repo, code := resolveRepo(c, path, policy.CanRead)
172	if code >= 0 {
173		return code
174	}
175	if code := refuseArchived(c, repo); code >= 0 {
176		return code
177	}
178	b, err := bodyFrom(c, body, file)
179	if err != nil {
180		return c.fail(protocol.ExitUsage, "%v", err)
181	}
182	n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b, fmtName)
183	if err != nil {
184		return c.fail(protocol.ExitFailure, "%v", err)
185	}
186	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
187	if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
188		notifyUsers(c, targets, issueSubject(repo, n, title),
189			notifyBody(c, fmt.Sprintf("opened issue #%d", n), b, fmt.Sprintf("%s/issues/%d", repo.Path(), n)))
190	}
191	return c.emit(map[string]any{"number": n}, func(w io.Writer) {
192		fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
193	})
194}
195
196func runIssueList(c *Ctx, args []string) int {
197	args, p, code := parsePageFlags(c, args, "issue", true)
198	if code >= 0 {
199		return code
200	}
201	const usage = "usage: issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>] [--author <user>] [--milestone <title>|none] [--limit <n>] [--cursor <c>]"
202	f := store.IssueFilter{State: "open"}
203	var path string
204	for i := 0; i < len(args); i++ {
205		var target *string
206		switch args[i] {
207		case "--state":
208			target = &f.State
209		case "--label":
210			target = &f.Label
211		case "--assignee":
212			target = &f.Assignee
213		case "--author":
214			target = &f.Author
215		case "--milestone":
216			target = &f.Milestone
217		default:
218			if path != "" || strings.HasPrefix(args[i], "--") {
219				return c.fail(protocol.ExitUsage, "unexpected argument %q\n%s", args[i], usage)
220			}
221			path = args[i]
222			continue
223		}
224		if i+1 >= len(args) {
225			return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
226		}
227		*target = args[i+1]
228		i++
229	}
230	if path == "" || (f.State != "open" && f.State != "closed" && f.State != "all") {
231		return c.fail(protocol.ExitUsage, usage)
232	}
233	repo, code := resolveRepo(c, path, policy.CanRead)
234	if code >= 0 {
235		return code
236	}
237	f.Limit, f.Before = p.queryLimit(), p.keyInt()
238	issues, err := c.Store.QueryIssues(repo.ID, f)
239	if err != nil {
240		return c.fail(protocol.ExitFailure, "%v", err)
241	}
242	issues, next := trimPage(p, issues, "issue", func(i store.Issue) string {
243		return strconv.FormatInt(i.Number, 10)
244	})
245	var ds []issueOut
246	for _, i := range issues {
247		ds = append(ds, issueToOut(i, false))
248	}
249	return c.emitPage(p, ds, next, func(w io.Writer) {
250		for _, d := range ds {
251			fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", d.Number, d.State, d.Title, d.Author)
252		}
253	})
254}
255
256func runIssueShow(c *Ctx, args []string) int {
257	repo, issue, code := issueRef(c, args, policy.CanRead)
258	if code >= 0 {
259		return code
260	}
261	if len(args) != 2 {
262		return c.fail(protocol.ExitUsage, "usage: issue show <owner/name> <n>")
263	}
264	comments, err := c.Store.ListIssueComments(issue.ID)
265	if err != nil {
266		return c.fail(protocol.ExitFailure, "%v", err)
267	}
268	type commentOut struct {
269		Author     string `json:"author"`
270		Body       string `json:"body"`
271		BodyFormat string `json:"body_format,omitempty"`
272		CreatedAt  string `json:"created_at"`
273	}
274	var cs []commentOut
275	for _, cm := range comments {
276		cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
277	}
278	d := struct {
279		issueOut
280		Comments []commentOut `json:"comments,omitempty"`
281	}{issueToOut(issue, true), cs}
282	_ = repo
283	return c.emit(d, func(w io.Writer) {
284		fmt.Fprintf(w, "#%d %s [%s] by %s\n", d.Number, d.Title, d.State, d.Author)
285		if len(d.Labels) > 0 {
286			fmt.Fprintf(w, "labels: %s\n", strings.Join(d.Labels, ", "))
287		}
288		if len(d.Assignees) > 0 {
289			fmt.Fprintf(w, "assignees: %s\n", strings.Join(d.Assignees, ", "))
290		}
291		if d.Body != "" {
292			fmt.Fprintf(w, "\n%s\n", d.Body)
293		}
294		for _, cm := range cs {
295			fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
296		}
297	})
298}
299
300func runIssueComment(c *Ctx, args []string) int {
301	var rest []string
302	var message, file, format string
303	for i := 0; i < len(args); i++ {
304		switch args[i] {
305		case "--format":
306			if i+1 >= len(args) {
307				return c.fail(protocol.ExitUsage, "--format requires a value")
308			}
309			format = args[i+1]
310			i++
311		case "--message":
312			if i+1 >= len(args) {
313				return c.fail(protocol.ExitUsage, "--message requires a value")
314			}
315			message = args[i+1]
316			i++
317		case "--file":
318			if i+1 >= len(args) {
319				return c.fail(protocol.ExitUsage, "--file requires a value")
320			}
321			file = args[i+1]
322			i++
323		default:
324			rest = append(rest, args[i])
325		}
326	}
327	fmtName, err := markupFormat(format)
328	if err != nil {
329		return c.fail(protocol.ExitUsage, "%v", err)
330	}
331	if fmtName == "" {
332		fmtName = "md"
333	}
334	repo, issue, code := issueRef(c, rest, policy.CanRead)
335	if code >= 0 {
336		return code
337	}
338	if code := refuseArchived(c, repo); code >= 0 {
339		return code
340	}
341	body, err := bodyFrom(c, message, file)
342	if err != nil {
343		return c.fail(protocol.ExitUsage, "%v", err)
344	}
345	if strings.TrimSpace(body) == "" {
346		return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
347	}
348	if err := c.Store.AddIssueComment(issue.ID, c.User.ID, body, fmtName); err != nil {
349		return c.fail(protocol.ExitFailure, "%v", err)
350	}
351	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, issue.Number))
352	if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
353		notifyUsers(c, parts, issueSubject(repo, issue.Number, issue.Title),
354			notifyBody(c, fmt.Sprintf("commented on #%d", issue.Number), body, fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)))
355	}
356	return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
357		fmt.Fprintf(w, "commented on %s#%d\n", repo.Path(), issue.Number)
358	})
359}
360
361func setIssueState(c *Ctx, args []string, state string) int {
362	// Author may close/reopen their own issue; otherwise write access.
363	repo, issue, code := issueRef(c, args, policy.CanRead)
364	if code >= 0 {
365		return code
366	}
367	if code := refuseArchived(c, repo); code >= 0 {
368		return code
369	}
370	if len(args) != 2 {
371		return c.fail(protocol.ExitUsage, "usage: issue %s <owner/name> <n>", state)
372	}
373	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
374	if err != nil {
375		return c.fail(protocol.ExitFailure, "%v", err)
376	}
377	if issue.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
378		return c.fail(protocol.ExitDenied, "only the author or users with write access can %s this issue",
379			map[string]string{"open": "reopen", "closed": "close"}[state])
380	}
381	if issue.State == state {
382		return c.fail(protocol.ExitUsage, "issue #%d is already %s", issue.Number, state)
383	}
384	if err := c.Store.SetIssueState(issue.ID, state); err != nil {
385		return c.fail(protocol.ExitFailure, "%v", err)
386	}
387	c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number))
388	if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
389		verb := map[string]string{"open": "reopened", "closed": "closed"}[state]
390		notifyUsers(c, parts, issueSubject(repo, issue.Number, issue.Title),
391			notifyBody(c, fmt.Sprintf("%s #%d", verb, issue.Number), "", fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)))
392	}
393	return c.emit(map[string]any{"number": issue.Number, "state": state}, func(w io.Writer) {
394		fmt.Fprintf(w, "%s#%d is now %s\n", repo.Path(), issue.Number, state)
395	})
396}
397
398// editText parses --title/--body/--file -/--format and authorizes: author or
399// write. A nil format means the stored markup format stays as it is.
400func editText(c *Ctx, args []string, kind string) (rest []string, title, body, format *string, code int) {
401	var titleV, bodyV, file, formatV string
402	haveTitle, haveBody := false, false
403	for i := 0; i < len(args); i++ {
404		switch args[i] {
405		case "--title", "--body", "--file", "--format":
406			if i+1 >= len(args) {
407				return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%s requires a value", args[i])
408			}
409			switch args[i] {
410			case "--title":
411				titleV, haveTitle = args[i+1], true
412			case "--body":
413				bodyV, haveBody = args[i+1], true
414			case "--file":
415				file = args[i+1]
416			case "--format":
417				formatV = args[i+1]
418			}
419			i++
420		default:
421			rest = append(rest, args[i])
422		}
423	}
424	if file != "" {
425		b, err := bodyFrom(c, "", file)
426		if err != nil {
427			return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err)
428		}
429		bodyV, haveBody = b, true
430	}
431	fmtName, err := markupFormat(formatV)
432	if err != nil {
433		return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err)
434	}
435	if !haveTitle && !haveBody && fmtName == "" {
436		return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "usage: %s edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]", kind)
437	}
438	if haveTitle {
439		if strings.TrimSpace(titleV) == "" {
440			return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "--title must not be empty")
441		}
442		title = &titleV
443	}
444	if haveBody {
445		body = &bodyV
446	}
447	if fmtName != "" {
448		format = &fmtName
449	}
450	return rest, title, body, format, -1
451}
452
453func runIssueEdit(c *Ctx, args []string) int {
454	rest, title, body, format, code := editText(c, args, "issue")
455	if code >= 0 {
456		return code
457	}
458	repo, issue, code := issueRef(c, rest, policy.CanRead)
459	if code >= 0 {
460		return code
461	}
462	if code := refuseArchived(c, repo); code >= 0 {
463		return code
464	}
465	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
466	if err != nil {
467		return c.fail(protocol.ExitFailure, "%v", err)
468	}
469	if issue.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
470		return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this issue")
471	}
472	if err := c.Store.UpdateIssueText(issue.ID, title, body, format); err != nil {
473		return c.fail(protocol.ExitFailure, "%v", err)
474	}
475	return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
476		fmt.Fprintf(w, "edited %s#%d\n", repo.Path(), issue.Number)
477	})
478}
479
480func runIssueClose(c *Ctx, args []string) int  { return setIssueState(c, args, "closed") }
481func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") }
482
483// addRemoveFlags parses repeated --add/--remove flags.
484func addRemoveFlags(args []string) (rest, adds, removes []string, err error) {
485	for i := 0; i < len(args); i++ {
486		switch args[i] {
487		case "--add":
488			if i+1 >= len(args) {
489				return nil, nil, nil, errors.New("--add requires a value")
490			}
491			adds = append(adds, args[i+1])
492			i++
493		case "--remove":
494			if i+1 >= len(args) {
495				return nil, nil, nil, errors.New("--remove requires a value")
496			}
497			removes = append(removes, args[i+1])
498			i++
499		default:
500			rest = append(rest, args[i])
501		}
502	}
503	return rest, adds, removes, nil
504}
505
506func runIssueLabel(c *Ctx, args []string) int {
507	rest, adds, removes, err := addRemoveFlags(args)
508	if err != nil {
509		return c.fail(protocol.ExitUsage, "%v", err)
510	}
511	if len(adds)+len(removes) == 0 {
512		return c.fail(protocol.ExitUsage, "usage: issue label <owner/name> <n> [--add <l>]... [--remove <l>]...")
513	}
514	repo, issue, code := issueRef(c, rest, policy.CanWrite)
515	if code >= 0 {
516		return code
517	}
518	if code := refuseArchived(c, repo); code >= 0 {
519		return code
520	}
521	for _, l := range adds {
522		if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, true); err != nil {
523			return c.fail(protocol.ExitFailure, "%v", err)
524		}
525	}
526	for _, l := range removes {
527		if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, false); err != nil {
528			if errors.Is(err, store.ErrNotFound) {
529				return c.fail(protocol.ExitNotFound, "%v", err)
530			}
531			return c.fail(protocol.ExitFailure, "%v", err)
532		}
533	}
534	updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
535	if err != nil {
536		return c.fail(protocol.ExitFailure, "%v", err)
537	}
538	return c.emit(map[string]any{"number": issue.Number, "labels": updated.Labels}, func(w io.Writer) {
539		fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Labels, ", "))
540	})
541}
542
543func runIssueAssign(c *Ctx, args []string) int {
544	rest, adds, removes, err := addRemoveFlags(args)
545	if err != nil {
546		return c.fail(protocol.ExitUsage, "%v", err)
547	}
548	if len(adds)+len(removes) == 0 {
549		return c.fail(protocol.ExitUsage, "usage: issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...")
550	}
551	repo, issue, code := issueRef(c, rest, policy.CanWrite)
552	if code >= 0 {
553		return code
554	}
555	if code := refuseArchived(c, repo); code >= 0 {
556		return code
557	}
558	resolve := func(name string) (store.User, int) {
559		u, err := c.Store.UserByUsername(name)
560		if errors.Is(err, store.ErrNotFound) {
561			return u, c.fail(protocol.ExitNotFound, "no such user %q", name)
562		}
563		if err != nil {
564			return u, c.fail(protocol.ExitFailure, "%v", err)
565		}
566		return u, -1
567	}
568	for _, name := range adds {
569		u, code := resolve(name)
570		if code >= 0 {
571			return code
572		}
573		if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil {
574			return c.fail(protocol.ExitFailure, "%v", err)
575		}
576	}
577	for _, name := range removes {
578		u, code := resolve(name)
579		if code >= 0 {
580			return code
581		}
582		if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil {
583			if errors.Is(err, store.ErrNotFound) {
584				return c.fail(protocol.ExitNotFound, "%s is not assigned", name)
585			}
586			return c.fail(protocol.ExitFailure, "%v", err)
587		}
588	}
589	updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
590	if err != nil {
591		return c.fail(protocol.ExitFailure, "%v", err)
592	}
593	return c.emit(map[string]any{"number": issue.Number, "assignees": updated.Assignees}, func(w io.Writer) {
594		fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Assignees, ", "))
595	})
596}