internal/control/issue.go

13f864d416c4ab328bbb07bdc53867656d3ab678
gitbay/internal/control/issue.go history · blame · raw

439 lines · 14305 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]", 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", "comment"},
 26		Summary:    "comment: issue comment <owner/name> <n> [--message <m> | --file -]",
 27		ReadsStdin: true, Run: runIssueComment})
 28	register(Command{Path: []string{"issue", "close"},
 29		Summary: "close an issue: issue close <owner/name> <n>", Run: runIssueClose})
 30	register(Command{Path: []string{"issue", "reopen"},
 31		Summary: "reopen an issue: issue reopen <owner/name> <n>", Run: runIssueReopen})
 32	register(Command{Path: []string{"issue", "label"},
 33		Summary: "labels: issue label <owner/name> <n> [--add <l>]... [--remove <l>]...", Run: runIssueLabel})
 34	register(Command{Path: []string{"issue", "assign"},
 35		Summary: "assignees: issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...", Run: runIssueAssign})
 36}
 37
 38// issueArgs parses "<owner/name> <n>" plus flags handled by the caller.
 39func issueRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.Issue, int) {
 40	if len(args) < 2 {
 41		return store.Repo{}, store.Issue{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>")
 42	}
 43	repo, code := resolveRepo(c, args[0], perm)
 44	if code >= 0 {
 45		return repo, store.Issue{}, code
 46	}
 47	n, err := strconv.ParseInt(args[1], 10, 64)
 48	if err != nil {
 49		return repo, store.Issue{}, c.fail(protocol.ExitUsage, "bad issue number %q", args[1])
 50	}
 51	issue, err := c.Store.IssueByNumber(repo.ID, n)
 52	if errors.Is(err, store.ErrNotFound) {
 53		return repo, issue, c.fail(protocol.ExitNotFound, "issue #%d not found in %s", n, repo.Path())
 54	}
 55	if err != nil {
 56		return repo, issue, c.fail(protocol.ExitFailure, "%v", err)
 57	}
 58	return repo, issue, -1
 59}
 60
 61// bodyFrom resolves --body/--message inline text or --file - (stdin).
 62func bodyFrom(c *Ctx, inline, file string) (string, error) {
 63	if inline != "" && file != "" {
 64		return "", errors.New("give either an inline message or --file -, not both")
 65	}
 66	if file != "" {
 67		if file != "-" {
 68			return "", errors.New("--file only supports - (stdin) over ssh")
 69		}
 70		raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxBodyBytes))
 71		return string(raw), err
 72	}
 73	return inline, nil
 74}
 75
 76type issueOut struct {
 77	Number    int64    `json:"number"`
 78	Title     string   `json:"title"`
 79	State     string   `json:"state"`
 80	Author    string   `json:"author"`
 81	Milestone string   `json:"milestone,omitempty"`
 82	Labels    []string `json:"labels,omitempty"`
 83	Assignees []string `json:"assignees,omitempty"`
 84	Body      string   `json:"body,omitempty"`
 85	CreatedAt string   `json:"created_at"`
 86}
 87
 88func issueToOut(i store.Issue, withBody bool) issueOut {
 89	o := issueOut{Number: i.Number, Title: i.Title, State: i.State, Author: i.Author,
 90		Milestone: i.Milestone, Labels: i.Labels, Assignees: i.Assignees, CreatedAt: i.CreatedAt}
 91	if withBody {
 92		o.Body = i.Body
 93	}
 94	return o
 95}
 96
 97func runIssueCreate(c *Ctx, args []string) int {
 98	var path, title, body, file string
 99	for i := 0; i < len(args); i++ {
100		switch args[i] {
101		case "--title":
102			if i+1 >= len(args) {
103				return c.fail(protocol.ExitUsage, "--title requires a value")
104			}
105			title = args[i+1]
106			i++
107		case "--body":
108			if i+1 >= len(args) {
109				return c.fail(protocol.ExitUsage, "--body requires a value")
110			}
111			body = args[i+1]
112			i++
113		case "--file":
114			if i+1 >= len(args) {
115				return c.fail(protocol.ExitUsage, "--file requires a value")
116			}
117			file = args[i+1]
118			i++
119		default:
120			if path != "" {
121				return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
122			}
123			path = args[i]
124		}
125	}
126	if path == "" || title == "" {
127		return c.fail(protocol.ExitUsage, "usage: issue create <owner/name> --title <t> [--body <b> | --file -]")
128	}
129	// Anyone who can read the repo can file an issue.
130	repo, code := resolveRepo(c, path, policy.CanRead)
131	if code >= 0 {
132		return code
133	}
134	if code := refuseArchived(c, repo); code >= 0 {
135		return code
136	}
137	b, err := bodyFrom(c, body, file)
138	if err != nil {
139		return c.fail(protocol.ExitUsage, "%v", err)
140	}
141	n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b)
142	if err != nil {
143		return c.fail(protocol.ExitFailure, "%v", err)
144	}
145	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
146	if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
147		notifyUsers(c, targets, issueSubject(repo, n, title),
148			notifyBody(c, fmt.Sprintf("opened issue #%d", n), b, fmt.Sprintf("%s/issues/%d", repo.Path(), n)))
149	}
150	return c.emit(map[string]any{"number": n}, func(w io.Writer) {
151		fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
152	})
153}
154
155func runIssueList(c *Ctx, args []string) int {
156	state := "open"
157	var path string
158	for i := 0; i < len(args); i++ {
159		switch args[i] {
160		case "--state":
161			if i+1 >= len(args) {
162				return c.fail(protocol.ExitUsage, "--state requires open|closed|all")
163			}
164			state = args[i+1]
165			i++
166		default:
167			if path != "" {
168				return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
169			}
170			path = args[i]
171		}
172	}
173	if path == "" || (state != "open" && state != "closed" && state != "all") {
174		return c.fail(protocol.ExitUsage, "usage: issue list <owner/name> [--state open|closed|all]")
175	}
176	repo, code := resolveRepo(c, path, policy.CanRead)
177	if code >= 0 {
178		return code
179	}
180	issues, err := c.Store.ListIssues(repo.ID, state)
181	if err != nil {
182		return c.fail(protocol.ExitFailure, "%v", err)
183	}
184	var ds []issueOut
185	for _, i := range issues {
186		ds = append(ds, issueToOut(i, false))
187	}
188	return c.emit(ds, func(w io.Writer) {
189		for _, d := range ds {
190			fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", d.Number, d.State, d.Title, d.Author)
191		}
192	})
193}
194
195func runIssueShow(c *Ctx, args []string) int {
196	repo, issue, code := issueRef(c, args, policy.CanRead)
197	if code >= 0 {
198		return code
199	}
200	if len(args) != 2 {
201		return c.fail(protocol.ExitUsage, "usage: issue show <owner/name> <n>")
202	}
203	comments, err := c.Store.ListIssueComments(issue.ID)
204	if err != nil {
205		return c.fail(protocol.ExitFailure, "%v", err)
206	}
207	type commentOut struct {
208		Author    string `json:"author"`
209		Body      string `json:"body"`
210		CreatedAt string `json:"created_at"`
211	}
212	var cs []commentOut
213	for _, cm := range comments {
214		cs = append(cs, commentOut{cm.Author, cm.Body, cm.CreatedAt})
215	}
216	d := struct {
217		issueOut
218		Comments []commentOut `json:"comments,omitempty"`
219	}{issueToOut(issue, true), cs}
220	_ = repo
221	return c.emit(d, func(w io.Writer) {
222		fmt.Fprintf(w, "#%d %s [%s] by %s\n", d.Number, d.Title, d.State, d.Author)
223		if len(d.Labels) > 0 {
224			fmt.Fprintf(w, "labels: %s\n", strings.Join(d.Labels, ", "))
225		}
226		if len(d.Assignees) > 0 {
227			fmt.Fprintf(w, "assignees: %s\n", strings.Join(d.Assignees, ", "))
228		}
229		if d.Body != "" {
230			fmt.Fprintf(w, "\n%s\n", d.Body)
231		}
232		for _, cm := range cs {
233			fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
234		}
235	})
236}
237
238func runIssueComment(c *Ctx, args []string) int {
239	var rest []string
240	var message, file string
241	for i := 0; i < len(args); i++ {
242		switch args[i] {
243		case "--message":
244			if i+1 >= len(args) {
245				return c.fail(protocol.ExitUsage, "--message requires a value")
246			}
247			message = args[i+1]
248			i++
249		case "--file":
250			if i+1 >= len(args) {
251				return c.fail(protocol.ExitUsage, "--file requires a value")
252			}
253			file = args[i+1]
254			i++
255		default:
256			rest = append(rest, args[i])
257		}
258	}
259	repo, issue, code := issueRef(c, rest, policy.CanRead)
260	if code >= 0 {
261		return code
262	}
263	if code := refuseArchived(c, repo); code >= 0 {
264		return code
265	}
266	body, err := bodyFrom(c, message, file)
267	if err != nil {
268		return c.fail(protocol.ExitUsage, "%v", err)
269	}
270	if strings.TrimSpace(body) == "" {
271		return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
272	}
273	if err := c.Store.AddIssueComment(issue.ID, c.User.ID, body); err != nil {
274		return c.fail(protocol.ExitFailure, "%v", err)
275	}
276	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, issue.Number))
277	if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
278		notifyUsers(c, parts, issueSubject(repo, issue.Number, issue.Title),
279			notifyBody(c, fmt.Sprintf("commented on #%d", issue.Number), body, fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)))
280	}
281	return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
282		fmt.Fprintf(w, "commented on %s#%d\n", repo.Path(), issue.Number)
283	})
284}
285
286func setIssueState(c *Ctx, args []string, state string) int {
287	// Author may close/reopen their own issue; otherwise write access.
288	repo, issue, code := issueRef(c, args, policy.CanRead)
289	if code >= 0 {
290		return code
291	}
292	if code := refuseArchived(c, repo); code >= 0 {
293		return code
294	}
295	if len(args) != 2 {
296		return c.fail(protocol.ExitUsage, "usage: issue %s <owner/name> <n>", state)
297	}
298	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
299	if err != nil {
300		return c.fail(protocol.ExitFailure, "%v", err)
301	}
302	if issue.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
303		return c.fail(protocol.ExitDenied, "only the author or users with write access can %s this issue",
304			map[string]string{"open": "reopen", "closed": "close"}[state])
305	}
306	if issue.State == state {
307		return c.fail(protocol.ExitUsage, "issue #%d is already %s", issue.Number, state)
308	}
309	if err := c.Store.SetIssueState(issue.ID, state); err != nil {
310		return c.fail(protocol.ExitFailure, "%v", err)
311	}
312	c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number))
313	if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
314		verb := map[string]string{"open": "reopened", "closed": "closed"}[state]
315		notifyUsers(c, parts, issueSubject(repo, issue.Number, issue.Title),
316			notifyBody(c, fmt.Sprintf("%s #%d", verb, issue.Number), "", fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)))
317	}
318	return c.emit(map[string]any{"number": issue.Number, "state": state}, func(w io.Writer) {
319		fmt.Fprintf(w, "%s#%d is now %s\n", repo.Path(), issue.Number, state)
320	})
321}
322
323func runIssueClose(c *Ctx, args []string) int  { return setIssueState(c, args, "closed") }
324func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") }
325
326// addRemoveFlags parses repeated --add/--remove flags.
327func addRemoveFlags(args []string) (rest, adds, removes []string, err error) {
328	for i := 0; i < len(args); i++ {
329		switch args[i] {
330		case "--add":
331			if i+1 >= len(args) {
332				return nil, nil, nil, errors.New("--add requires a value")
333			}
334			adds = append(adds, args[i+1])
335			i++
336		case "--remove":
337			if i+1 >= len(args) {
338				return nil, nil, nil, errors.New("--remove requires a value")
339			}
340			removes = append(removes, args[i+1])
341			i++
342		default:
343			rest = append(rest, args[i])
344		}
345	}
346	return rest, adds, removes, nil
347}
348
349func runIssueLabel(c *Ctx, args []string) int {
350	rest, adds, removes, err := addRemoveFlags(args)
351	if err != nil {
352		return c.fail(protocol.ExitUsage, "%v", err)
353	}
354	if len(adds)+len(removes) == 0 {
355		return c.fail(protocol.ExitUsage, "usage: issue label <owner/name> <n> [--add <l>]... [--remove <l>]...")
356	}
357	repo, issue, code := issueRef(c, rest, policy.CanWrite)
358	if code >= 0 {
359		return code
360	}
361	if code := refuseArchived(c, repo); code >= 0 {
362		return code
363	}
364	for _, l := range adds {
365		if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, true); err != nil {
366			return c.fail(protocol.ExitFailure, "%v", err)
367		}
368	}
369	for _, l := range removes {
370		if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, false); err != nil {
371			if errors.Is(err, store.ErrNotFound) {
372				return c.fail(protocol.ExitNotFound, "%v", err)
373			}
374			return c.fail(protocol.ExitFailure, "%v", err)
375		}
376	}
377	updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
378	if err != nil {
379		return c.fail(protocol.ExitFailure, "%v", err)
380	}
381	return c.emit(map[string]any{"number": issue.Number, "labels": updated.Labels}, func(w io.Writer) {
382		fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Labels, ", "))
383	})
384}
385
386func runIssueAssign(c *Ctx, args []string) int {
387	rest, adds, removes, err := addRemoveFlags(args)
388	if err != nil {
389		return c.fail(protocol.ExitUsage, "%v", err)
390	}
391	if len(adds)+len(removes) == 0 {
392		return c.fail(protocol.ExitUsage, "usage: issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...")
393	}
394	repo, issue, code := issueRef(c, rest, policy.CanWrite)
395	if code >= 0 {
396		return code
397	}
398	if code := refuseArchived(c, repo); code >= 0 {
399		return code
400	}
401	resolve := func(name string) (store.User, int) {
402		u, err := c.Store.UserByUsername(name)
403		if errors.Is(err, store.ErrNotFound) {
404			return u, c.fail(protocol.ExitNotFound, "no such user %q", name)
405		}
406		if err != nil {
407			return u, c.fail(protocol.ExitFailure, "%v", err)
408		}
409		return u, -1
410	}
411	for _, name := range adds {
412		u, code := resolve(name)
413		if code >= 0 {
414			return code
415		}
416		if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil {
417			return c.fail(protocol.ExitFailure, "%v", err)
418		}
419	}
420	for _, name := range removes {
421		u, code := resolve(name)
422		if code >= 0 {
423			return code
424		}
425		if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil {
426			if errors.Is(err, store.ErrNotFound) {
427				return c.fail(protocol.ExitNotFound, "%s is not assigned", name)
428			}
429			return c.fail(protocol.ExitFailure, "%v", err)
430		}
431	}
432	updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
433	if err != nil {
434		return c.fail(protocol.ExitFailure, "%v", err)
435	}
436	return c.emit(map[string]any{"number": issue.Number, "assignees": updated.Assignees}, func(w io.Writer) {
437		fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Assignees, ", "))
438	})
439}