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