internal/control/issue.go
503 lines · 17884 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 f, err := parseFlags(args, flagSpec{Values: []string{"--format", "--title", "--body", "--file"}, MaxPos: 1,
127 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]"})
128 if err != nil {
129 return c.fail(protocol.ExitUsage, "%v", err)
130 }
131 path, title, body, file, format := f.pos(0), f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
132 if path == "" || title == "" {
133 return c.fail(protocol.ExitUsage, "usage: issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]")
134 }
135 fmtName, err := markupFormat(format)
136 if err != nil {
137 return c.failErr(err)
138 }
139 if fmtName == "" {
140 fmtName = "md"
141 }
142 // Anyone who can read the repo can file an issue.
143 repo, code := resolveRepo(c, path, policy.CanRead)
144 if code >= 0 {
145 return code
146 }
147 if code := refuseArchived(c, repo); code >= 0 {
148 return code
149 }
150 b, err := bodyFrom(c, body, file)
151 if err != nil {
152 return c.failErr(err)
153 }
154 n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b, fmtName)
155 if err != nil {
156 return c.fail(protocol.ExitFailure, "%v", err)
157 }
158 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
159 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
160 notifyUsers(c, targets, issueSubject(repo, n, title),
161 notifyBody(c, fmt.Sprintf("opened issue #%d", n), b, fmt.Sprintf("%s/issues/%d", repo.Path(), n)))
162 }
163 return c.emit(map[string]any{"number": n}, func(w io.Writer) {
164 fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
165 })
166}
167
168func runIssueList(c *Ctx, args []string) int {
169 args, p, code := parsePageFlags(c, args, "issue", true)
170 if code >= 0 {
171 return code
172 }
173 const usage = "usage: issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>] [--author <user>] [--milestone <title>|none] [--limit <n>] [--cursor <c>]"
174 f := store.IssueFilter{State: "open"}
175 fl, err := parseFlags(args, flagSpec{Values: []string{"--state", "--label", "--assignee", "--author", "--milestone"}, MaxPos: 1, Usage: usage})
176 if err != nil {
177 return c.fail(protocol.ExitUsage, "%v", err)
178 }
179 path := fl.pos(0)
180 if fl.Has("--state") {
181 f.State = fl.Value("--state")
182 }
183 f.Label, f.Assignee, f.Author, f.Milestone = fl.Value("--label"), fl.Value("--assignee"), fl.Value("--author"), fl.Value("--milestone")
184 if path == "" || (f.State != "open" && f.State != "closed" && f.State != "all") {
185 return c.fail(protocol.ExitUsage, usage)
186 }
187 repo, code := resolveRepo(c, path, policy.CanRead)
188 if code >= 0 {
189 return code
190 }
191 f.Limit, f.Before = p.queryLimit(), p.keyInt()
192 issues, err := c.Store.QueryIssues(repo.ID, f)
193 if err != nil {
194 return c.fail(protocol.ExitFailure, "%v", err)
195 }
196 issues, next := trimPage(p, issues, "issue", func(i store.Issue) string {
197 return strconv.FormatInt(i.Number, 10)
198 })
199 var ds []issueOut
200 for _, i := range issues {
201 ds = append(ds, issueToOut(i, false))
202 }
203 return c.emitPage(p, ds, next, func(w io.Writer) {
204 for _, d := range ds {
205 fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", d.Number, d.State, d.Title, d.Author)
206 }
207 })
208}
209
210func runIssueShow(c *Ctx, args []string) int {
211 repo, issue, code := issueRef(c, args, policy.CanRead)
212 if code >= 0 {
213 return code
214 }
215 if len(args) != 2 {
216 return c.fail(protocol.ExitUsage, "usage: issue show <owner/name> <n>")
217 }
218 comments, err := c.Store.ListIssueComments(issue.ID)
219 if err != nil {
220 return c.fail(protocol.ExitFailure, "%v", err)
221 }
222 type commentOut struct {
223 Author string `json:"author"`
224 Body string `json:"body"`
225 BodyFormat string `json:"body_format,omitempty"`
226 CreatedAt string `json:"created_at"`
227 }
228 var cs []commentOut
229 for _, cm := range comments {
230 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
231 }
232 d := struct {
233 issueOut
234 Comments []commentOut `json:"comments,omitempty"`
235 }{issueToOut(issue, true), cs}
236 _ = repo
237 return c.emit(d, func(w io.Writer) {
238 fmt.Fprintf(w, "#%d %s [%s] by %s\n", d.Number, d.Title, d.State, d.Author)
239 if len(d.Labels) > 0 {
240 fmt.Fprintf(w, "labels: %s\n", strings.Join(d.Labels, ", "))
241 }
242 if len(d.Assignees) > 0 {
243 fmt.Fprintf(w, "assignees: %s\n", strings.Join(d.Assignees, ", "))
244 }
245 if d.Body != "" {
246 fmt.Fprintf(w, "\n%s\n", d.Body)
247 }
248 for _, cm := range cs {
249 fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
250 }
251 })
252}
253
254func runIssueComment(c *Ctx, args []string) int {
255 f, err := parseFlags(args, flagSpec{Values: []string{"--format", "--message", "--file"}, MaxPos: -1,
256 Usage: "issue comment <owner/name> <n> [--message <m> | --file -] [--format md|org]"})
257 if err != nil {
258 return c.fail(protocol.ExitUsage, "%v", err)
259 }
260 rest := f.Pos
261 message, file, format := f.Value("--message"), f.Value("--file"), f.Value("--format")
262 fmtName, err := markupFormat(format)
263 if err != nil {
264 return c.failErr(err)
265 }
266 if fmtName == "" {
267 fmtName = "md"
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.failErr(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, fmtName); 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 -/--format and authorizes: author or
334// write. A nil format means the stored markup format stays as it is.
335func editText(c *Ctx, args []string, kind string) (rest []string, title, body, format *string, code int) {
336 f, err := parseFlags(args, flagSpec{Values: []string{"--title", "--body", "--file", "--format"}, MaxPos: -1,
337 Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"})
338 if err != nil {
339 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err)
340 }
341 rest = f.Pos
342 titleV, bodyV, file, formatV := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
343 haveTitle, haveBody := f.Has("--title"), f.Has("--body")
344 if file != "" {
345 b, err := bodyFrom(c, "", file)
346 if err != nil {
347 return nil, nil, nil, nil, c.failErr(err)
348 }
349 bodyV, haveBody = b, true
350 }
351 fmtName, err := markupFormat(formatV)
352 if err != nil {
353 return nil, nil, nil, nil, c.failErr(err)
354 }
355 if !haveTitle && !haveBody && fmtName == "" {
356 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "usage: %s edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]", kind)
357 }
358 if haveTitle {
359 if strings.TrimSpace(titleV) == "" {
360 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "--title must not be empty")
361 }
362 title = &titleV
363 }
364 if haveBody {
365 body = &bodyV
366 }
367 if fmtName != "" {
368 format = &fmtName
369 }
370 return rest, title, body, format, -1
371}
372
373func runIssueEdit(c *Ctx, args []string) int {
374 rest, title, body, format, code := editText(c, args, "issue")
375 if code >= 0 {
376 return code
377 }
378 repo, issue, code := issueRef(c, rest, policy.CanRead)
379 if code >= 0 {
380 return code
381 }
382 if code := refuseArchived(c, repo); code >= 0 {
383 return code
384 }
385 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
386 if err != nil {
387 return c.fail(protocol.ExitFailure, "%v", err)
388 }
389 if issue.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
390 return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this issue")
391 }
392 if err := c.Store.UpdateIssueText(issue.ID, title, body, format); err != nil {
393 return c.fail(protocol.ExitFailure, "%v", err)
394 }
395 return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
396 fmt.Fprintf(w, "edited %s#%d\n", repo.Path(), issue.Number)
397 })
398}
399
400func runIssueClose(c *Ctx, args []string) int { return setIssueState(c, args, "closed") }
401func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") }
402
403// addRemoveFlags parses repeated --add/--remove flags.
404func addRemoveFlags(args []string) (rest, adds, removes []string, err error) {
405 f, err := parseFlags(args, flagSpec{Multi: []string{"--add", "--remove"}, MaxPos: -1})
406 if err != nil {
407 return nil, nil, nil, err
408 }
409 rest, adds, removes = f.Pos, f.List("--add"), f.List("--remove")
410 return rest, adds, removes, nil
411}
412
413func runIssueLabel(c *Ctx, args []string) int {
414 rest, adds, removes, err := addRemoveFlags(args)
415 if err != nil {
416 return c.failErr(err)
417 }
418 if len(adds)+len(removes) == 0 {
419 return c.fail(protocol.ExitUsage, "usage: issue label <owner/name> <n> [--add <l>]... [--remove <l>]...")
420 }
421 repo, issue, code := issueRef(c, rest, policy.CanWrite)
422 if code >= 0 {
423 return code
424 }
425 if code := refuseArchived(c, repo); code >= 0 {
426 return code
427 }
428 for _, l := range adds {
429 if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, true); err != nil {
430 return c.fail(protocol.ExitFailure, "%v", err)
431 }
432 }
433 for _, l := range removes {
434 if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, false); err != nil {
435 if errors.Is(err, store.ErrNotFound) {
436 return c.fail(protocol.ExitNotFound, "%v", err)
437 }
438 return c.fail(protocol.ExitFailure, "%v", err)
439 }
440 }
441 updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
442 if err != nil {
443 return c.fail(protocol.ExitFailure, "%v", err)
444 }
445 return c.emit(map[string]any{"number": issue.Number, "labels": updated.Labels}, func(w io.Writer) {
446 fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Labels, ", "))
447 })
448}
449
450func runIssueAssign(c *Ctx, args []string) int {
451 rest, adds, removes, err := addRemoveFlags(args)
452 if err != nil {
453 return c.failErr(err)
454 }
455 if len(adds)+len(removes) == 0 {
456 return c.fail(protocol.ExitUsage, "usage: issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...")
457 }
458 repo, issue, code := issueRef(c, rest, policy.CanWrite)
459 if code >= 0 {
460 return code
461 }
462 if code := refuseArchived(c, repo); code >= 0 {
463 return code
464 }
465 resolve := func(name string) (store.User, int) {
466 u, err := c.Store.UserByUsername(name)
467 if errors.Is(err, store.ErrNotFound) {
468 return u, c.fail(protocol.ExitNotFound, "no such user %q", name)
469 }
470 if err != nil {
471 return u, c.fail(protocol.ExitFailure, "%v", err)
472 }
473 return u, -1
474 }
475 for _, name := range adds {
476 u, code := resolve(name)
477 if code >= 0 {
478 return code
479 }
480 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil {
481 return c.fail(protocol.ExitFailure, "%v", err)
482 }
483 }
484 for _, name := range removes {
485 u, code := resolve(name)
486 if code >= 0 {
487 return code
488 }
489 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil {
490 if errors.Is(err, store.ErrNotFound) {
491 return c.fail(protocol.ExitNotFound, "%s is not assigned", name)
492 }
493 return c.fail(protocol.ExitFailure, "%v", err)
494 }
495 }
496 updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
497 if err != nil {
498 return c.fail(protocol.ExitFailure, "%v", err)
499 }
500 return c.emit(map[string]any{"number": issue.Number, "assignees": updated.Assignees}, func(w io.Writer) {
501 fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Assignees, ", "))
502 })
503}