1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "slices"
12 "strconv"
13 "strings"
14
15 "connectrpc.com/connect"
16 "github.com/spf13/cobra"
17
18 "bnema.dev/ermite-api/gen/ermite"
19 "bnema.dev/ermite/internal/adapters/client"
20)
21
22const statePending = "pending"
23
24func issueStateName(s ermite.IssueState) string {
25 switch s {
26 case ermite.IssueState_ISSUE_STATE_OPEN:
27 return stateOpen
28 case ermite.IssueState_ISSUE_STATE_CLOSED:
29 return stateClosed
30 case ermite.IssueState_ISSUE_STATE_PENDING:
31 return statePending
32 case ermite.IssueState_ISSUE_STATE_UNSPECIFIED:
33 }
34 return "unknown"
35}
36
37func parseIssueState(s string) (ermite.IssueState, error) {
38 switch strings.ToLower(strings.TrimSpace(s)) {
39 case stateOpen:
40 return ermite.IssueState_ISSUE_STATE_OPEN, nil
41 case stateClosed:
42 return ermite.IssueState_ISSUE_STATE_CLOSED, nil
43 case stateAll:
44 return ermite.IssueState_ISSUE_STATE_UNSPECIFIED, nil
45 }
46 return 0, fmt.Errorf("invalid state %q: use %s, %s or %s", s, stateOpen, stateClosed, stateAll)
47}
48
49// jsonIssue is the stable JSON shape of an issue.
50type jsonIssue struct {
51 Repo string `json:"repo"`
52 Number int64 `json:"number"`
53 Title string `json:"title"`
54 Body string `json:"body"`
55 State string `json:"state"`
56 Author string `json:"author"`
57 CreatedAt *string `json:"created_at"`
58 UpdatedAt *string `json:"updated_at"`
59 CommentCount int32 `json:"comment_count"`
60 // ClosedByMergeRequest is the merge request whose merge closed the issue; 0 for none.
61 ClosedByMergeRequest int64 `json:"closed_by_merge_request"`
62}
63
64func toJSONIssue(i *ermite.Issue) jsonIssue {
65 return jsonIssue{
66 Repo: i.GetRepo(), Number: i.GetNumber(), Title: i.GetTitle(), Body: i.GetBody(),
67 State: issueStateName(i.GetState()), Author: i.GetAuthor(),
68 CreatedAt: jsonTime(i.GetCreatedAt()), UpdatedAt: jsonTime(i.GetUpdatedAt()), CommentCount: i.GetCommentCount(),
69 ClosedByMergeRequest: i.GetClosedByMergeRequest(),
70 }
71}
72
73// jsonIssueComment is the stable JSON shape of an issue comment.
74type jsonIssueComment struct {
75 ID string `json:"id"`
76 Author string `json:"author"`
77 Text string `json:"text"`
78 CreatedAt *string `json:"created_at"`
79}
80
81func toJSONIssueComment(c *ermite.IssueComment) jsonIssueComment {
82 return jsonIssueComment{ID: c.GetId(), Author: c.GetAuthor(), Text: c.GetText(), CreatedAt: jsonTime(c.GetCreatedAt())}
83}
84
85// jsonIssueDetail is the stable JSON shape of `issue show --json`.
86type jsonIssueDetail struct {
87 jsonIssue
88 Comments []jsonIssueComment `json:"comments"`
89 References []jsonIssueReference `json:"references"`
90}
91
92// jsonIssueReference is a merge request that names the issue.
93type jsonIssueReference struct {
94 Number int64 `json:"number"`
95 Title string `json:"title"`
96 State string `json:"state"`
97 Closes bool `json:"closes"`
98}
99
100const jsonIssueHelp = `
101JSON fields of an issue (--json): repo, number, title, body, state ("open" or "closed"), author
102(token label), created_at and updated_at (RFC 3339 UTC), comment_count, closed_by_merge_request (number of
103the merge request whose merge closed the issue, 0 for none).`
104
105const jsonIssueCommentHelp = `
106JSON fields of an issue comment: id, author, text, created_at.`
107
108const jsonIssueReferenceHelp = `
109JSON fields of a merge request reference: number, title, state ("open", "merged" or "closed"), closes
110(true when merging it closes the issue).`
111
112func newIssueCmd(env *cliEnv) *cobra.Command {
113 issue := &cobra.Command{
114 Use: "issue",
115 Short: "Track issues on a remote server",
116 Long: "Issues record a task, a bug or a question about a repository. They share the numbering of merge\n" +
117 "requests: the next issue after merge request 1 is number 2. Issues must be enabled for the\n" +
118 "repository (`ermite repo edit <repo> --issues owner`). Needs a server with the issues feature (see\n" +
119 "`ermite version`). An issue is named by its repository and its number: `ermite issue show proj 3`.",
120 }
121 issue.AddCommand(
122 newIssueCreateCmd(env), newIssueListCmd(env), newIssueShowCmd(env), newIssueCommentCmd(env),
123 newIssueCloseCmd(env), newIssueReopenCmd(env),
124 )
125 return issue
126}
127
128// requireIssues refuses to go on with a server that does not list issues: it
129// has no IssueService. A server whose features cannot be read is refused too.
130// errNoIssues marks a server that lacks the issues feature.
131var errNoIssues = errors.New("issues are not available")
132
133func requireIssues(ctx context.Context, c *client.Client) error {
134 res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
135 if err != nil {
136 return c.Describe(err)
137 }
138 if !slices.Contains(res.Msg.GetFeatures(), client.FeatureIssues) {
139 return fmt.Errorf("%w: the server does not support %s, so it has no issues (upgrade the server, see `ermite version`)", errNoIssues, client.FeatureIssues)
140 }
141 return nil
142}
143
144// issueClient returns a client for a server that supports issues.
145func (e *cliEnv) issueClient(cmd *cobra.Command) (*client.Client, error) {
146 c, err := e.client(cmd)
147 if err != nil {
148 return nil, err
149 }
150 if err := requireIssues(e.ctx(cmd), c); err != nil {
151 return nil, err
152 }
153 return c, nil
154}
155
156// parseIssueNumber reads the number of an issue, written 3 or #3.
157func parseIssueNumber(s string) (int64, error) {
158 n, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(s), "#"), 10, 64)
159 if err != nil || n < 1 {
160 return 0, fmt.Errorf("invalid issue number %q: use a positive number such as 3 or #3", s)
161 }
162 return n, nil
163}
164
165func completeIssueRepoThenNumber(env *cliEnv) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
166 return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
167 switch len(args) {
168 case 0:
169 return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp
170 case 1:
171 c, err := env.completionClient(cmd)
172 if err != nil {
173 return nil, cobra.ShellCompDirectiveNoFileComp
174 }
175 issues, err := listIssues(env.ctx(cmd), c, args[0], ermite.IssueState_ISSUE_STATE_UNSPECIFIED)
176 if err != nil {
177 return nil, cobra.ShellCompDirectiveNoFileComp
178 }
179 var out []string
180 for _, i := range issues {
181 if n := strconv.FormatInt(i.GetNumber(), 10); strings.HasPrefix(n, toComplete) {
182 out = append(out, n+"\t"+issueStateName(i.GetState())+", "+clean(i.GetTitle()))
183 }
184 }
185 return out, cobra.ShellCompDirectiveNoFileComp
186 }
187 return nil, cobra.ShellCompDirectiveNoFileComp
188 }
189}
190
191func listIssues(ctx context.Context, c *client.Client, repo string, state ermite.IssueState) ([]*ermite.Issue, error) {
192 return collect(ctx, func(token string) ([]*ermite.Issue, string, error) {
193 res, err := c.Issues().ListIssues(ctx, connect.NewRequest(&ermite.ListIssuesRequest{
194 Repo: repo, State: state, PageToken: token,
195 }))
196 if err != nil {
197 return nil, "", c.Describe(err)
198 }
199 return res.Msg.GetIssues(), res.Msg.GetNextPageToken(), nil
200 })
201}
202
203// outputIssue prints an issue as JSON, or as one confirmation line.
204func outputIssue(cmd *cobra.Command, i *ermite.Issue, asJSON bool, verb string) error {
205 if asJSON {
206 return writeJSON(cmd.OutOrStdout(), toJSONIssue(i))
207 }
208 _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s#%d (%s)\n", verb, clean(i.GetRepo()), i.GetNumber(), clean(i.GetTitle()))
209 return err
210}
211
212func newIssueCreateCmd(env *cliEnv) *cobra.Command {
213 var title, body, bodyFile string
214 cmd := &cobra.Command{
215 Use: "create <repo>",
216 Short: "Open an issue",
217 Long: "Open an issue. The title is one line of at most 120 characters, the body Markdown of at most\n" +
218 "10,000 characters; disallowed control characters are stripped (a body may hold line breaks).\n" +
219 "--body - and --body-file - read the body from standard input; --body-file reads it\n" +
220 "from a file otherwise. Needs the issue:write scope." + jsonIssueHelp,
221 Args: cobra.ExactArgs(1),
222 ValidArgsFunction: completeRepoNames(env),
223 }
224 cmd.Flags().StringVar(&title, "title", "", "title of the issue (required)")
225 cmd.Flags().StringVar(&body, "body", "", "body, or - to read standard input")
226 cmd.Flags().StringVar(&bodyFile, "body-file", "", "read the body from this file, or - for standard input")
227 cmd.MarkFlagsMutuallyExclusive("body", "body-file")
228 _ = cmd.MarkFlagRequired("title")
229 _ = cmd.MarkFlagFilename("body-file")
230 asJSON := addJSONFlag(cmd)
231 cmd.RunE = func(cmd *cobra.Command, args []string) error {
232 text := body
233 if cmd.Flags().Changed("body-file") {
234 if bodyFile == "-" {
235 text = "-"
236 } else {
237 b, err := os.ReadFile(bodyFile) //nolint:gosec // a path typed by the user
238 if err != nil {
239 return fmt.Errorf("read --body-file: %w", err)
240 }
241 text = strings.TrimRight(string(b), "\r\n")
242 }
243 }
244 text, err := readText(cmd, text)
245 if err != nil {
246 return err
247 }
248 c, err := env.issueClient(cmd)
249 if err != nil {
250 return err
251 }
252 res, err := c.Issues().CreateIssue(env.ctx(cmd), connect.NewRequest(&ermite.CreateIssueRequest{
253 Repo: args[0], Title: title, Body: text,
254 }))
255 if err != nil {
256 return c.Describe(err)
257 }
258 return outputIssue(cmd, res.Msg.GetIssue(), *asJSON, "opened")
259 }
260 return cmd
261}
262
263func newIssueListCmd(env *cliEnv) *cobra.Command {
264 var state string
265 cmd := &cobra.Command{
266 Use: "list <repo>",
267 Short: "List the issues of a repository",
268 Long: "List the issues of a repository, newest first. --state restricts them to open or closed ones\n" +
269 "(default: open; all shows every state)." + jsonIssueHelp + "\n--json prints an array of issues.",
270 Args: cobra.ExactArgs(1),
271 ValidArgsFunction: completeRepoNames(env),
272 }
273 cmd.Flags().StringVar(&state, "state", stateOpen, "state: open, closed or all")
274 _ = cmd.RegisterFlagCompletionFunc("state", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
275 return []string{stateOpen, stateClosed, stateAll}, cobra.ShellCompDirectiveNoFileComp
276 })
277 asJSON := addJSONFlag(cmd)
278 cmd.RunE = func(cmd *cobra.Command, args []string) error {
279 st, err := parseIssueState(state)
280 if err != nil {
281 return err
282 }
283 c, err := env.issueClient(cmd)
284 if err != nil {
285 return err
286 }
287 issues, err := listIssues(env.ctx(cmd), c, args[0], st)
288 if err != nil {
289 return err
290 }
291 if *asJSON {
292 out := make([]jsonIssue, 0, len(issues))
293 for _, i := range issues {
294 out = append(out, toJSONIssue(i))
295 }
296 return writeJSON(cmd.OutOrStdout(), out)
297 }
298 if len(issues) == 0 {
299 noticef(cmd, "no issues")
300 return nil
301 }
302 rows := make([][]string, 0, len(issues))
303 for _, i := range issues {
304 rows = append(rows, []string{
305 "#" + strconv.FormatInt(i.GetNumber(), 10), issueStateName(i.GetState()), i.GetAuthor(),
306 strconv.Itoa(int(i.GetCommentCount())), shortTime(i.GetUpdatedAt(), "-"), i.GetTitle(),
307 })
308 }
309 return table(cmd.OutOrStdout(), []string{"NUMBER", "STATE", "AUTHOR", "COMMENTS", "UPDATED", "TITLE"}, rows)
310 }
311 return cmd
312}
313
314func newIssueShowCmd(env *cliEnv) *cobra.Command {
315 cmd := &cobra.Command{
316 Use: "show <repo> <number>",
317 Short: "Show an issue with its comments",
318 Long: "Show an issue: its fields, its body, the merge requests that name it (#N in their title,\n" +
319 "description, notes or commits; \"closes\" when a closing keyword such as \"Fixes #N\" names it) and\n" +
320 "its comments, oldest first. \"closed by\" shows the merge request whose merge closed the issue." +
321 jsonIssueHelp + jsonIssueCommentHelp + jsonIssueReferenceHelp +
322 "\n--json adds comments and references (always present, possibly empty) to the issue fields.",
323 Args: cobra.ExactArgs(2),
324 ValidArgsFunction: completeIssueRepoThenNumber(env),
325 }
326 asJSON := addJSONFlag(cmd)
327 cmd.RunE = func(cmd *cobra.Command, args []string) error {
328 n, err := parseIssueNumber(args[1])
329 if err != nil {
330 return err
331 }
332 c, err := env.issueClient(cmd)
333 if err != nil {
334 return err
335 }
336 res, err := c.Issues().GetIssue(env.ctx(cmd), connect.NewRequest(&ermite.GetIssueRequest{Repo: args[0], Number: n}))
337 if err != nil {
338 return c.Describe(err)
339 }
340 if *asJSON {
341 out := jsonIssueDetail{jsonIssue: toJSONIssue(res.Msg.GetIssue()), Comments: []jsonIssueComment{}}
342 for _, cm := range res.Msg.GetComments() {
343 out.Comments = append(out.Comments, toJSONIssueComment(cm))
344 }
345 out.References = []jsonIssueReference{}
346 for _, r := range res.Msg.GetReferences() {
347 out.References = append(out.References, jsonIssueReference{
348 Number: r.GetNumber(), Title: r.GetTitle(), State: stateName(r.GetState()), Closes: r.GetCloses(),
349 })
350 }
351 return writeJSON(cmd.OutOrStdout(), out)
352 }
353 return printIssueDetail(cmd.OutOrStdout(), res.Msg)
354 }
355 return cmd
356}
357
358func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
359 i := d.GetIssue()
360 body := i.GetBody()
361 if body == "" {
362 body = "-"
363 }
364 rows := [][2]string{
365 {"issue", fmt.Sprintf("%s#%d", i.GetRepo(), i.GetNumber())},
366 {"title", i.GetTitle()},
367 {"state", issueStateName(i.GetState())},
368 }
369 if n := i.GetClosedByMergeRequest(); n > 0 {
370 rows = append(rows, [2]string{"closed by", fmt.Sprintf("!%d", n)})
371 }
372 rows = append(rows,
373 [2]string{"author", i.GetAuthor()},
374 [2]string{"created", shortTime(i.GetCreatedAt(), "-")},
375 [2]string{"updated", shortTime(i.GetUpdatedAt(), "-")},
376 )
377 err := fields(w, rows)
378 if err != nil {
379 return err
380 }
381 if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil {
382 return err
383 }
384 if refs := d.GetReferences(); len(refs) > 0 {
385 if _, err := fmt.Fprintf(w, "\nmerge requests (%d)\n", len(refs)); err != nil {
386 return err
387 }
388 for _, r := range refs {
389 line := fmt.Sprintf(" !%d %-6s %s", r.GetNumber(), stateName(r.GetState()), clean(r.GetTitle()))
390 if r.GetCloses() {
391 line += " · closes"
392 }
393 if _, err := fmt.Fprintln(w, line); err != nil {
394 return err
395 }
396 }
397 }
398 if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {
399 return err
400 }
401 for _, c := range d.GetComments() {
402 if _, err := fmt.Fprintf(w, " #%s %s, %s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), indentText(c.GetText())); err != nil {
403 return err
404 }
405 }
406 return nil
407}
408
409func newIssueCommentCmd(env *cliEnv) *cobra.Command {
410 var message string
411 cmd := &cobra.Command{
412 Use: "comment <repo> <number>",
413 Short: "Comment on an issue",
414 Long: "Add a plain-text comment (at most 10,000 characters; disallowed control characters are stripped).\n" +
415 "-m - reads it from standard input. Needs the issue:write scope." + jsonIssueCommentHelp,
416 Args: cobra.ExactArgs(2),
417 ValidArgsFunction: completeIssueRepoThenNumber(env),
418 }
419 cmd.Flags().StringVarP(&message, "message", "m", "", "text of the comment, or - to read standard input (required)")
420 cmd.Flags().StringVar(&message, "text", "", "alias of --message")
421 _ = cmd.Flags().MarkHidden("text")
422 asJSON := addJSONFlag(cmd)
423 cmd.RunE = func(cmd *cobra.Command, args []string) error {
424 n, err := parseIssueNumber(args[1])
425 if err != nil {
426 return err
427 }
428 if !cmd.Flags().Changed("message") && !cmd.Flags().Changed("text") {
429 return errors.New("the comment is required: give it with -m, or -m - for standard input")
430 }
431 text, err := readText(cmd, message)
432 if err != nil {
433 return err
434 }
435 c, err := env.issueClient(cmd)
436 if err != nil {
437 return err
438 }
439 res, err := c.Issues().AddIssueComment(env.ctx(cmd), connect.NewRequest(&ermite.AddIssueCommentRequest{
440 Repo: args[0], Number: n, Text: text,
441 }))
442 if err != nil {
443 return c.Describe(err)
444 }
445 if *asJSON {
446 return writeJSON(cmd.OutOrStdout(), toJSONIssueComment(res.Msg.GetComment()))
447 }
448 _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s added to %s#%d\n", res.Msg.GetComment().GetId(), clean(args[0]), n)
449 return err
450 }
451 return cmd
452}
453
454func newIssueCloseCmd(env *cliEnv) *cobra.Command {
455 return newIssueStateCmd(env, "close", "closed", "Close an issue", "Close an open issue. `ermite issue reopen` opens it again. Needs the issue:write scope.",
456 func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) {
457 res, err := c.Issues().CloseIssue(ctx, connect.NewRequest(&ermite.CloseIssueRequest{Repo: repo, Number: n}))
458 if err != nil {
459 return nil, err
460 }
461 return res.Msg.GetIssue(), nil
462 })
463}
464
465func newIssueReopenCmd(env *cliEnv) *cobra.Command {
466 return newIssueStateCmd(env, "reopen", "reopened", "Reopen a closed issue", "Reopen a closed issue. Needs the issue:write scope.",
467 func(ctx context.Context, c *client.Client, repo string, n int64) (*ermite.Issue, error) {
468 res, err := c.Issues().ReopenIssue(ctx, connect.NewRequest(&ermite.ReopenIssueRequest{Repo: repo, Number: n}))
469 if err != nil {
470 return nil, err
471 }
472 return res.Msg.GetIssue(), nil
473 })
474}
475
476func newIssueStateCmd(env *cliEnv, use, verb, short, long string, do func(context.Context, *client.Client, string, int64) (*ermite.Issue, error)) *cobra.Command {
477 cmd := &cobra.Command{
478 Use: use + " <repo> <number>",
479 Short: short,
480 Long: long + jsonIssueHelp,
481 Args: cobra.ExactArgs(2),
482 ValidArgsFunction: completeIssueRepoThenNumber(env),
483 }
484 asJSON := addJSONFlag(cmd)
485 cmd.RunE = func(cmd *cobra.Command, args []string) error {
486 n, err := parseIssueNumber(args[1])
487 if err != nil {
488 return err
489 }
490 c, err := env.issueClient(cmd)
491 if err != nil {
492 return err
493 }
494 i, err := do(env.ctx(cmd), c, args[0], n)
495 if err != nil {
496 return c.Describe(err)
497 }
498 return outputIssue(cmd, i, *asJSON, verb)
499 }
500 return cmd
501}