internal/control/issue.go
469 lines · 16845 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] [--search <text>] [--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 repo, n, code := refArgs(c, args, perm, "issue")
53 if code >= 0 {
54 return repo, store.Issue{}, code
55 }
56 issue, err := c.Store.IssueByNumber(repo.ID, n)
57 if errors.Is(err, store.ErrNotFound) {
58 return repo, issue, c.fail(protocol.ExitNotFound, "issue #%d not found in %s", n, repo.Path())
59 }
60 if err != nil {
61 return repo, issue, c.fail(protocol.ExitFailure, "%v", err)
62 }
63 return repo, issue, -1
64}
65
66// bodyFrom resolves --body/--message inline text or --file - (stdin).
67func bodyFrom(c *Ctx, inline, file string) (string, error) {
68 if inline != "" && file != "" {
69 return "", errors.New("give either an inline message or --file -, not both")
70 }
71 if file != "" {
72 if file != "-" {
73 return "", errors.New("--file only supports - (stdin) over ssh")
74 }
75 raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxBodyBytes))
76 return string(raw), err
77 }
78 return inline, nil
79}
80
81// markupFormat normalizes a --format value. Empty means the caller did not ask,
82// which the caller turns into "md" on create or "unchanged" on edit.
83func markupFormat(v string) (string, error) {
84 switch strings.ToLower(strings.TrimSpace(v)) {
85 case "":
86 return "", nil
87 case "md", "markdown":
88 return "md", nil
89 case "org", "org-mode":
90 return "org", nil
91 }
92 return "", fmt.Errorf("unknown --format %q (want md or org)", v)
93}
94
95type issueOut struct {
96 Number int64 `json:"number"`
97 Title string `json:"title"`
98 State string `json:"state"`
99 Author string `json:"author"`
100 Milestone string `json:"milestone,omitempty"`
101 Labels []string `json:"labels,omitempty"`
102 Assignees []string `json:"assignees,omitempty"`
103 Body string `json:"body,omitempty"`
104 BodyFormat string `json:"body_format,omitempty"`
105 CreatedAt string `json:"created_at"`
106}
107
108func issueToOut(i store.Issue, withBody bool) issueOut {
109 o := issueOut{Number: i.Number, Title: i.Title, State: i.State, Author: i.Author,
110 Milestone: i.Milestone, Labels: i.Labels, Assignees: i.Assignees, CreatedAt: i.CreatedAt}
111 if withBody {
112 o.Body = i.Body
113 o.BodyFormat = i.BodyFormat
114 }
115 return o
116}
117
118func runIssueCreate(c *Ctx, args []string) int {
119 f, err := parseFlags(args, flagSpec{Values: []string{"--format", "--title", "--body", "--file"}, MaxPos: 1,
120 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]"})
121 if err != nil {
122 return c.fail(protocol.ExitUsage, "%v", err)
123 }
124 path, title, body, file, format := f.pos(0), f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
125 if path == "" || title == "" {
126 return c.fail(protocol.ExitUsage, "usage: issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]")
127 }
128 fmtName, err := markupFormat(format)
129 if err != nil {
130 return c.failErr(err)
131 }
132 if fmtName == "" {
133 fmtName = "md"
134 }
135 // Anyone who can read the repo can file an issue.
136 repo, code := resolveRepo(c, path, policy.CanRead)
137 if code >= 0 {
138 return code
139 }
140 if code := refuseArchived(c, repo); code >= 0 {
141 return code
142 }
143 b, err := bodyFrom(c, body, file)
144 if err != nil {
145 return c.failErr(err)
146 }
147 n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b, fmtName)
148 if err != nil {
149 return c.fail(protocol.ExitFailure, "%v", err)
150 }
151 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
152 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
153 notify(c, targets, notice{repo: repo, kind: "issue",
154 subject: issueSubject(repo, n, title),
155 action: fmt.Sprintf("opened issue #%d", n),
156 excerpt: b, path: fmt.Sprintf("%s/issues/%d", repo.Path(), n)})
157 }
158 if issue, err := c.Store.IssueByNumber(repo.ID, n); err == nil {
159 notifyMentions(c, repo, issueThread, issue.ID, n, title, b)
160 }
161 return c.emit(Created{Number: n}, func(w io.Writer) {
162 fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
163 })
164}
165
166func runIssueList(c *Ctx, args []string) int {
167 args, p, code := parsePageFlags(c, args, "issue", true)
168 if code >= 0 {
169 return code
170 }
171 const usage = "usage: issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>] [--author <user>] [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>]"
172 f := store.IssueFilter{State: "open"}
173 fl, err := parseFlags(args, flagSpec{Values: []string{"--state", "--label", "--assignee", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: usage})
174 if err != nil {
175 return c.fail(protocol.ExitUsage, "%v", err)
176 }
177 path := fl.pos(0)
178 if fl.Has("--state") {
179 f.State = fl.Value("--state")
180 }
181 f.Label, f.Assignee, f.Author, f.Milestone = fl.Value("--label"), fl.Value("--assignee"), fl.Value("--author"), fl.Value("--milestone")
182 f.Search = fl.Value("--search")
183 if fl.Has("--search") {
184 if err := validQuery(f.Search); err != nil {
185 return c.failErr(err)
186 }
187 }
188 if path == "" || (f.State != "open" && f.State != "closed" && f.State != "all") {
189 return c.fail(protocol.ExitUsage, usage)
190 }
191 repo, code := resolveRepo(c, path, policy.CanRead)
192 if code >= 0 {
193 return code
194 }
195 f.Limit, f.Before = p.queryLimit(), p.keyInt()
196 issues, err := c.Store.QueryIssues(repo.ID, f)
197 if err != nil {
198 return c.fail(protocol.ExitFailure, "%v", err)
199 }
200 issues, next := trimPage(p, issues, "issue", func(i store.Issue) string {
201 return strconv.FormatInt(i.Number, 10)
202 })
203 var ds []issueOut
204 for _, i := range issues {
205 ds = append(ds, issueToOut(i, false))
206 }
207 return c.emitPage(p, ds, next, func(w io.Writer) {
208 for _, d := range ds {
209 fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", d.Number, d.State, d.Title, d.Author)
210 }
211 })
212}
213
214func runIssueShow(c *Ctx, args []string) int {
215 repo, issue, code := issueRef(c, args, policy.CanRead)
216 if code >= 0 {
217 return code
218 }
219 if len(args) != 2 {
220 return c.fail(protocol.ExitUsage, "usage: issue show <owner/name> <n>")
221 }
222 comments, err := c.Store.ListIssueComments(issue.ID)
223 if err != nil {
224 return c.fail(protocol.ExitFailure, "%v", err)
225 }
226 var cs []commentOut
227 for _, cm := range comments {
228 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
229 }
230 d := IssueShow{issueOut: issueToOut(issue, true), Comments: cs}
231 _ = repo
232 return c.emit(d, func(w io.Writer) {
233 fmt.Fprintf(w, "#%d %s [%s] by %s\n", d.Number, d.Title, d.State, d.Author)
234 if len(d.Labels) > 0 {
235 fmt.Fprintf(w, "labels: %s\n", strings.Join(d.Labels, ", "))
236 }
237 if len(d.Assignees) > 0 {
238 fmt.Fprintf(w, "assignees: %s\n", strings.Join(d.Assignees, ", "))
239 }
240 if d.Body != "" {
241 fmt.Fprintf(w, "\n%s\n", d.Body)
242 }
243 for _, cm := range cs {
244 fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
245 }
246 })
247}
248
249func runIssueComment(c *Ctx, args []string) int {
250 return runComment(c, args, issueThread, "issue",
251 func(rest []string) (store.Repo, int64, int64, string, int) {
252 repo, issue, code := issueRef(c, rest, policy.CanRead)
253 return repo, issue.ID, issue.Number, issue.Title, code
254 },
255 c.Store.AddIssueComment, c.Store.IssueParticipants)
256}
257
258func setIssueState(c *Ctx, args []string, state string) int {
259 // Author may close/reopen their own issue; otherwise write access.
260 repo, issue, code := issueRef(c, args, policy.CanRead)
261 if code >= 0 {
262 return code
263 }
264 if code := refuseArchived(c, repo); code >= 0 {
265 return code
266 }
267 if len(args) != 2 {
268 return c.fail(protocol.ExitUsage, "usage: issue %s <owner/name> <n>", state)
269 }
270 if code := authorOrWrite(c, repo, issue.Author, map[string]string{"open": "reopen", "closed": "close"}[state]+" this issue"); code >= 0 {
271 return code
272 }
273 if issue.State == state {
274 return c.fail(protocol.ExitUsage, "issue #%d is already %s", issue.Number, state)
275 }
276 if err := c.Store.SetIssueState(issue.ID, state); err != nil {
277 return c.fail(protocol.ExitFailure, "%v", err)
278 }
279 c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number))
280 if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
281 verb := map[string]string{"open": "reopened", "closed": "closed"}[state]
282 notify(c, parts, notice{repo: repo, kind: "issue",
283 subject: issueSubject(repo, issue.Number, issue.Title),
284 action: fmt.Sprintf("%s #%d", verb, issue.Number),
285 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
286 }
287 return c.emit(map[string]any{"number": issue.Number, "state": state}, func(w io.Writer) {
288 fmt.Fprintf(w, "%s#%d is now %s\n", repo.Path(), issue.Number, state)
289 })
290}
291
292// editText parses --title/--body/--file -/--format and authorizes: author or
293// write. A nil format means the stored markup format stays as it is.
294func editText(c *Ctx, args []string, kind string) (rest []string, title, body, format *string, code int) {
295 f, err := parseFlags(args, flagSpec{Values: []string{"--title", "--body", "--file", "--format"}, MaxPos: -1,
296 Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"})
297 if err != nil {
298 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err)
299 }
300 rest = f.Pos
301 titleV, bodyV, file, formatV := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
302 haveTitle, haveBody := f.Has("--title"), f.Has("--body")
303 if file != "" {
304 b, err := bodyFrom(c, "", file)
305 if err != nil {
306 return nil, nil, nil, nil, c.failErr(err)
307 }
308 bodyV, haveBody = b, true
309 }
310 fmtName, err := markupFormat(formatV)
311 if err != nil {
312 return nil, nil, nil, nil, c.failErr(err)
313 }
314 if !haveTitle && !haveBody && fmtName == "" {
315 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "usage: %s edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]", kind)
316 }
317 if haveTitle {
318 if strings.TrimSpace(titleV) == "" {
319 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "--title must not be empty")
320 }
321 title = &titleV
322 }
323 if haveBody {
324 body = &bodyV
325 }
326 if fmtName != "" {
327 format = &fmtName
328 }
329 return rest, title, body, format, -1
330}
331
332func runIssueEdit(c *Ctx, args []string) int {
333 rest, title, body, format, code := editText(c, args, "issue")
334 if code >= 0 {
335 return code
336 }
337 repo, issue, code := issueRef(c, rest, policy.CanRead)
338 if code >= 0 {
339 return code
340 }
341 if code := refuseArchived(c, repo); code >= 0 {
342 return code
343 }
344 if code := authorOrWrite(c, repo, issue.Author, "edit this issue"); code >= 0 {
345 return code
346 }
347 if err := c.Store.UpdateIssueText(issue.ID, title, body, format); err != nil {
348 return c.fail(protocol.ExitFailure, "%v", err)
349 }
350 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.edited", fmt.Sprintf(`{"number":%d}`, issue.Number))
351 if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
352 notify(c, parts, notice{repo: repo, kind: "issue",
353 subject: issueSubject(repo, issue.Number, issue.Title),
354 action: fmt.Sprintf("edited #%d", issue.Number),
355 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
356 }
357 return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
358 fmt.Fprintf(w, "edited %s#%d\n", repo.Path(), issue.Number)
359 })
360}
361
362func runIssueClose(c *Ctx, args []string) int { return setIssueState(c, args, "closed") }
363func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") }
364
365// addRemoveFlags parses repeated --add/--remove flags.
366func addRemoveFlags(args []string) (rest, adds, removes []string, err error) {
367 f, err := parseFlags(args, flagSpec{Multi: []string{"--add", "--remove"}, MaxPos: -1})
368 if err != nil {
369 return nil, nil, nil, err
370 }
371 rest, adds, removes = f.Pos, f.List("--add"), f.List("--remove")
372 return rest, adds, removes, nil
373}
374
375func runIssueLabel(c *Ctx, args []string) int {
376 rest, adds, removes, err := addRemoveFlags(args)
377 if err != nil {
378 return c.failErr(err)
379 }
380 if len(adds)+len(removes) == 0 {
381 return c.fail(protocol.ExitUsage, "usage: issue label <owner/name> <n> [--add <l>]... [--remove <l>]...")
382 }
383 repo, issue, code := issueRef(c, rest, policy.CanWrite)
384 if code >= 0 {
385 return code
386 }
387 if code := refuseArchived(c, repo); code >= 0 {
388 return code
389 }
390 for _, l := range adds {
391 if err := c.Store.SetIssueLabel(repo, issue.ID, l, true); err != nil {
392 return c.fail(protocol.ExitFailure, "%v", err)
393 }
394 }
395 for _, l := range removes {
396 if err := c.Store.SetIssueLabel(repo, issue.ID, l, false); err != nil {
397 if errors.Is(err, store.ErrNotFound) {
398 return c.fail(protocol.ExitNotFound, "%v", err)
399 }
400 return c.fail(protocol.ExitFailure, "%v", err)
401 }
402 }
403 updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
404 if err != nil {
405 return c.fail(protocol.ExitFailure, "%v", err)
406 }
407 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.labeled",
408 fmt.Sprintf(`{"number":%d,"labels":%s}`, issue.Number, jsonStrings(updated.Labels)))
409 return c.emit(map[string]any{"number": issue.Number, "labels": updated.Labels}, func(w io.Writer) {
410 fmt.Fprintf(w, "labels on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Labels, ", "))
411 })
412}
413
414func runIssueAssign(c *Ctx, args []string) int {
415 rest, adds, removes, err := addRemoveFlags(args)
416 if err != nil {
417 return c.failErr(err)
418 }
419 if len(adds)+len(removes) == 0 {
420 return c.fail(protocol.ExitUsage, "usage: issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...")
421 }
422 repo, issue, code := issueRef(c, rest, policy.CanWrite)
423 if code >= 0 {
424 return code
425 }
426 if code := refuseArchived(c, repo); code >= 0 {
427 return code
428 }
429 resolve := func(name string) (store.User, int) {
430 u, err := c.Store.UserByUsername(name)
431 if errors.Is(err, store.ErrNotFound) {
432 return u, c.fail(protocol.ExitNotFound, "no such user %q", name)
433 }
434 if err != nil {
435 return u, c.fail(protocol.ExitFailure, "%v", err)
436 }
437 return u, -1
438 }
439 for _, name := range adds {
440 u, code := resolve(name)
441 if code >= 0 {
442 return code
443 }
444 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil {
445 return c.fail(protocol.ExitFailure, "%v", err)
446 }
447 }
448 for _, name := range removes {
449 u, code := resolve(name)
450 if code >= 0 {
451 return code
452 }
453 if err := c.Store.SetIssueAssignee(issue.ID, u.ID, false); err != nil {
454 if errors.Is(err, store.ErrNotFound) {
455 return c.fail(protocol.ExitNotFound, "%s is not assigned", name)
456 }
457 return c.fail(protocol.ExitFailure, "%v", err)
458 }
459 }
460 updated, err := c.Store.IssueByNumber(repo.ID, issue.Number)
461 if err != nil {
462 return c.fail(protocol.ExitFailure, "%v", err)
463 }
464 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.assigned",
465 fmt.Sprintf(`{"number":%d,"assignees":%s}`, issue.Number, jsonStrings(updated.Assignees)))
466 return c.emit(map[string]any{"number": issue.Number, "assignees": updated.Assignees}, func(w io.Writer) {
467 fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Assignees, ", "))
468 })
469}