v0.28.0 fca9155

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

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