internal/control/issue.go

160fc0ec2bbbb4367ce5db1814c1a7404b656048
gitbay/internal/control/issue.go history · blame · raw

521 lines · 16918 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 -]",
 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 -]",
 27		ReadsStdin: true, Run: runIssueEdit})
 28	register(Command{Path: []string{"issue", "comment"},
 29		Summary:    "comment: issue comment <owner/name> <n> [--message <m> | --file -]",
 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
 79type issueOut struct {
 80	Number    int64    `json:"number"`
 81	Title     string   `json:"title"`
 82	State     string   `json:"state"`
 83	Author    string   `json:"author"`
 84	Milestone string   `json:"milestone,omitempty"`
 85	Labels    []string `json:"labels,omitempty"`
 86	Assignees []string `json:"assignees,omitempty"`
 87	Body      string   `json:"body,omitempty"`
 88	CreatedAt string   `json:"created_at"`
 89}
 90
 91func issueToOut(i store.Issue, withBody bool) issueOut {
 92	o := issueOut{Number: i.Number, Title: i.Title, State: i.State, Author: i.Author,
 93		Milestone: i.Milestone, Labels: i.Labels, Assignees: i.Assignees, CreatedAt: i.CreatedAt}
 94	if withBody {
 95		o.Body = i.Body
 96	}
 97	return o
 98}
 99
100func runIssueCreate(c *Ctx, args []string) int {
101	var path, title, body, file string
102	for i := 0; i < len(args); i++ {
103		switch args[i] {
104		case "--title":
105			if i+1 >= len(args) {
106				return c.fail(protocol.ExitUsage, "--title requires a value")
107			}
108			title = args[i+1]
109			i++
110		case "--body":
111			if i+1 >= len(args) {
112				return c.fail(protocol.ExitUsage, "--body requires a value")
113			}
114			body = args[i+1]
115			i++
116		case "--file":
117			if i+1 >= len(args) {
118				return c.fail(protocol.ExitUsage, "--file requires a value")
119			}
120			file = args[i+1]
121			i++
122		default:
123			if path != "" {
124				return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
125			}
126			path = args[i]
127		}
128	}
129	if path == "" || title == "" {
130		return c.fail(protocol.ExitUsage, "usage: issue create <owner/name> --title <t> [--body <b> | --file -]")
131	}
132	// Anyone who can read the repo can file an issue.
133	repo, code := resolveRepo(c, path, policy.CanRead)
134	if code >= 0 {
135		return code
136	}
137	if code := refuseArchived(c, repo); code >= 0 {
138		return code
139	}
140	b, err := bodyFrom(c, body, file)
141	if err != nil {
142		return c.fail(protocol.ExitUsage, "%v", err)
143	}
144	n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b)
145	if err != nil {
146		return c.fail(protocol.ExitFailure, "%v", err)
147	}
148	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
149	if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
150		notifyUsers(c, targets, issueSubject(repo, n, title),
151			notifyBody(c, fmt.Sprintf("opened issue #%d", n), b, fmt.Sprintf("%s/issues/%d", repo.Path(), n)))
152	}
153	return c.emit(map[string]any{"number": n}, func(w io.Writer) {
154		fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
155	})
156}
157
158func runIssueList(c *Ctx, args []string) int {
159	args, p, code := parsePageFlags(c, args, "issue", true)
160	if code >= 0 {
161		return code
162	}
163	state := "open"
164	var path string
165	for i := 0; i < len(args); i++ {
166		switch args[i] {
167		case "--state":
168			if i+1 >= len(args) {
169				return c.fail(protocol.ExitUsage, "--state requires open|closed|all")
170			}
171			state = args[i+1]
172			i++
173		default:
174			if path != "" {
175				return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
176			}
177			path = args[i]
178		}
179	}
180	if path == "" || (state != "open" && state != "closed" && state != "all") {
181		return c.fail(protocol.ExitUsage, "usage: issue list <owner/name> [--state open|closed|all] [--limit <n>] [--cursor <c>]")
182	}
183	repo, code := resolveRepo(c, path, policy.CanRead)
184	if code >= 0 {
185		return code
186	}
187	issues, err := c.Store.ListIssues(repo.ID, state, p.queryLimit(), p.keyInt())
188	if err != nil {
189		return c.fail(protocol.ExitFailure, "%v", err)
190	}
191	issues, next := trimPage(p, issues, "issue", func(i store.Issue) string {
192		return strconv.FormatInt(i.Number, 10)
193	})
194	var ds []issueOut
195	for _, i := range issues {
196		ds = append(ds, issueToOut(i, false))
197	}
198	return c.emitPage(p, ds, next, func(w io.Writer) {
199		for _, d := range ds {
200			fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", d.Number, d.State, d.Title, d.Author)
201		}
202	})
203}
204
205func runIssueShow(c *Ctx, args []string) int {
206	repo, issue, code := issueRef(c, args, policy.CanRead)
207	if code >= 0 {
208		return code
209	}
210	if len(args) != 2 {
211		return c.fail(protocol.ExitUsage, "usage: issue show <owner/name> <n>")
212	}
213	comments, err := c.Store.ListIssueComments(issue.ID)
214	if err != nil {
215		return c.fail(protocol.ExitFailure, "%v", err)
216	}
217	type commentOut struct {
218		Author    string `json:"author"`
219		Body      string `json:"body"`
220		CreatedAt string `json:"created_at"`
221	}
222	var cs []commentOut
223	for _, cm := range comments {
224		cs = append(cs, commentOut{cm.Author, cm.Body, cm.CreatedAt})
225	}
226	d := struct {
227		issueOut
228		Comments []commentOut `json:"comments,omitempty"`
229	}{issueToOut(issue, true), 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	var rest []string
250	var message, file string
251	for i := 0; i < len(args); i++ {
252		switch args[i] {
253		case "--message":
254			if i+1 >= len(args) {
255				return c.fail(protocol.ExitUsage, "--message requires a value")
256			}
257			message = args[i+1]
258			i++
259		case "--file":
260			if i+1 >= len(args) {
261				return c.fail(protocol.ExitUsage, "--file requires a value")
262			}
263			file = args[i+1]
264			i++
265		default:
266			rest = append(rest, args[i])
267		}
268	}
269	repo, issue, code := issueRef(c, rest, policy.CanRead)
270	if code >= 0 {
271		return code
272	}
273	if code := refuseArchived(c, repo); code >= 0 {
274		return code
275	}
276	body, err := bodyFrom(c, message, file)
277	if err != nil {
278		return c.fail(protocol.ExitUsage, "%v", err)
279	}
280	if strings.TrimSpace(body) == "" {
281		return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
282	}
283	if err := c.Store.AddIssueComment(issue.ID, c.User.ID, body); err != nil {
284		return c.fail(protocol.ExitFailure, "%v", err)
285	}
286	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, issue.Number))
287	if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
288		notifyUsers(c, parts, issueSubject(repo, issue.Number, issue.Title),
289			notifyBody(c, fmt.Sprintf("commented on #%d", issue.Number), body, fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)))
290	}
291	return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
292		fmt.Fprintf(w, "commented on %s#%d\n", repo.Path(), issue.Number)
293	})
294}
295
296func setIssueState(c *Ctx, args []string, state string) int {
297	// Author may close/reopen their own issue; otherwise write access.
298	repo, issue, code := issueRef(c, args, policy.CanRead)
299	if code >= 0 {
300		return code
301	}
302	if code := refuseArchived(c, repo); code >= 0 {
303		return code
304	}
305	if len(args) != 2 {
306		return c.fail(protocol.ExitUsage, "usage: issue %s <owner/name> <n>", state)
307	}
308	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
309	if err != nil {
310		return c.fail(protocol.ExitFailure, "%v", err)
311	}
312	if issue.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
313		return c.fail(protocol.ExitDenied, "only the author or users with write access can %s this issue",
314			map[string]string{"open": "reopen", "closed": "close"}[state])
315	}
316	if issue.State == state {
317		return c.fail(protocol.ExitUsage, "issue #%d is already %s", issue.Number, state)
318	}
319	if err := c.Store.SetIssueState(issue.ID, state); err != nil {
320		return c.fail(protocol.ExitFailure, "%v", err)
321	}
322	c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number))
323	if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
324		verb := map[string]string{"open": "reopened", "closed": "closed"}[state]
325		notifyUsers(c, parts, issueSubject(repo, issue.Number, issue.Title),
326			notifyBody(c, fmt.Sprintf("%s #%d", verb, issue.Number), "", fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)))
327	}
328	return c.emit(map[string]any{"number": issue.Number, "state": state}, func(w io.Writer) {
329		fmt.Fprintf(w, "%s#%d is now %s\n", repo.Path(), issue.Number, state)
330	})
331}
332
333// editText parses --title/--body/--file - and authorizes: author or write.
334func editText(c *Ctx, args []string, kind string) (rest []string, title, body *string, code int) {
335	var titleV, bodyV, file string
336	haveTitle, haveBody := false, false
337	for i := 0; i < len(args); i++ {
338		switch args[i] {
339		case "--title", "--body", "--file":
340			if i+1 >= len(args) {
341				return nil, nil, nil, c.fail(protocol.ExitUsage, "%s requires a value", args[i])
342			}
343			switch args[i] {
344			case "--title":
345				titleV, haveTitle = args[i+1], true
346			case "--body":
347				bodyV, haveBody = args[i+1], true
348			case "--file":
349				file = args[i+1]
350			}
351			i++
352		default:
353			rest = append(rest, args[i])
354		}
355	}
356	if file != "" {
357		b, err := bodyFrom(c, "", file)
358		if err != nil {
359			return nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err)
360		}
361		bodyV, haveBody = b, true
362	}
363	if !haveTitle && !haveBody {
364		return nil, nil, nil, c.fail(protocol.ExitUsage, "usage: %s edit <owner/name> <n> [--title <t>] [--body <b> | --file -]", kind)
365	}
366	if haveTitle {
367		if strings.TrimSpace(titleV) == "" {
368			return nil, nil, nil, c.fail(protocol.ExitUsage, "--title must not be empty")
369		}
370		title = &titleV
371	}
372	if haveBody {
373		body = &bodyV
374	}
375	return rest, title, body, -1
376}
377
378func runIssueEdit(c *Ctx, args []string) int {
379	rest, title, body, code := editText(c, args, "issue")
380	if code >= 0 {
381		return code
382	}
383	repo, issue, code := issueRef(c, rest, policy.CanRead)
384	if code >= 0 {
385		return code
386	}
387	if code := refuseArchived(c, repo); code >= 0 {
388		return code
389	}
390	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
391	if err != nil {
392		return c.fail(protocol.ExitFailure, "%v", err)
393	}
394	if issue.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
395		return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this issue")
396	}
397	if err := c.Store.UpdateIssueText(issue.ID, title, body); err != nil {
398		return c.fail(protocol.ExitFailure, "%v", err)
399	}
400	return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
401		fmt.Fprintf(w, "edited %s#%d\n", repo.Path(), issue.Number)
402	})
403}
404
405func runIssueClose(c *Ctx, args []string) int  { return setIssueState(c, args, "closed") }
406func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") }
407
408// addRemoveFlags parses repeated --add/--remove flags.
409func addRemoveFlags(args []string) (rest, adds, removes []string, err error) {
410	for i := 0; i < len(args); i++ {
411		switch args[i] {
412		case "--add":
413			if i+1 >= len(args) {
414				return nil, nil, nil, errors.New("--add requires a value")
415			}
416			adds = append(adds, args[i+1])
417			i++
418		case "--remove":
419			if i+1 >= len(args) {
420				return nil, nil, nil, errors.New("--remove requires a value")
421			}
422			removes = append(removes, args[i+1])
423			i++
424		default:
425			rest = append(rest, args[i])
426		}
427	}
428	return rest, adds, removes, nil
429}
430
431func runIssueLabel(c *Ctx, args []string) int {
432	rest, adds, removes, err := addRemoveFlags(args)
433	if err != nil {
434		return c.fail(protocol.ExitUsage, "%v", err)
435	}
436	if len(adds)+len(removes) == 0 {
437		return c.fail(protocol.ExitUsage, "usage: issue label <owner/name> <n> [--add <l>]... [--remove <l>]...")
438	}
439	repo, issue, code := issueRef(c, rest, policy.CanWrite)
440	if code >= 0 {
441		return code
442	}
443	if code := refuseArchived(c, repo); code >= 0 {
444		return code
445	}
446	for _, l := range adds {
447		if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, true); err != nil {
448			return c.fail(protocol.ExitFailure, "%v", err)
449		}
450	}
451	for _, l := range removes {
452		if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, false); err != nil {
453			if errors.Is(err, store.ErrNotFound) {
454				return c.fail(protocol.ExitNotFound, "%v", err)
455			}
456			return c.fail(protocol.ExitFailure, "%v", err)
457		}
458	}
459	updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
460	if err != nil {
461		return c.fail(protocol.ExitFailure, "%v", err)
462	}
463	return c.emit(map[string]any{"number": issue.Number, "labels": updated.Labels}, func(w io.Writer) {
464		fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Labels, ", "))
465	})
466}
467
468func runIssueAssign(c *Ctx, args []string) int {
469	rest, adds, removes, err := addRemoveFlags(args)
470	if err != nil {
471		return c.fail(protocol.ExitUsage, "%v", err)
472	}
473	if len(adds)+len(removes) == 0 {
474		return c.fail(protocol.ExitUsage, "usage: issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...")
475	}
476	repo, issue, code := issueRef(c, rest, policy.CanWrite)
477	if code >= 0 {
478		return code
479	}
480	if code := refuseArchived(c, repo); code >= 0 {
481		return code
482	}
483	resolve := func(name string) (store.User, int) {
484		u, err := c.Store.UserByUsername(name)
485		if errors.Is(err, store.ErrNotFound) {
486			return u, c.fail(protocol.ExitNotFound, "no such user %q", name)
487		}
488		if err != nil {
489			return u, c.fail(protocol.ExitFailure, "%v", err)
490		}
491		return u, -1
492	}
493	for _, name := range adds {
494		u, code := resolve(name)
495		if code >= 0 {
496			return code
497		}
498		if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil {
499			return c.fail(protocol.ExitFailure, "%v", err)
500		}
501	}
502	for _, name := range removes {
503		u, code := resolve(name)
504		if code >= 0 {
505			return code
506		}
507		if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil {
508			if errors.Is(err, store.ErrNotFound) {
509				return c.fail(protocol.ExitNotFound, "%s is not assigned", name)
510			}
511			return c.fail(protocol.ExitFailure, "%v", err)
512		}
513	}
514	updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
515	if err != nil {
516		return c.fail(protocol.ExitFailure, "%v", err)
517	}
518	return c.emit(map[string]any{"number": issue.Number, "assignees": updated.Assignees}, func(w io.Writer) {
519		fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Assignees, ", "))
520	})
521}