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

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