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