internal/control/issue.go
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}