internal/control/issue.go

94884a22329740d40876a987689c17ab77aa9965
gitbay/internal/control/issue.go history · blame · raw

503 lines · 17884 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	f, err := parseFlags(args, flagSpec{Values: []string{"--format", "--title", "--body", "--file"}, MaxPos: 1,
127		Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]"})
128	if err != nil {
129		return c.fail(protocol.ExitUsage, "%v", err)
130	}
131	path, title, body, file, format := f.pos(0), f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
132	if path == "" || title == "" {
133		return c.fail(protocol.ExitUsage, "usage: issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]")
134	}
135	fmtName, err := markupFormat(format)
136	if err != nil {
137		return c.failErr(err)
138	}
139	if fmtName == "" {
140		fmtName = "md"
141	}
142	// Anyone who can read the repo can file an issue.
143	repo, code := resolveRepo(c, path, policy.CanRead)
144	if code >= 0 {
145		return code
146	}
147	if code := refuseArchived(c, repo); code >= 0 {
148		return code
149	}
150	b, err := bodyFrom(c, body, file)
151	if err != nil {
152		return c.failErr(err)
153	}
154	n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b, fmtName)
155	if err != nil {
156		return c.fail(protocol.ExitFailure, "%v", err)
157	}
158	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
159	if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
160		notifyUsers(c, targets, issueSubject(repo, n, title),
161			notifyBody(c, fmt.Sprintf("opened issue #%d", n), b, fmt.Sprintf("%s/issues/%d", repo.Path(), n)))
162	}
163	return c.emit(map[string]any{"number": n}, func(w io.Writer) {
164		fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
165	})
166}
167
168func runIssueList(c *Ctx, args []string) int {
169	args, p, code := parsePageFlags(c, args, "issue", true)
170	if code >= 0 {
171		return code
172	}
173	const usage = "usage: issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>] [--author <user>] [--milestone <title>|none] [--limit <n>] [--cursor <c>]"
174	f := store.IssueFilter{State: "open"}
175	fl, err := parseFlags(args, flagSpec{Values: []string{"--state", "--label", "--assignee", "--author", "--milestone"}, MaxPos: 1, Usage: usage})
176	if err != nil {
177		return c.fail(protocol.ExitUsage, "%v", err)
178	}
179	path := fl.pos(0)
180	if fl.Has("--state") {
181		f.State = fl.Value("--state")
182	}
183	f.Label, f.Assignee, f.Author, f.Milestone = fl.Value("--label"), fl.Value("--assignee"), fl.Value("--author"), fl.Value("--milestone")
184	if path == "" || (f.State != "open" && f.State != "closed" && f.State != "all") {
185		return c.fail(protocol.ExitUsage, usage)
186	}
187	repo, code := resolveRepo(c, path, policy.CanRead)
188	if code >= 0 {
189		return code
190	}
191	f.Limit, f.Before = p.queryLimit(), p.keyInt()
192	issues, err := c.Store.QueryIssues(repo.ID, f)
193	if err != nil {
194		return c.fail(protocol.ExitFailure, "%v", err)
195	}
196	issues, next := trimPage(p, issues, "issue", func(i store.Issue) string {
197		return strconv.FormatInt(i.Number, 10)
198	})
199	var ds []issueOut
200	for _, i := range issues {
201		ds = append(ds, issueToOut(i, false))
202	}
203	return c.emitPage(p, ds, next, func(w io.Writer) {
204		for _, d := range ds {
205			fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", d.Number, d.State, d.Title, d.Author)
206		}
207	})
208}
209
210func runIssueShow(c *Ctx, args []string) int {
211	repo, issue, code := issueRef(c, args, policy.CanRead)
212	if code >= 0 {
213		return code
214	}
215	if len(args) != 2 {
216		return c.fail(protocol.ExitUsage, "usage: issue show <owner/name> <n>")
217	}
218	comments, err := c.Store.ListIssueComments(issue.ID)
219	if err != nil {
220		return c.fail(protocol.ExitFailure, "%v", err)
221	}
222	type commentOut struct {
223		Author     string `json:"author"`
224		Body       string `json:"body"`
225		BodyFormat string `json:"body_format,omitempty"`
226		CreatedAt  string `json:"created_at"`
227	}
228	var cs []commentOut
229	for _, cm := range comments {
230		cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
231	}
232	d := struct {
233		issueOut
234		Comments []commentOut `json:"comments,omitempty"`
235	}{issueToOut(issue, true), cs}
236	_ = repo
237	return c.emit(d, func(w io.Writer) {
238		fmt.Fprintf(w, "#%d %s [%s] by %s\n", d.Number, d.Title, d.State, d.Author)
239		if len(d.Labels) > 0 {
240			fmt.Fprintf(w, "labels: %s\n", strings.Join(d.Labels, ", "))
241		}
242		if len(d.Assignees) > 0 {
243			fmt.Fprintf(w, "assignees: %s\n", strings.Join(d.Assignees, ", "))
244		}
245		if d.Body != "" {
246			fmt.Fprintf(w, "\n%s\n", d.Body)
247		}
248		for _, cm := range cs {
249			fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
250		}
251	})
252}
253
254func runIssueComment(c *Ctx, args []string) int {
255	f, err := parseFlags(args, flagSpec{Values: []string{"--format", "--message", "--file"}, MaxPos: -1,
256		Usage: "issue comment <owner/name> <n> [--message <m> | --file -] [--format md|org]"})
257	if err != nil {
258		return c.fail(protocol.ExitUsage, "%v", err)
259	}
260	rest := f.Pos
261	message, file, format := f.Value("--message"), f.Value("--file"), f.Value("--format")
262	fmtName, err := markupFormat(format)
263	if err != nil {
264		return c.failErr(err)
265	}
266	if fmtName == "" {
267		fmtName = "md"
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.failErr(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, fmtName); 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 -/--format and authorizes: author or
334// write. A nil format means the stored markup format stays as it is.
335func editText(c *Ctx, args []string, kind string) (rest []string, title, body, format *string, code int) {
336	f, err := parseFlags(args, flagSpec{Values: []string{"--title", "--body", "--file", "--format"}, MaxPos: -1,
337		Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"})
338	if err != nil {
339		return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err)
340	}
341	rest = f.Pos
342	titleV, bodyV, file, formatV := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
343	haveTitle, haveBody := f.Has("--title"), f.Has("--body")
344	if file != "" {
345		b, err := bodyFrom(c, "", file)
346		if err != nil {
347			return nil, nil, nil, nil, c.failErr(err)
348		}
349		bodyV, haveBody = b, true
350	}
351	fmtName, err := markupFormat(formatV)
352	if err != nil {
353		return nil, nil, nil, nil, c.failErr(err)
354	}
355	if !haveTitle && !haveBody && fmtName == "" {
356		return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "usage: %s edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]", kind)
357	}
358	if haveTitle {
359		if strings.TrimSpace(titleV) == "" {
360			return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "--title must not be empty")
361		}
362		title = &titleV
363	}
364	if haveBody {
365		body = &bodyV
366	}
367	if fmtName != "" {
368		format = &fmtName
369	}
370	return rest, title, body, format, -1
371}
372
373func runIssueEdit(c *Ctx, args []string) int {
374	rest, title, body, format, code := editText(c, args, "issue")
375	if code >= 0 {
376		return code
377	}
378	repo, issue, code := issueRef(c, rest, policy.CanRead)
379	if code >= 0 {
380		return code
381	}
382	if code := refuseArchived(c, repo); code >= 0 {
383		return code
384	}
385	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
386	if err != nil {
387		return c.fail(protocol.ExitFailure, "%v", err)
388	}
389	if issue.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
390		return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this issue")
391	}
392	if err := c.Store.UpdateIssueText(issue.ID, title, body, format); err != nil {
393		return c.fail(protocol.ExitFailure, "%v", err)
394	}
395	return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
396		fmt.Fprintf(w, "edited %s#%d\n", repo.Path(), issue.Number)
397	})
398}
399
400func runIssueClose(c *Ctx, args []string) int  { return setIssueState(c, args, "closed") }
401func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") }
402
403// addRemoveFlags parses repeated --add/--remove flags.
404func addRemoveFlags(args []string) (rest, adds, removes []string, err error) {
405	f, err := parseFlags(args, flagSpec{Multi: []string{"--add", "--remove"}, MaxPos: -1})
406	if err != nil {
407		return nil, nil, nil, err
408	}
409	rest, adds, removes = f.Pos, f.List("--add"), f.List("--remove")
410	return rest, adds, removes, nil
411}
412
413func runIssueLabel(c *Ctx, args []string) int {
414	rest, adds, removes, err := addRemoveFlags(args)
415	if err != nil {
416		return c.failErr(err)
417	}
418	if len(adds)+len(removes) == 0 {
419		return c.fail(protocol.ExitUsage, "usage: issue label <owner/name> <n> [--add <l>]... [--remove <l>]...")
420	}
421	repo, issue, code := issueRef(c, rest, policy.CanWrite)
422	if code >= 0 {
423		return code
424	}
425	if code := refuseArchived(c, repo); code >= 0 {
426		return code
427	}
428	for _, l := range adds {
429		if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, true); err != nil {
430			return c.fail(protocol.ExitFailure, "%v", err)
431		}
432	}
433	for _, l := range removes {
434		if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, false); err != nil {
435			if errors.Is(err, store.ErrNotFound) {
436				return c.fail(protocol.ExitNotFound, "%v", err)
437			}
438			return c.fail(protocol.ExitFailure, "%v", err)
439		}
440	}
441	updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
442	if err != nil {
443		return c.fail(protocol.ExitFailure, "%v", err)
444	}
445	return c.emit(map[string]any{"number": issue.Number, "labels": updated.Labels}, func(w io.Writer) {
446		fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Labels, ", "))
447	})
448}
449
450func runIssueAssign(c *Ctx, args []string) int {
451	rest, adds, removes, err := addRemoveFlags(args)
452	if err != nil {
453		return c.failErr(err)
454	}
455	if len(adds)+len(removes) == 0 {
456		return c.fail(protocol.ExitUsage, "usage: issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...")
457	}
458	repo, issue, code := issueRef(c, rest, policy.CanWrite)
459	if code >= 0 {
460		return code
461	}
462	if code := refuseArchived(c, repo); code >= 0 {
463		return code
464	}
465	resolve := func(name string) (store.User, int) {
466		u, err := c.Store.UserByUsername(name)
467		if errors.Is(err, store.ErrNotFound) {
468			return u, c.fail(protocol.ExitNotFound, "no such user %q", name)
469		}
470		if err != nil {
471			return u, c.fail(protocol.ExitFailure, "%v", err)
472		}
473		return u, -1
474	}
475	for _, name := range adds {
476		u, code := resolve(name)
477		if code >= 0 {
478			return code
479		}
480		if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil {
481			return c.fail(protocol.ExitFailure, "%v", err)
482		}
483	}
484	for _, name := range removes {
485		u, code := resolve(name)
486		if code >= 0 {
487			return code
488		}
489		if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil {
490			if errors.Is(err, store.ErrNotFound) {
491				return c.fail(protocol.ExitNotFound, "%s is not assigned", name)
492			}
493			return c.fail(protocol.ExitFailure, "%v", err)
494		}
495	}
496	updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
497	if err != nil {
498		return c.fail(protocol.ExitFailure, "%v", err)
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}