detached 388d3be

cli_issue.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close