krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

repo-descriptions: internal/control/issue.go · raw

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