internal/control/issue.go
574 lines · 18589 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 -] [--format md|org]",
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 -] [--format md|org]",
27 ReadsStdin: true, Run: runIssueEdit})
28 register(Command{Path: []string{"issue", "comment"},
29 Summary: "comment: issue comment <owner/name> <n> [--message <m> | --file -] [--format md|org]",
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
79// markupFormat normalizes a --format value. Empty means the caller did not ask,
80// which the caller turns into "md" on create or "unchanged" on edit.
81func markupFormat(v string) (string, error) {
82 switch strings.ToLower(strings.TrimSpace(v)) {
83 case "":
84 return "", nil
85 case "md", "markdown":
86 return "md", nil
87 case "org", "org-mode":
88 return "org", nil
89 }
90 return "", fmt.Errorf("unknown --format %q (want md or org)", v)
91}
92
93type issueOut struct {
94 Number int64 `json:"number"`
95 Title string `json:"title"`
96 State string `json:"state"`
97 Author string `json:"author"`
98 Milestone string `json:"milestone,omitempty"`
99 Labels []string `json:"labels,omitempty"`
100 Assignees []string `json:"assignees,omitempty"`
101 Body string `json:"body,omitempty"`
102 BodyFormat string `json:"body_format,omitempty"`
103 CreatedAt string `json:"created_at"`
104}
105
106func issueToOut(i store.Issue, withBody bool) issueOut {
107 o := issueOut{Number: i.Number, Title: i.Title, State: i.State, Author: i.Author,
108 Milestone: i.Milestone, Labels: i.Labels, Assignees: i.Assignees, CreatedAt: i.CreatedAt}
109 if withBody {
110 o.Body = i.Body
111 o.BodyFormat = i.BodyFormat
112 }
113 return o
114}
115
116func runIssueCreate(c *Ctx, args []string) int {
117 var path, title, body, file, format string
118 for i := 0; i < len(args); i++ {
119 switch args[i] {
120 case "--format":
121 if i+1 >= len(args) {
122 return c.fail(protocol.ExitUsage, "--format requires a value")
123 }
124 format = args[i+1]
125 i++
126 case "--title":
127 if i+1 >= len(args) {
128 return c.fail(protocol.ExitUsage, "--title requires a value")
129 }
130 title = args[i+1]
131 i++
132 case "--body":
133 if i+1 >= len(args) {
134 return c.fail(protocol.ExitUsage, "--body requires a value")
135 }
136 body = args[i+1]
137 i++
138 case "--file":
139 if i+1 >= len(args) {
140 return c.fail(protocol.ExitUsage, "--file requires a value")
141 }
142 file = args[i+1]
143 i++
144 default:
145 if path != "" {
146 return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
147 }
148 path = args[i]
149 }
150 }
151 if path == "" || title == "" {
152 return c.fail(protocol.ExitUsage, "usage: issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]")
153 }
154 fmtName, err := markupFormat(format)
155 if err != nil {
156 return c.fail(protocol.ExitUsage, "%v", err)
157 }
158 if fmtName == "" {
159 fmtName = "md"
160 }
161 // Anyone who can read the repo can file an issue.
162 repo, code := resolveRepo(c, path, policy.CanRead)
163 if code >= 0 {
164 return code
165 }
166 if code := refuseArchived(c, repo); code >= 0 {
167 return code
168 }
169 b, err := bodyFrom(c, body, file)
170 if err != nil {
171 return c.fail(protocol.ExitUsage, "%v", err)
172 }
173 n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b, fmtName)
174 if err != nil {
175 return c.fail(protocol.ExitFailure, "%v", err)
176 }
177 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
178 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
179 notifyUsers(c, targets, issueSubject(repo, n, title),
180 notifyBody(c, fmt.Sprintf("opened issue #%d", n), b, fmt.Sprintf("%s/issues/%d", repo.Path(), n)))
181 }
182 return c.emit(map[string]any{"number": n}, func(w io.Writer) {
183 fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
184 })
185}
186
187func runIssueList(c *Ctx, args []string) int {
188 args, p, code := parsePageFlags(c, args, "issue", true)
189 if code >= 0 {
190 return code
191 }
192 state := "open"
193 var path string
194 for i := 0; i < len(args); i++ {
195 switch args[i] {
196 case "--state":
197 if i+1 >= len(args) {
198 return c.fail(protocol.ExitUsage, "--state requires open|closed|all")
199 }
200 state = args[i+1]
201 i++
202 default:
203 if path != "" {
204 return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
205 }
206 path = args[i]
207 }
208 }
209 if path == "" || (state != "open" && state != "closed" && state != "all") {
210 return c.fail(protocol.ExitUsage, "usage: issue list <owner/name> [--state open|closed|all] [--limit <n>] [--cursor <c>]")
211 }
212 repo, code := resolveRepo(c, path, policy.CanRead)
213 if code >= 0 {
214 return code
215 }
216 issues, err := c.Store.ListIssues(repo.ID, state, p.queryLimit(), p.keyInt())
217 if err != nil {
218 return c.fail(protocol.ExitFailure, "%v", err)
219 }
220 issues, next := trimPage(p, issues, "issue", func(i store.Issue) string {
221 return strconv.FormatInt(i.Number, 10)
222 })
223 var ds []issueOut
224 for _, i := range issues {
225 ds = append(ds, issueToOut(i, false))
226 }
227 return c.emitPage(p, ds, next, func(w io.Writer) {
228 for _, d := range ds {
229 fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", d.Number, d.State, d.Title, d.Author)
230 }
231 })
232}
233
234func runIssueShow(c *Ctx, args []string) int {
235 repo, issue, code := issueRef(c, args, policy.CanRead)
236 if code >= 0 {
237 return code
238 }
239 if len(args) != 2 {
240 return c.fail(protocol.ExitUsage, "usage: issue show <owner/name> <n>")
241 }
242 comments, err := c.Store.ListIssueComments(issue.ID)
243 if err != nil {
244 return c.fail(protocol.ExitFailure, "%v", err)
245 }
246 type commentOut struct {
247 Author string `json:"author"`
248 Body string `json:"body"`
249 BodyFormat string `json:"body_format,omitempty"`
250 CreatedAt string `json:"created_at"`
251 }
252 var cs []commentOut
253 for _, cm := range comments {
254 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
255 }
256 d := struct {
257 issueOut
258 Comments []commentOut `json:"comments,omitempty"`
259 }{issueToOut(issue, true), cs}
260 _ = repo
261 return c.emit(d, func(w io.Writer) {
262 fmt.Fprintf(w, "#%d %s [%s] by %s\n", d.Number, d.Title, d.State, d.Author)
263 if len(d.Labels) > 0 {
264 fmt.Fprintf(w, "labels: %s\n", strings.Join(d.Labels, ", "))
265 }
266 if len(d.Assignees) > 0 {
267 fmt.Fprintf(w, "assignees: %s\n", strings.Join(d.Assignees, ", "))
268 }
269 if d.Body != "" {
270 fmt.Fprintf(w, "\n%s\n", d.Body)
271 }
272 for _, cm := range cs {
273 fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
274 }
275 })
276}
277
278func runIssueComment(c *Ctx, args []string) int {
279 var rest []string
280 var message, file, format string
281 for i := 0; i < len(args); i++ {
282 switch args[i] {
283 case "--format":
284 if i+1 >= len(args) {
285 return c.fail(protocol.ExitUsage, "--format requires a value")
286 }
287 format = args[i+1]
288 i++
289 case "--message":
290 if i+1 >= len(args) {
291 return c.fail(protocol.ExitUsage, "--message requires a value")
292 }
293 message = args[i+1]
294 i++
295 case "--file":
296 if i+1 >= len(args) {
297 return c.fail(protocol.ExitUsage, "--file requires a value")
298 }
299 file = args[i+1]
300 i++
301 default:
302 rest = append(rest, args[i])
303 }
304 }
305 fmtName, err := markupFormat(format)
306 if err != nil {
307 return c.fail(protocol.ExitUsage, "%v", err)
308 }
309 if fmtName == "" {
310 fmtName = "md"
311 }
312 repo, issue, code := issueRef(c, rest, policy.CanRead)
313 if code >= 0 {
314 return code
315 }
316 if code := refuseArchived(c, repo); code >= 0 {
317 return code
318 }
319 body, err := bodyFrom(c, message, file)
320 if err != nil {
321 return c.fail(protocol.ExitUsage, "%v", err)
322 }
323 if strings.TrimSpace(body) == "" {
324 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
325 }
326 if err := c.Store.AddIssueComment(issue.ID, c.User.ID, body, fmtName); err != nil {
327 return c.fail(protocol.ExitFailure, "%v", err)
328 }
329 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, issue.Number))
330 if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
331 notifyUsers(c, parts, issueSubject(repo, issue.Number, issue.Title),
332 notifyBody(c, fmt.Sprintf("commented on #%d", issue.Number), body, fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)))
333 }
334 return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
335 fmt.Fprintf(w, "commented on %s#%d\n", repo.Path(), issue.Number)
336 })
337}
338
339func setIssueState(c *Ctx, args []string, state string) int {
340 // Author may close/reopen their own issue; otherwise write access.
341 repo, issue, code := issueRef(c, args, policy.CanRead)
342 if code >= 0 {
343 return code
344 }
345 if code := refuseArchived(c, repo); code >= 0 {
346 return code
347 }
348 if len(args) != 2 {
349 return c.fail(protocol.ExitUsage, "usage: issue %s <owner/name> <n>", state)
350 }
351 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
352 if err != nil {
353 return c.fail(protocol.ExitFailure, "%v", err)
354 }
355 if issue.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
356 return c.fail(protocol.ExitDenied, "only the author or users with write access can %s this issue",
357 map[string]string{"open": "reopen", "closed": "close"}[state])
358 }
359 if issue.State == state {
360 return c.fail(protocol.ExitUsage, "issue #%d is already %s", issue.Number, state)
361 }
362 if err := c.Store.SetIssueState(issue.ID, state); err != nil {
363 return c.fail(protocol.ExitFailure, "%v", err)
364 }
365 c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number))
366 if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
367 verb := map[string]string{"open": "reopened", "closed": "closed"}[state]
368 notifyUsers(c, parts, issueSubject(repo, issue.Number, issue.Title),
369 notifyBody(c, fmt.Sprintf("%s #%d", verb, issue.Number), "", fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)))
370 }
371 return c.emit(map[string]any{"number": issue.Number, "state": state}, func(w io.Writer) {
372 fmt.Fprintf(w, "%s#%d is now %s\n", repo.Path(), issue.Number, state)
373 })
374}
375
376// editText parses --title/--body/--file -/--format and authorizes: author or
377// write. A nil format means the stored markup format stays as it is.
378func editText(c *Ctx, args []string, kind string) (rest []string, title, body, format *string, code int) {
379 var titleV, bodyV, file, formatV string
380 haveTitle, haveBody := false, false
381 for i := 0; i < len(args); i++ {
382 switch args[i] {
383 case "--title", "--body", "--file", "--format":
384 if i+1 >= len(args) {
385 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%s requires a value", args[i])
386 }
387 switch args[i] {
388 case "--title":
389 titleV, haveTitle = args[i+1], true
390 case "--body":
391 bodyV, haveBody = args[i+1], true
392 case "--file":
393 file = args[i+1]
394 case "--format":
395 formatV = args[i+1]
396 }
397 i++
398 default:
399 rest = append(rest, args[i])
400 }
401 }
402 if file != "" {
403 b, err := bodyFrom(c, "", file)
404 if err != nil {
405 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err)
406 }
407 bodyV, haveBody = b, true
408 }
409 fmtName, err := markupFormat(formatV)
410 if err != nil {
411 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err)
412 }
413 if !haveTitle && !haveBody && fmtName == "" {
414 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "usage: %s edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]", kind)
415 }
416 if haveTitle {
417 if strings.TrimSpace(titleV) == "" {
418 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "--title must not be empty")
419 }
420 title = &titleV
421 }
422 if haveBody {
423 body = &bodyV
424 }
425 if fmtName != "" {
426 format = &fmtName
427 }
428 return rest, title, body, format, -1
429}
430
431func runIssueEdit(c *Ctx, args []string) int {
432 rest, title, body, format, code := editText(c, args, "issue")
433 if code >= 0 {
434 return code
435 }
436 repo, issue, code := issueRef(c, rest, policy.CanRead)
437 if code >= 0 {
438 return code
439 }
440 if code := refuseArchived(c, repo); code >= 0 {
441 return code
442 }
443 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
444 if err != nil {
445 return c.fail(protocol.ExitFailure, "%v", err)
446 }
447 if issue.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
448 return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this issue")
449 }
450 if err := c.Store.UpdateIssueText(issue.ID, title, body, format); err != nil {
451 return c.fail(protocol.ExitFailure, "%v", err)
452 }
453 return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
454 fmt.Fprintf(w, "edited %s#%d\n", repo.Path(), issue.Number)
455 })
456}
457
458func runIssueClose(c *Ctx, args []string) int { return setIssueState(c, args, "closed") }
459func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") }
460
461// addRemoveFlags parses repeated --add/--remove flags.
462func addRemoveFlags(args []string) (rest, adds, removes []string, err error) {
463 for i := 0; i < len(args); i++ {
464 switch args[i] {
465 case "--add":
466 if i+1 >= len(args) {
467 return nil, nil, nil, errors.New("--add requires a value")
468 }
469 adds = append(adds, args[i+1])
470 i++
471 case "--remove":
472 if i+1 >= len(args) {
473 return nil, nil, nil, errors.New("--remove requires a value")
474 }
475 removes = append(removes, args[i+1])
476 i++
477 default:
478 rest = append(rest, args[i])
479 }
480 }
481 return rest, adds, removes, nil
482}
483
484func runIssueLabel(c *Ctx, args []string) int {
485 rest, adds, removes, err := addRemoveFlags(args)
486 if err != nil {
487 return c.fail(protocol.ExitUsage, "%v", err)
488 }
489 if len(adds)+len(removes) == 0 {
490 return c.fail(protocol.ExitUsage, "usage: issue label <owner/name> <n> [--add <l>]... [--remove <l>]...")
491 }
492 repo, issue, code := issueRef(c, rest, policy.CanWrite)
493 if code >= 0 {
494 return code
495 }
496 if code := refuseArchived(c, repo); code >= 0 {
497 return code
498 }
499 for _, l := range adds {
500 if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, true); err != nil {
501 return c.fail(protocol.ExitFailure, "%v", err)
502 }
503 }
504 for _, l := range removes {
505 if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, false); err != nil {
506 if errors.Is(err, store.ErrNotFound) {
507 return c.fail(protocol.ExitNotFound, "%v", err)
508 }
509 return c.fail(protocol.ExitFailure, "%v", err)
510 }
511 }
512 updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
513 if err != nil {
514 return c.fail(protocol.ExitFailure, "%v", err)
515 }
516 return c.emit(map[string]any{"number": issue.Number, "labels": updated.Labels}, func(w io.Writer) {
517 fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Labels, ", "))
518 })
519}
520
521func runIssueAssign(c *Ctx, args []string) int {
522 rest, adds, removes, err := addRemoveFlags(args)
523 if err != nil {
524 return c.fail(protocol.ExitUsage, "%v", err)
525 }
526 if len(adds)+len(removes) == 0 {
527 return c.fail(protocol.ExitUsage, "usage: issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...")
528 }
529 repo, issue, code := issueRef(c, rest, policy.CanWrite)
530 if code >= 0 {
531 return code
532 }
533 if code := refuseArchived(c, repo); code >= 0 {
534 return code
535 }
536 resolve := func(name string) (store.User, int) {
537 u, err := c.Store.UserByUsername(name)
538 if errors.Is(err, store.ErrNotFound) {
539 return u, c.fail(protocol.ExitNotFound, "no such user %q", name)
540 }
541 if err != nil {
542 return u, c.fail(protocol.ExitFailure, "%v", err)
543 }
544 return u, -1
545 }
546 for _, name := range adds {
547 u, code := resolve(name)
548 if code >= 0 {
549 return code
550 }
551 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil {
552 return c.fail(protocol.ExitFailure, "%v", err)
553 }
554 }
555 for _, name := range removes {
556 u, code := resolve(name)
557 if code >= 0 {
558 return code
559 }
560 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil {
561 if errors.Is(err, store.ErrNotFound) {
562 return c.fail(protocol.ExitNotFound, "%s is not assigned", name)
563 }
564 return c.fail(protocol.ExitFailure, "%v", err)
565 }
566 }
567 updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
568 if err != nil {
569 return c.fail(protocol.ExitFailure, "%v", err)
570 }
571 return c.emit(map[string]any{"number": issue.Number, "assignees": updated.Assignees}, func(w io.Writer) {
572 fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Assignees, ", "))
573 })
574}