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

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