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