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