internal/control/issue.go

93b38a063b3b4b161a1baf976e92f53d02b1a221
gitbay/internal/control/issue.go history · blame · raw

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