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