v0.19.0 55a688e

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

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 3

Tags 44

↑↓ moveenter openesc close