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