detached 388d3be

cli_mr.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	"net/url"
11	"strconv"
12	"strings"
13
14	"connectrpc.com/connect"
15	"github.com/spf13/cobra"
16
17	"bnema.dev/ermite-api/gen/ermite"
18	"bnema.dev/ermite/internal/adapters/client"
19)
20
21// Names of the states, the sides and the merge modes in the output and in the
22// flags.
23const (
24	stateOpen   = "open"
25	stateMerged = "merged"
26	stateClosed = "closed"
27	stateAll    = "all"
28
29	sideOld = "old"
30	sideNew = "new"
31
32	modeFastForward = "fast-forward"
33	modeSquash      = "squash"
34)
35
36func stateName(s ermite.MergeRequestState) string {
37	switch s {
38	case ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN:
39		return stateOpen
40	case ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED:
41		return stateMerged
42	case ermite.MergeRequestState_MERGE_REQUEST_STATE_CLOSED:
43		return stateClosed
44	}
45	return "unknown"
46}
47
48func parseState(s string) (ermite.MergeRequestState, error) {
49	switch strings.ToLower(strings.TrimSpace(s)) {
50	case stateOpen:
51		return ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, nil
52	case stateMerged:
53		return ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED, nil
54	case stateClosed:
55		return ermite.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, nil
56	case stateAll:
57		return ermite.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, nil
58	}
59	return 0, fmt.Errorf("invalid state %q: use %s, %s, %s or %s", s, stateOpen, stateMerged, stateClosed, stateAll)
60}
61
62func modeName(m ermite.MergeMode) string {
63	switch m {
64	case ermite.MergeMode_MERGE_MODE_FAST_FORWARD:
65		return modeFastForward
66	case ermite.MergeMode_MERGE_MODE_SQUASH:
67		return modeSquash
68	}
69	return ""
70}
71
72func parseMode(s string) (ermite.MergeMode, error) {
73	switch strings.ToLower(strings.TrimSpace(s)) {
74	case modeFastForward, "ff":
75		return ermite.MergeMode_MERGE_MODE_FAST_FORWARD, nil
76	case modeSquash:
77		return ermite.MergeMode_MERGE_MODE_SQUASH, nil
78	}
79	return 0, fmt.Errorf("invalid merge mode %q: use %s or %s", s, modeFastForward, modeSquash)
80}
81
82func sideName(s ermite.DiffSide) string {
83	switch s {
84	case ermite.DiffSide_DIFF_SIDE_OLD:
85		return sideOld
86	case ermite.DiffSide_DIFF_SIDE_NEW:
87		return sideNew
88	}
89	return ""
90}
91
92func parseSide(s string) (ermite.DiffSide, error) {
93	switch strings.ToLower(strings.TrimSpace(s)) {
94	case sideOld:
95		return ermite.DiffSide_DIFF_SIDE_OLD, nil
96	case sideNew:
97		return ermite.DiffSide_DIFF_SIDE_NEW, nil
98	}
99	return 0, fmt.Errorf("invalid side %q: use %s or %s", s, sideOld, sideNew)
100}
101
102// jsonMergeRequest is the stable JSON shape of a merge request.
103type jsonMergeRequest struct {
104	Repo           string  `json:"repo"`
105	URL            string  `json:"url"`
106	Number         int64   `json:"number"`
107	Title          string  `json:"title"`
108	Description    string  `json:"description"`
109	SourceBranch   string  `json:"source_branch"`
110	TargetBranch   string  `json:"target_branch"`
111	State          string  `json:"state"`
112	Author         string  `json:"author"`
113	CreatedAt      *string `json:"created_at"`
114	UpdatedAt      *string `json:"updated_at"`
115	HeadID         string  `json:"head_id"`
116	MergedCommitID string  `json:"merged_commit_id"`
117	MergeMode      string  `json:"merge_mode"`
118}
119
120// jsonMerged is the output of `mr merge --json`.
121type jsonMerged struct {
122	jsonMergeRequest
123	SourceBranchDeleted bool `json:"source_branch_deleted"`
124}
125
126func toJSONMergeRequest(m *ermite.MergeRequest, baseURL string) jsonMergeRequest {
127	return jsonMergeRequest{
128		URL:  mrWebURL(baseURL, m.GetRepo(), m.GetNumber()),
129		Repo: m.GetRepo(), Number: m.GetNumber(), Title: m.GetTitle(), Description: m.GetDescription(),
130		SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()),
131		Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()),
132		HeadID: m.GetHeadId(), MergedCommitID: m.GetMergedCommitId(), MergeMode: modeName(m.GetMergeMode()),
133	}
134}
135
136// jsonComment is the stable JSON shape of a comment.
137type jsonComment struct {
138	ID        string  `json:"id"`
139	Author    string  `json:"author"`
140	Path      string  `json:"path"`
141	Line      int32   `json:"line"`
142	Side      string  `json:"side"`
143	CommitID  string  `json:"commit_id"`
144	Text      string  `json:"text"`
145	Resolved  bool    `json:"resolved"`
146	CreatedAt *string `json:"created_at"`
147	ReplyTo   string  `json:"reply_to"`
148	StartLine int32   `json:"start_line"`
149	Verdict   string  `json:"verdict"`
150	EditedAt  *string `json:"edited_at"`
151	Deleted   bool    `json:"deleted"`
152}
153
154func verdictName(v ermite.Verdict) string {
155	switch v {
156	case ermite.Verdict_VERDICT_APPROVE:
157		return "approve"
158	case ermite.Verdict_VERDICT_CHANGES_REQUESTED:
159		return "changes_requested"
160	}
161	return ""
162}
163
164func toJSONComment(c *ermite.Comment) jsonComment {
165	return jsonComment{
166		ID: c.GetId(), Author: c.GetAuthor(), Path: c.GetPath(), Line: c.GetLine(), Side: sideName(c.GetSide()),
167		CommitID: c.GetCommitId(), Text: c.GetText(), Resolved: c.GetResolved(), CreatedAt: jsonTime(c.GetCreatedAt()),
168		ReplyTo: c.GetReplyTo(), StartLine: c.GetStartLine(), Verdict: verdictName(c.GetVerdict()), EditedAt: jsonTime(c.GetEditedAt()), Deleted: c.GetDeleted(),
169	}
170}
171
172const jsonMRHelp = `
173JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch,
174target_branch, state ("open", "merged" or "closed"), author (token label), created_at and
175updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the
176branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty
177unless merged).`
178
179const jsonCommentHelp = `
180JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0
181and empty side for a general comment), commit_id (the head when it was written), text,
182resolved, created_at, reply_to (empty for roots), start_line (0 for a single line),
183verdict ("", "approve", "changes_requested"), edited_at (nullable RFC 3339), deleted.`
184
185func newMRCmd(env *cliEnv) *cobra.Command {
186	mr := &cobra.Command{
187		Use:   "mr",
188		Short: "Review and merge branches on a remote server",
189		Long: "Merge requests ask to merge one branch of a repository into another. They are about a branch,\n" +
190			"not about who wrote it. Needs a server with the merge_requests feature (see `ermite version`).\n" +
191			"A merge request is named by its repository and its number: `ermite mr show proj 3`.",
192	}
193	mr.AddCommand(
194		newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env),
195		newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),
196	)
197	return mr
198}
199
200// parseNumber reads the number of a merge request, written 3 or !3.
201func parseNumber(s string) (int64, error) {
202	n, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(s), "!"), 10, 64)
203	if err != nil || n < 1 {
204		return 0, fmt.Errorf("invalid merge request number %q: use a positive number such as 3 or !3", s)
205	}
206	return n, nil
207}
208
209// readText returns value, or all of standard input when value is "-".
210func readText(cmd *cobra.Command, value string) (string, error) {
211	if value != "-" {
212		return value, nil
213	}
214	b, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20))
215	if err != nil {
216		return "", fmt.Errorf("read standard input: %w", err)
217	}
218	return strings.TrimRight(string(b), "\r\n"), nil
219}
220
221func completeMRRepoThenNumber(env *cliEnv) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
222	return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
223		switch len(args) {
224		case 0:
225			return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp
226		case 1:
227			c, err := env.completionClient(cmd)
228			if err != nil {
229				return nil, cobra.ShellCompDirectiveNoFileComp
230			}
231			mrs, err := listMergeRequests(env.ctx(cmd), c, args[0], ermite.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED)
232			if err != nil {
233				return nil, cobra.ShellCompDirectiveNoFileComp
234			}
235			var out []string
236			for _, m := range mrs {
237				if n := strconv.FormatInt(m.GetNumber(), 10); strings.HasPrefix(n, toComplete) {
238					out = append(out, n+"\t"+stateName(m.GetState())+", "+clean(m.GetTitle()))
239				}
240			}
241			return out, cobra.ShellCompDirectiveNoFileComp
242		}
243		return nil, cobra.ShellCompDirectiveNoFileComp
244	}
245}
246
247func listMergeRequests(ctx context.Context, c *client.Client, repo string, state ermite.MergeRequestState) ([]*ermite.MergeRequest, error) {
248	return collect(ctx, func(token string) ([]*ermite.MergeRequest, string, error) {
249		res, err := c.MergeRequests().ListMergeRequests(ctx, connect.NewRequest(&ermite.ListMergeRequestsRequest{
250			Repo: repo, State: state, PageToken: token,
251		}))
252		if err != nil {
253			return nil, "", c.Describe(err)
254		}
255		return res.Msg.GetMergeRequests(), res.Msg.GetNextPageToken(), nil
256	})
257}
258
259func newMRCreateCmd(env *cliEnv) *cobra.Command {
260	var title, description, source, target string
261	cmd := &cobra.Command{
262		Use:   "create <repo>",
263		Short: "Open a merge request",
264		Long: "Open a merge request for a branch of the repository. --target defaults to the default branch.\n" +
265			"The title is one line of at most 120 characters, the description plain text of at most 8000;\n" +
266			"neither may hold control characters (a description may hold line breaks) or bidirectional\n" +
267			"override characters. --description - reads it from standard input. Fails if an open merge request\n" +
268			"already has the same source and target. Prints a confirmation, then the full web URL on its own line.\n" +
269			"Needs the mr:write scope." + jsonMRHelp,
270		Args:              cobra.ExactArgs(1),
271		ValidArgsFunction: completeRepoNames(env),
272	}
273	cmd.Flags().StringVar(&title, "title", "", "title of the merge request (required)")
274	cmd.Flags().StringVar(&description, "description", "", "description, or - to read standard input")
275	cmd.Flags().StringVar(&source, "source", "", "branch to merge (required)")
276	cmd.Flags().StringVar(&target, "target", "", "branch to merge into (default: the default branch)")
277	_ = cmd.MarkFlagRequired("title")
278	_ = cmd.MarkFlagRequired("source")
279	asJSON := addJSONFlag(cmd)
280	cmd.RunE = func(cmd *cobra.Command, args []string) error {
281		desc, err := readText(cmd, description)
282		if err != nil {
283			return err
284		}
285		req := &ermite.CreateMergeRequestRequest{
286			Repo: args[0], Title: title, Description: desc,
287			SourceBranch: trimBranch(source), TargetBranch: trimBranch(target),
288		}
289		c, err := env.client(cmd)
290		if err != nil {
291			return err
292		}
293		res, err := c.MergeRequests().CreateMergeRequest(env.ctx(cmd), connect.NewRequest(req))
294		if err != nil {
295			return c.Describe(err)
296		}
297		return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened", c.URL())
298	}
299	return cmd
300}
301
302func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "refs/heads/") }
303
304// mrWebURL builds the web route with the repository as a single path segment.
305// Repository names currently forbid slashes, but escape all path delimiters defensively.
306func mrWebURL(baseURL, repo string, number int64) string {
307	return strings.TrimRight(baseURL, "/") + "/" + url.PathEscape(repo) + "/merge-requests/" + strconv.FormatInt(number, 10)
308}
309
310// outputMR prints a merge request as JSON, or a confirmation followed by its URL.
311func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb, baseURL string) error {
312	if asJSON {
313		return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, baseURL))
314	}
315	_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n%s\n", verb, clean(m.GetRepo()), m.GetNumber(),
316		clean(m.GetSourceBranch()), clean(m.GetTargetBranch()), mrWebURL(baseURL, m.GetRepo(), m.GetNumber()))
317	return err
318}
319
320func newMRListCmd(env *cliEnv) *cobra.Command {
321	var state string
322	cmd := &cobra.Command{
323		Use:   "list <repo>",
324		Short: "List the merge requests of a repository",
325		Long: "List the merge requests of a repository, newest first. --state restricts them to open, merged or\n" +
326			"closed ones (default: open; all shows every state)." + jsonMRHelp + "\n--json prints an array of merge requests.",
327		Args:              cobra.ExactArgs(1),
328		ValidArgsFunction: completeRepoNames(env),
329	}
330	cmd.Flags().StringVar(&state, "state", stateOpen, "state: open, merged, closed or all")
331	_ = cmd.RegisterFlagCompletionFunc("state", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
332		return []string{stateOpen, stateMerged, stateClosed, stateAll}, cobra.ShellCompDirectiveNoFileComp
333	})
334	asJSON := addJSONFlag(cmd)
335	cmd.RunE = func(cmd *cobra.Command, args []string) error {
336		st, err := parseState(state)
337		if err != nil {
338			return err
339		}
340		c, err := env.client(cmd)
341		if err != nil {
342			return err
343		}
344		mrs, err := listMergeRequests(env.ctx(cmd), c, args[0], st)
345		if err != nil {
346			return err
347		}
348		if *asJSON {
349			out := make([]jsonMergeRequest, 0, len(mrs))
350			for _, m := range mrs {
351				out = append(out, toJSONMergeRequest(m, c.URL()))
352			}
353			return writeJSON(cmd.OutOrStdout(), out)
354		}
355		if len(mrs) == 0 {
356			noticef(cmd, "no merge requests")
357			return nil
358		}
359		rows := make([][]string, 0, len(mrs))
360		for _, m := range mrs {
361			rows = append(rows, []string{
362				"!" + strconv.FormatInt(m.GetNumber(), 10), stateName(m.GetState()),
363				m.GetSourceBranch() + " -> " + m.GetTargetBranch(), m.GetAuthor(),
364				shortTime(m.GetUpdatedAt(), "-"), m.GetTitle(),
365			})
366		}
367		return table(cmd.OutOrStdout(), []string{"NUMBER", "STATE", "BRANCHES", "AUTHOR", "UPDATED", "TITLE"}, rows)
368	}
369	return cmd
370}
371
372// jsonStats are the totals of a diff.
373type jsonStats struct {
374	FilesChanged int32 `json:"files_changed"`
375	Additions    int32 `json:"additions"`
376	Deletions    int32 `json:"deletions"`
377}
378
379type jsonLine struct {
380	Kind      string `json:"kind"`
381	OldNumber int32  `json:"old_number"`
382	NewNumber int32  `json:"new_number"`
383	Text      string `json:"text"`
384}
385
386type jsonHunk struct {
387	Header   string     `json:"header"`
388	OldStart int32      `json:"old_start"`
389	OldLines int32      `json:"old_lines"`
390	NewStart int32      `json:"new_start"`
391	NewLines int32      `json:"new_lines"`
392	Lines    []jsonLine `json:"lines"`
393}
394
395type jsonFile struct {
396	OldPath   string     `json:"old_path"`
397	NewPath   string     `json:"new_path"`
398	Status    string     `json:"status"`
399	Additions int32      `json:"additions"`
400	Deletions int32      `json:"deletions"`
401	Binary    bool       `json:"binary"`
402	Truncated bool       `json:"truncated"`
403	Hunks     []jsonHunk `json:"hunks,omitempty"`
404}
405
406type jsonCommit struct {
407	ID      string `json:"id"`
408	Author  string `json:"author"`
409	Summary string `json:"summary"`
410}
411
412// jsonMRDetail is the stable JSON shape of `mr show --json`.
413type jsonMRDetail struct {
414	jsonMergeRequest
415	Commits          []jsonCommit        `json:"commits"`
416	CommitsTruncated bool                `json:"commits_truncated"`
417	DiffBaseID       string              `json:"diff_base_id"`
418	Stats            jsonStats           `json:"stats"`
419	DiffTruncated    bool                `json:"diff_truncated"`
420	Files            []jsonFile          `json:"files"`
421	Comments         []jsonComment       `json:"comments"`
422	Verdicts         []jsonAuthorVerdict `json:"verdicts"`
423	LastReadID       string              `json:"last_read_id"`
424	LastReadLost     bool                `json:"last_read_lost"`
425}
426
427type jsonAuthorVerdict struct {
428	Author    string  `json:"author"`
429	Verdict   string  `json:"verdict"`
430	CommentID string  `json:"comment_id"`
431	CommitID  string  `json:"commit_id"`
432	CreatedAt *string `json:"created_at"`
433	Stale     bool    `json:"stale"`
434}
435
436func fileStatusName(s ermite.FileStatus) string {
437	return strings.ToLower(strings.TrimPrefix(s.String(), "FILE_STATUS_"))
438}
439
440func lineKindName(k ermite.LineKind) string {
441	return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))
442}
443
444func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail {
445	out := jsonMRDetail{
446		jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL),
447		Commits:          []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),
448		DiffBaseID: d.GetDiffBaseId(),
449		Stats: jsonStats{
450			FilesChanged: d.GetStats().GetFilesChanged(), Additions: d.GetStats().GetAdditions(), Deletions: d.GetStats().GetDeletions(),
451		},
452		DiffTruncated: d.GetDiff().GetTruncated(),
453		Files:         []jsonFile{}, Comments: []jsonComment{}, Verdicts: []jsonAuthorVerdict{},
454		LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(),
455	}
456	for _, c := range d.GetCommits() {
457		out.Commits = append(out.Commits, jsonCommit{ID: c.GetId(), Author: c.GetAuthor().GetName(), Summary: c.GetSummary()})
458	}
459	for _, f := range d.GetDiff().GetFiles() {
460		jf := jsonFile{
461			OldPath: f.GetOldPath(), NewPath: f.GetNewPath(), Status: fileStatusName(f.GetStatus()),
462			Additions: f.GetAdditions(), Deletions: f.GetDeletions(), Binary: f.GetBinary(), Truncated: f.GetTruncated(),
463		}
464		if withDiff {
465			for _, h := range f.GetHunks() {
466				jh := jsonHunk{
467					Header: h.GetHeader(), OldStart: h.GetOldStart(), OldLines: h.GetOldLines(),
468					NewStart: h.GetNewStart(), NewLines: h.GetNewLines(), Lines: []jsonLine{},
469				}
470				for _, l := range h.GetLines() {
471					jh.Lines = append(jh.Lines, jsonLine{
472						Kind: lineKindName(l.GetKind()), OldNumber: l.GetOldNumber(), NewNumber: l.GetNewNumber(), Text: l.GetText(),
473					})
474				}
475				jf.Hunks = append(jf.Hunks, jh)
476			}
477		}
478		out.Files = append(out.Files, jf)
479	}
480	for _, c := range d.GetComments() {
481		out.Comments = append(out.Comments, toJSONComment(c))
482	}
483	for _, v := range d.GetVerdicts() {
484		out.Verdicts = append(out.Verdicts, jsonAuthorVerdict{Author: v.GetAuthor(), Verdict: verdictName(v.GetVerdict()), CommentID: v.GetCommentId(), CommitID: v.GetCommitId(), CreatedAt: jsonTime(v.GetCreatedAt()), Stale: v.GetStale()})
485	}
486	return out
487}
488
489func newMRShowCmd(env *cliEnv) *cobra.Command {
490	var sinceLastRead, withDiff, markRead bool
491	cmd := &cobra.Command{
492		Use:   "show <repo> <number>",
493		Short: "Show a merge request with its commits, comments and diff",
494		Long: "Show a merge request: its fields, the commits of the branch that the target lacks, the changed\n" +
495			"files and the comments. --diff adds the diff, with the line numbers to use in `mr comment`.\n" +
496			"--since-last-read shows the diff since the head recorded by --mark-read (or by the web) instead of\n" +
497			"the whole change; after a force-push it still works when the recorded commit exists, else the\n" +
498			"whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" +
499			"the mr:write scope." + jsonMRHelp + jsonCommentHelp + "\n" +
500			"--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +
501			"diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
502			"diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +
503			"with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
504			"and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",
505		Args:              cobra.ExactArgs(2),
506		ValidArgsFunction: completeMRRepoThenNumber(env),
507	}
508	cmd.Flags().BoolVar(&withDiff, "diff", false, "include the diff")
509	cmd.Flags().BoolVar(&sinceLastRead, "since-last-read", false, "diff since the head last marked read")
510	cmd.Flags().BoolVar(&markRead, "mark-read", false, "record the head shown as read (needs mr:write)")
511	asJSON := addJSONFlag(cmd)
512	cmd.RunE = func(cmd *cobra.Command, args []string) error {
513		n, err := parseNumber(args[1])
514		if err != nil {
515			return err
516		}
517		c, err := env.client(cmd)
518		if err != nil {
519			return err
520		}
521		ctx := env.ctx(cmd)
522		res, err := c.MergeRequests().GetMergeRequest(ctx, connect.NewRequest(&ermite.GetMergeRequestRequest{
523			Repo: args[0], Number: n, SinceLastRead: sinceLastRead,
524		}))
525		if err != nil {
526			return c.Describe(err)
527		}
528		d := res.Msg
529		if *asJSON {
530			err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff, c.URL()))
531		} else {
532			err = printMRDetail(cmd.OutOrStdout(), d, withDiff, sinceLastRead)
533		}
534		if err != nil {
535			return err
536		}
537		if markRead && d.GetMergeRequest().GetHeadId() != "" {
538			_, err = c.MergeRequests().MarkRead(ctx, connect.NewRequest(&ermite.MarkReadRequest{
539				Repo: args[0], Number: n, HeadId: d.GetMergeRequest().GetHeadId(),
540			}))
541			if err != nil {
542				return c.Describe(err)
543			}
544		}
545		if d.GetLastReadLost() {
546			noticef(cmd, "the head you last read is gone (force-push?): showing the whole change")
547		}
548		return nil
549	}
550	return cmd
551}
552
553func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, since bool) error {
554	m := d.GetMergeRequest()
555	desc := m.GetDescription()
556	if desc == "" {
557		desc = "-"
558	}
559	pairs := [][2]string{
560		{"merge request", fmt.Sprintf("%s!%d", m.GetRepo(), m.GetNumber())},
561		{"title", m.GetTitle()},
562		{"state", stateName(m.GetState())},
563		{"branches", m.GetSourceBranch() + " -> " + m.GetTargetBranch()},
564		{"author", m.GetAuthor()},
565		{"created", shortTime(m.GetCreatedAt(), "-")},
566		{"updated", shortTime(m.GetUpdatedAt(), "-")},
567		{"head", orDash(m.GetHeadId())},
568	}
569	if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {
570		pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"})
571	}
572	if len(d.GetVerdicts()) > 0 {
573		var verdicts []string
574		for _, v := range d.GetVerdicts() {
575			var label string
576			switch v.GetVerdict() {
577			case ermite.Verdict_VERDICT_APPROVE:
578				label = "approved"
579			case ermite.Verdict_VERDICT_CHANGES_REQUESTED:
580				label = "changes requested"
581			default:
582				label = fmt.Sprintf("verdict %d", v.GetVerdict())
583			}
584			if v.GetStale() {
585				label += " (stale)"
586			}
587			verdicts = append(verdicts, v.GetAuthor()+" "+label)
588		}
589		pairs = append(pairs, [2]string{"verdicts", strings.Join(verdicts, ", ")})
590	}
591	if err := fields(w, pairs); err != nil {
592		return err
593	}
594	_, _ = fmt.Fprintf(w, "\n%s\n", indentText(desc))
595
596	_, _ = fmt.Fprintf(w, "\ncommits (%d%s)\n", len(d.GetCommits()), plus(d.GetCommitsTruncated()))
597	for _, c := range d.GetCommits() {
598		_, _ = fmt.Fprintf(w, "  %s  %s  %s\n", shortID(c.GetId()), clean(c.GetAuthor().GetName()), clean(c.GetSummary()))
599	}
600
601	st := d.GetStats()
602	what := "changes"
603	if since && !d.GetLastReadLost() && d.GetLastReadId() != "" {
604		what = "changes since " + shortID(d.GetLastReadId())
605	}
606	_, _ = fmt.Fprintf(w, "\n%s: %d file(s), +%d -%d, from %s\n", what, st.GetFilesChanged(), st.GetAdditions(), st.GetDeletions(), orDash(shortID(d.GetDiffBaseId())))
607	for _, f := range d.GetDiff().GetFiles() {
608		path := f.GetNewPath()
609		if path == "" {
610			path = f.GetOldPath()
611		}
612		note := ""
613		if f.GetTruncated() {
614			note = " (too large, not shown)"
615		}
616		_, _ = fmt.Fprintf(w, "  %-8s +%d -%d  %s%s\n", fileStatusName(f.GetStatus()), f.GetAdditions(), f.GetDeletions(), clean(path), note)
617	}
618	if d.GetDiff().GetTruncated() {
619		_, _ = fmt.Fprintln(w, "  (the diff is truncated)")
620	}
621
622	_, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments()))
623	var ordered []*ermite.Comment
624	for _, root := range d.GetComments() {
625		if root.GetReplyTo() != "" {
626			continue
627		}
628		ordered = append(ordered, root)
629		for _, reply := range d.GetComments() {
630			if reply.GetReplyTo() == root.GetId() {
631				ordered = append(ordered, reply)
632			}
633		}
634	}
635	for _, c := range ordered {
636		where := "general"
637		if c.GetPath() != "" {
638			where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide()))
639		}
640		if c.GetStartLine() > 0 {
641			where = fmt.Sprintf("%s:lines %d-%d (%s)", clean(c.GetPath()), c.GetStartLine(), c.GetLine(), sideName(c.GetSide()))
642		}
643		mark := ""
644		if c.GetEditedAt() != nil {
645			mark += " (edited)"
646		}
647		if c.GetDeleted() {
648			mark += " [deleted]"
649		}
650		if c.GetVerdict() != ermite.Verdict_VERDICT_UNSPECIFIED {
651			mark += " [" + verdictName(c.GetVerdict()) + "]"
652		}
653		if c.GetResolved() {
654			mark += " [resolved]"
655		}
656		prefix := "  "
657		body := indentText(c.GetText())
658		if c.GetReplyTo() != "" {
659			prefix += "    "
660			body = "    " + strings.ReplaceAll(body, "\n", "\n    ")
661		}
662		_, _ = fmt.Fprintf(w, "%s#%s %s, %s, %s%s\n%s\n", prefix, c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, body)
663	}
664	if withDiff {
665		printDiff(w, d.GetDiff())
666	}
667	return nil
668}
669
670func orDash(s string) string {
671	if s == "" {
672		return "-"
673	}
674	return s
675}
676
677func plus(truncated bool) string {
678	if truncated {
679		return "+"
680	}
681	return ""
682}
683
684func shortID(id string) string {
685	if len(id) > 10 {
686		return id[:10]
687	}
688	return id
689}
690
691// indentText indents a multi-line text by four spaces, making each line safe
692// for a terminal.
693func indentText(s string) string {
694	lines := strings.Split(s, "\n")
695	for i, l := range lines {
696		lines[i] = "    " + client.Clean(l)
697	}
698	return strings.Join(lines, "\n")
699}
700
701// printDiff prints the diff with a gutter of the old and new line numbers,
702// which are the numbers `mr comment --line` takes with --side old or new.
703func printDiff(w io.Writer, d *ermite.Diff) {
704	_, _ = fmt.Fprintln(w, "\ndiff (old and new line numbers on the left)")
705	num := func(n int32) string {
706		if n == 0 {
707			return ""
708		}
709		return strconv.Itoa(int(n))
710	}
711	for _, f := range d.GetFiles() {
712		_, _ = fmt.Fprintf(w, "\n--- %s\n+++ %s\n", clean(orDash(f.GetOldPath())), clean(orDash(f.GetNewPath())))
713		if f.GetBinary() {
714			_, _ = fmt.Fprintln(w, "(binary file)")
715		}
716		if f.GetTruncated() {
717			_, _ = fmt.Fprintln(w, "(too large, not shown)")
718		}
719		for _, h := range f.GetHunks() {
720			_, _ = fmt.Fprintf(w, "@@ -%d,%d +%d,%d @@ %s\n", h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines(), clean(h.GetHeader()))
721			for _, l := range h.GetLines() {
722				sign := map[ermite.LineKind]string{
723					ermite.LineKind_LINE_KIND_ADDITION: "+", ermite.LineKind_LINE_KIND_DELETION: "-",
724				}[l.GetKind()]
725				if sign == "" {
726					sign = " "
727				}
728				_, _ = fmt.Fprintf(w, "%5s %5s %s%s\n", num(l.GetOldNumber()), num(l.GetNewNumber()), sign, clean(l.GetText()))
729			}
730		}
731	}
732}
733
734func newMRCommentCmd(env *cliEnv) *cobra.Command {
735	var message, path, side, line, reply string
736	var approve, requestChanges bool
737	cmd := &cobra.Command{
738		Use:   "comment <repo> <number>",
739		Short: "Comment on a merge request",
740		Long: "Add a Markdown note (at most 8000 characters); ^N refers to a note in this merge request.\n" +
741			"--reply replies to a root note. --line accepts N or A-B. --approve and --request-changes\n" +
742			"set a verdict (needs mr:review) and allow an empty message.\n" +
743			"-m - reads it from standard input. Without --path it is a general comment. With --path, --line and\n" +
744			"--side (default new) it is attached to a line of the current diff: the path as in `mr show --diff`\n" +
745			"(the new path, the old one for a deleted file), the line number on the old or new side; a line\n" +
746			"that is not in the diff is refused. Needs the mr:write scope." + jsonCommentHelp,
747		Args:              cobra.ExactArgs(2),
748		ValidArgsFunction: completeMRRepoThenNumber(env),
749	}
750	cmd.Flags().StringVarP(&message, "message", "m", "", "Markdown text, or - to read standard input (required unless setting a verdict)")
751	cmd.Flags().StringVar(&path, "path", "", "file the comment is attached to")
752	cmd.Flags().StringVar(&line, "line", "", "1-based line N or range A-B on --side")
753	cmd.Flags().StringVar(&reply, "reply", "", "reply to root note (^12, #12 or 12)")
754	cmd.Flags().BoolVar(&approve, "approve", false, "approve (needs mr:review)")
755	cmd.Flags().BoolVar(&requestChanges, "request-changes", false, "request changes (needs mr:review)")
756	cmd.Flags().StringVar(&side, "side", sideNew, "side the line counts on: old or new")
757	cmd.MarkFlagsMutuallyExclusive("approve", "request-changes")
758	for _, flag := range []string{"path", "line", "side", "approve", "request-changes"} {
759		cmd.MarkFlagsMutuallyExclusive("reply", flag)
760	}
761	// A verdict note may omit its message.
762	_ = cmd.RegisterFlagCompletionFunc("side", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
763		return []string{sideOld, sideNew}, cobra.ShellCompDirectiveNoFileComp
764	})
765	asJSON := addJSONFlag(cmd)
766	cmd.RunE = func(cmd *cobra.Command, args []string) error {
767		n, err := parseNumber(args[1])
768		if err != nil {
769			return err
770		}
771		text, err := readText(cmd, message)
772		if err != nil {
773			return err
774		}
775		if !cmd.Flags().Changed("message") && !approve && !requestChanges {
776			return errors.New("--message is required unless --approve or --request-changes is set")
777		}
778		req := &ermite.AddCommentRequest{Repo: args[0], Number: n, Text: text}
779		if approve {
780			req.Verdict = ermite.Verdict_VERDICT_APPROVE
781		}
782		if requestChanges {
783			req.Verdict = ermite.Verdict_VERDICT_CHANGES_REQUESTED
784		}
785		if cmd.Flags().Changed("reply") {
786			req.ReplyTo, err = parseNoteID(reply)
787			if err != nil {
788				return err
789			}
790		}
791		switch {
792		case path != "":
793			if line == "" {
794				return errors.New("--line is required with --path: give the line number")
795			}
796			req.StartLine, req.Line, err = parseLineRange(line)
797			if err != nil {
798				return err
799			}
800			if req.Side, err = parseSide(side); err != nil {
801				return err
802			}
803			req.Path = path
804		case cmd.Flags().Changed("line") || cmd.Flags().Changed("side"):
805			return errors.New("--line and --side need --path")
806		}
807		c, err := env.client(cmd)
808		if err != nil {
809			return err
810		}
811		res, err := c.MergeRequests().AddComment(env.ctx(cmd), connect.NewRequest(req))
812		if err != nil {
813			return c.Describe(err)
814		}
815		if *asJSON {
816			return writeJSON(cmd.OutOrStdout(), toJSONComment(res.Msg.GetComment()))
817		}
818		_, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s added to %s!%d\n", res.Msg.GetComment().GetId(), clean(args[0]), n)
819		return err
820	}
821	return cmd
822}
823
824func parseNoteID(s string) (string, error) {
825	id := strings.TrimLeft(strings.TrimSpace(s), "^#")
826	n, err := strconv.ParseInt(id, 10, 64)
827	if err != nil || n < 1 {
828		return "", fmt.Errorf("invalid note id %q: use ^12, #12 or 12", s)
829	}
830	return strconv.FormatInt(n, 10), nil
831}
832
833func parseLineRange(s string) (start, end int32, err error) {
834	parts := strings.Split(s, "-")
835	invalid := fmt.Errorf("invalid --line %q: use N or A-B with 1 <= A < B", s)
836	if len(parts) < 1 || len(parts) > 2 {
837		return 0, 0, invalid
838	}
839	parse := func(v string) (int32, error) {
840		n, e := strconv.ParseInt(v, 10, 32)
841		if e != nil || n < 1 {
842			return 0, invalid
843		}
844		return int32(n), nil
845	}
846	end, err = parse(parts[len(parts)-1])
847	if err != nil {
848		return 0, 0, err
849	}
850	if len(parts) == 2 {
851		start, err = parse(parts[0])
852		if err != nil || start >= end {
853			return 0, 0, invalid
854		}
855	}
856	return start, end, nil
857}
858
859func newMREditCommentCmd(env *cliEnv) *cobra.Command   { return newMRNoteMutationCmd(env, true) }
860func newMRDeleteCommentCmd(env *cliEnv) *cobra.Command { return newMRNoteMutationCmd(env, false) }
861
862func newMRNoteMutationCmd(env *cliEnv, edit bool) *cobra.Command {
863	verb := "delete"
864	if edit {
865		verb = "edit"
866	}
867	var message string
868	cmd := &cobra.Command{Use: verb + "-comment <repo> <number> <comment-id>", Short: verb + " your own merge request note", Long: "Edit or delete your own note on an open merge request. Needs mr:write. Verdicts are unchanged." + jsonCommentHelp, Args: cobra.ExactArgs(3), ValidArgsFunction: completeMRRepoThenNumber(env)}
869	if edit {
870		cmd.Flags().StringVarP(&message, "message", "m", "", "Markdown text, or - to read standard input (required)")
871		_ = cmd.MarkFlagRequired("message")
872	}
873	asJSON := addJSONFlag(cmd)
874	cmd.RunE = func(cmd *cobra.Command, args []string) error {
875		n, err := parseNumber(args[1])
876		if err != nil {
877			return err
878		}
879		id, err := parseNoteID(args[2])
880		if err != nil {
881			return err
882		}
883		text, err := readText(cmd, message)
884		if err != nil {
885			return err
886		}
887		c, err := env.client(cmd)
888		if err != nil {
889			return err
890		}
891		var note *ermite.Comment
892		if edit {
893			res, e := c.MergeRequests().EditComment(env.ctx(cmd), connect.NewRequest(&ermite.EditCommentRequest{Repo: args[0], Number: n, CommentId: id, Text: text}))
894			if e != nil {
895				return c.Describe(e)
896			}
897			note = res.Msg.GetComment()
898		} else {
899			res, e := c.MergeRequests().DeleteComment(env.ctx(cmd), connect.NewRequest(&ermite.DeleteCommentRequest{Repo: args[0], Number: n, CommentId: id}))
900			if e != nil {
901				return c.Describe(e)
902			}
903			note = res.Msg.GetComment()
904		}
905		if *asJSON {
906			return writeJSON(cmd.OutOrStdout(), toJSONComment(note))
907		}
908		past := "deleted"
909		if edit {
910			past = "edited"
911		}
912		_, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s of %s!%d %s\n", clean(id), clean(args[0]), n, past)
913		return err
914	}
915	return cmd
916}
917
918func newMRResolveCmd(env *cliEnv) *cobra.Command {
919	var undo bool
920	cmd := &cobra.Command{
921		Use:   "resolve <repo> <number> <comment-id>",
922		Short: "Mark a comment resolved",
923		Long: "Mark a comment of a merge request resolved, or unresolved with --undo. Doing it twice is not an\n" +
924			"error. Needs the mr:write scope." + jsonCommentHelp,
925		Args:              cobra.ExactArgs(3),
926		ValidArgsFunction: completeMRRepoThenNumber(env),
927	}
928	cmd.Flags().BoolVar(&undo, "undo", false, "mark the comment unresolved instead")
929	asJSON := addJSONFlag(cmd)
930	cmd.RunE = func(cmd *cobra.Command, args []string) error {
931		n, err := parseNumber(args[1])
932		if err != nil {
933			return err
934		}
935		id := strings.TrimPrefix(strings.TrimSpace(args[2]), "#")
936		c, err := env.client(cmd)
937		if err != nil {
938			return err
939		}
940		res, err := c.MergeRequests().ResolveComment(env.ctx(cmd), connect.NewRequest(&ermite.ResolveCommentRequest{
941			Repo: args[0], Number: n, CommentId: id, Resolved: !undo,
942		}))
943		if err != nil {
944			return c.Describe(err)
945		}
946		if *asJSON {
947			return writeJSON(cmd.OutOrStdout(), toJSONComment(res.Msg.GetComment()))
948		}
949		verb := "resolved"
950		if undo {
951			verb = "reopened"
952		}
953		_, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s of %s!%d %s\n", clean(id), clean(args[0]), n, verb)
954		return err
955	}
956	return cmd
957}
958
959func newMRMergeCmd(env *cliEnv) *cobra.Command {
960	var squash, deleteBranch bool
961	var head string
962	cmd := &cobra.Command{
963		Use:   "merge <repo> <number>",
964		Short: "Merge a merge request",
965		Long: "Merge an open merge request. The default is a fast-forward: the target branch moves to the head\n" +
966			"and no commit is created. --squash makes one new commit with the tree of the head and the target\n" +
967			"as only parent, signed by the server's key (see `ermite signing-key show`). The target must be an\n" +
968			"ancestor of the head, else rebase the branch and push it again; the repository settings may allow\n" +
969			"only one of the modes.\n\n" +
970			"The server merges only if the source branch still points to the head you reviewed, so --head is\n" +
971			"required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +
972			"reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +
973			"mr:merge scope.\n\n" +
974			"--delete-branch deletes the source branch after the merge. The merge is refused when the branch\n" +
975			"is the default branch or used by another open merge request. If the deletion fails after the\n" +
976			"merge, the merge stands, the branch is kept and a warning goes to standard error." + jsonMRHelp +
977			"\n--json adds source_branch_deleted (true when the branch was deleted).",
978		Args:              cobra.ExactArgs(2),
979		ValidArgsFunction: completeMRRepoThenNumber(env),
980	}
981	cmd.Flags().BoolVar(&squash, "squash", false, "squash the branch into one signed commit instead of a fast-forward")
982	cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")
983	cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")
984	asJSON := addJSONFlag(cmd)
985	cmd.RunE = func(cmd *cobra.Command, args []string) error {
986		n, err := parseNumber(args[1])
987		if err != nil {
988			return err
989		}
990		head = strings.TrimSpace(head)
991		if head == "" {
992			return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
993				"so that only the commit you reviewed is merged", args[0], n)
994		}
995		c, err := env.client(cmd)
996		if err != nil {
997			return err
998		}
999		ctx := env.ctx(cmd)
1000		mode := ermite.MergeMode_MERGE_MODE_FAST_FORWARD
1001		if squash {
1002			mode = ermite.MergeMode_MERGE_MODE_SQUASH
1003		}
1004		res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{
1005			Repo: args[0], Number: n, Mode: mode, HeadId: head, DeleteSourceBranch: deleteBranch,
1006		}))
1007		if err != nil {
1008			return c.Describe(err)
1009		}
1010		m := res.Msg.GetMergeRequest()
1011		deleted := res.Msg.GetSourceBranchDeleted()
1012		if deleteBranch && !deleted {
1013			_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ermite: warning: the branch %s was kept (it moved after the merge, or the server cannot delete it)\n",
1014				clean(m.GetSourceBranch()))
1015		}
1016		if *asJSON {
1017			return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted})
1018		}
1019		_, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),
1020			clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId()))
1021		if err == nil && deleted {
1022			_, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted branch %s\n", clean(m.GetSourceBranch()))
1023		}
1024		return err
1025	}
1026	return cmd
1027}
1028
1029func newMRCloseCmd(env *cliEnv) *cobra.Command {
1030	cmd := &cobra.Command{
1031		Use:   "close <repo> <number>",
1032		Short: "Close a merge request without merging it",
1033		Long: "Close an open merge request without merging it. A closed merge request cannot be reopened: open a\n" +
1034			"new one. Prints a confirmation, then the full web URL on its own line. Needs the mr:write scope." + jsonMRHelp,
1035		Args:              cobra.ExactArgs(2),
1036		ValidArgsFunction: completeMRRepoThenNumber(env),
1037	}
1038	asJSON := addJSONFlag(cmd)
1039	cmd.RunE = func(cmd *cobra.Command, args []string) error {
1040		n, err := parseNumber(args[1])
1041		if err != nil {
1042			return err
1043		}
1044		c, err := env.client(cmd)
1045		if err != nil {
1046			return err
1047		}
1048		res, err := c.MergeRequests().CloseMergeRequest(env.ctx(cmd), connect.NewRequest(&ermite.CloseMergeRequestRequest{Repo: args[0], Number: n}))
1049		if err != nil {
1050			return c.Describe(err)
1051		}
1052		return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed", c.URL())
1053	}
1054	return cmd
1055}

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