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 itemWebURL(baseURL, repo, "merge-requests", number)
407}
408
409// itemWebURL builds <base>/<repo>/<kind>/<number>.
410func itemWebURL(baseURL, repo, kind string, number int64) string {
411 return strings.TrimRight(baseURL, "/") + "/" + url.PathEscape(repo) + "/" + kind + "/" + strconv.FormatInt(number, 10)
412}
413
414// outputMR prints a merge request as JSON, or a confirmation followed by its URL.
415func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb, baseURL string) error {
416 if asJSON {
417 return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, baseURL))
418 }
419 _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n%s\n", verb, clean(m.GetRepo()), m.GetNumber(),
420 clean(m.GetSourceBranch()), clean(m.GetTargetBranch()), mrWebURL(baseURL, m.GetRepo(), m.GetNumber()))
421 return err
422}
423
424func newMRListCmd(env *cliEnv) *cobra.Command {
425 var state string
426 cmd := &cobra.Command{
427 Use: "list <repo>",
428 Short: "List the merge requests of a repository",
429 Long: "List the merge requests of a repository, newest first. --state restricts them to open, merged or\n" +
430 "closed ones (default: open; all shows every state)." + jsonMRHelp + "\n--json prints an array of merge requests.",
431 Args: cobra.ExactArgs(1),
432 ValidArgsFunction: completeRepoNames(env),
433 }
434 cmd.Flags().StringVar(&state, "state", stateOpen, "state: open, merged, closed or all")
435 _ = cmd.RegisterFlagCompletionFunc("state", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
436 return []string{stateOpen, stateMerged, stateClosed, stateAll}, cobra.ShellCompDirectiveNoFileComp
437 })
438 asJSON := addJSONFlag(cmd)
439 cmd.RunE = func(cmd *cobra.Command, args []string) error {
440 st, err := parseState(state)
441 if err != nil {
442 return err
443 }
444 c, err := env.client(cmd)
445 if err != nil {
446 return err
447 }
448 mrs, err := listMergeRequests(env.ctx(cmd), c, args[0], st)
449 if err != nil {
450 return err
451 }
452 if *asJSON {
453 out := make([]jsonMergeRequest, 0, len(mrs))
454 for _, m := range mrs {
455 out = append(out, toJSONMergeRequest(m, c.URL()))
456 }
457 return writeJSON(cmd.OutOrStdout(), out)
458 }
459 if len(mrs) == 0 {
460 noticef(cmd, "no merge requests")
461 return nil
462 }
463 rows := make([][]string, 0, len(mrs))
464 for _, m := range mrs {
465 rows = append(rows, []string{
466 "!" + strconv.FormatInt(m.GetNumber(), 10), stateName(m.GetState()),
467 m.GetSourceBranch() + " -> " + m.GetTargetBranch(), m.GetAuthor(),
468 shortTime(m.GetUpdatedAt(), "-"), m.GetTitle(),
469 })
470 }
471 return table(cmd.OutOrStdout(), []string{"NUMBER", "STATE", "BRANCHES", "AUTHOR", "UPDATED", "TITLE"}, rows)
472 }
473 return cmd
474}
475
476// jsonStats are the totals of a diff.
477type jsonStats struct {
478 FilesChanged int32 `json:"files_changed"`
479 Additions int32 `json:"additions"`
480 Deletions int32 `json:"deletions"`
481}
482
483type jsonLine struct {
484 Kind string `json:"kind"`
485 OldNumber int32 `json:"old_number"`
486 NewNumber int32 `json:"new_number"`
487 Text string `json:"text"`
488}
489
490type jsonHunk struct {
491 Header string `json:"header"`
492 OldStart int32 `json:"old_start"`
493 OldLines int32 `json:"old_lines"`
494 NewStart int32 `json:"new_start"`
495 NewLines int32 `json:"new_lines"`
496 Lines []jsonLine `json:"lines"`
497}
498
499type jsonFile struct {
500 OldPath string `json:"old_path"`
501 NewPath string `json:"new_path"`
502 Status string `json:"status"`
503 Additions int32 `json:"additions"`
504 Deletions int32 `json:"deletions"`
505 Binary bool `json:"binary"`
506 Truncated bool `json:"truncated"`
507 // Omitted is generated, large, budget or unavailable; absent when the hunks are not omitted.
508 Omitted string `json:"omitted,omitempty"`
509 Hunks []jsonHunk `json:"hunks,omitempty"`
510}
511
512type jsonCommit struct {
513 ID string `json:"id"`
514 Author string `json:"author"`
515 Summary string `json:"summary"`
516}
517
518// jsonMRDetail is the stable JSON shape of `mr show --json`.
519type jsonMRDetail struct {
520 jsonMergeRequest
521 // Mergeability is null unless the request is open and the server computes it.
522 Mergeability *jsonMergeability `json:"mergeability"`
523 Commits []jsonCommit `json:"commits"`
524 CommitsTruncated bool `json:"commits_truncated"`
525 DiffBaseID string `json:"diff_base_id"`
526 Stats jsonStats `json:"stats"`
527 DiffTruncated bool `json:"diff_truncated"`
528 Files []jsonFile `json:"files"`
529 Comments []jsonComment `json:"comments"`
530 Verdicts []jsonAuthorVerdict `json:"verdicts"`
531 LastReadID string `json:"last_read_id"`
532 LastReadLost bool `json:"last_read_lost"`
533}
534
535type jsonAuthorVerdict struct {
536 Author string `json:"author"`
537 Verdict string `json:"verdict"`
538 CommentID string `json:"comment_id"`
539 CommitID string `json:"commit_id"`
540 CreatedAt *string `json:"created_at"`
541 Stale bool `json:"stale"`
542}
543
544func fileStatusName(s ermite.FileStatus) string {
545 return strings.ToLower(strings.TrimPrefix(s.String(), "FILE_STATUS_"))
546}
547
548// fileOmissionName is the lowercase reason of an omitted file, "" for none.
549func fileOmissionName(o ermite.FileOmission) string {
550 if o == ermite.FileOmission_FILE_OMISSION_UNSPECIFIED {
551 return ""
552 }
553 return strings.ToLower(strings.TrimPrefix(o.String(), "FILE_OMISSION_"))
554}
555
556func lineKindName(k ermite.LineKind) string {
557 return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))
558}
559
560func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail {
561 out := jsonMRDetail{
562 jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL),
563 Mergeability: toJSONMergeability(d.GetMergeRequest().GetMergeability()),
564 Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),
565 DiffBaseID: d.GetDiffBaseId(),
566 Stats: jsonStats{
567 FilesChanged: d.GetStats().GetFilesChanged(), Additions: d.GetStats().GetAdditions(), Deletions: d.GetStats().GetDeletions(),
568 },
569 DiffTruncated: d.GetDiff().GetTruncated(),
570 Files: []jsonFile{}, Comments: []jsonComment{}, Verdicts: []jsonAuthorVerdict{},
571 LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(),
572 }
573 for _, c := range d.GetCommits() {
574 out.Commits = append(out.Commits, jsonCommit{ID: c.GetId(), Author: c.GetAuthor().GetName(), Summary: c.GetSummary()})
575 }
576 for _, f := range d.GetDiff().GetFiles() {
577 jf := jsonFile{
578 OldPath: f.GetOldPath(), NewPath: f.GetNewPath(), Status: fileStatusName(f.GetStatus()),
579 Additions: f.GetAdditions(), Deletions: f.GetDeletions(), Binary: f.GetBinary(), Truncated: f.GetTruncated(),
580 Omitted: fileOmissionName(f.GetOmitted()),
581 }
582 if withDiff {
583 for _, h := range f.GetHunks() {
584 jh := jsonHunk{
585 Header: h.GetHeader(), OldStart: h.GetOldStart(), OldLines: h.GetOldLines(),
586 NewStart: h.GetNewStart(), NewLines: h.GetNewLines(), Lines: []jsonLine{},
587 }
588 for _, l := range h.GetLines() {
589 jh.Lines = append(jh.Lines, jsonLine{
590 Kind: lineKindName(l.GetKind()), OldNumber: l.GetOldNumber(), NewNumber: l.GetNewNumber(), Text: l.GetText(),
591 })
592 }
593 jf.Hunks = append(jf.Hunks, jh)
594 }
595 }
596 out.Files = append(out.Files, jf)
597 }
598 for _, c := range d.GetComments() {
599 out.Comments = append(out.Comments, toJSONComment(c))
600 }
601 for _, v := range d.GetVerdicts() {
602 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()})
603 }
604 return out
605}
606
607func newMRShowCmd(env *cliEnv) *cobra.Command {
608 var sinceLastRead, withDiff, markRead bool
609 cmd := &cobra.Command{
610 Use: "show <repo> <number>",
611 Short: "Show a merge request with its commits, comments and diff",
612 Long: "Show a merge request: its fields, the commits of the branch that the target lacks, the changed\n" +
613 "files and the comments. --diff adds the diff, with the line numbers to use in `mr comment`.\n" +
614 "--since-last-read shows the diff since the head recorded by --mark-read (or by the web) instead of\n" +
615 "the whole change; after a force-push it still works when the recorded commit exists, else the\n" +
616 "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" +
617 "the mr:write scope. The mergeability line says whether the branch merges with its target now: up to\n" +
618 "date (can be fast-forwarded), behind by N commits (merges without conflicts, squash or merge commit),\n" +
619 "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" +
620 "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +
621 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
622 "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated, omitted (generated, large, budget or unavailable; absent when the hunks are\n" +
623 "not omitted) and, with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
624 "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",
625 Args: cobra.ExactArgs(2),
626 ValidArgsFunction: completeMRRepoThenNumber(env),
627 }
628 cmd.Flags().BoolVar(&withDiff, "diff", false, "include the diff")
629 cmd.Flags().BoolVar(&sinceLastRead, "since-last-read", false, "diff since the head last marked read")
630 cmd.Flags().BoolVar(&markRead, "mark-read", false, "record the head shown as read (needs mr:write)")
631 asJSON := addJSONFlag(cmd)
632 cmd.RunE = func(cmd *cobra.Command, args []string) error {
633 n, err := parseNumber(args[1])
634 if err != nil {
635 return err
636 }
637 c, err := env.client(cmd)
638 if err != nil {
639 return err
640 }
641 ctx := env.ctx(cmd)
642 res, err := c.MergeRequests().GetMergeRequest(ctx, connect.NewRequest(&ermite.GetMergeRequestRequest{
643 Repo: args[0], Number: n, SinceLastRead: sinceLastRead,
644 }))
645 if err != nil {
646 return c.Describe(err)
647 }
648 d := res.Msg
649 if *asJSON {
650 err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff, c.URL()))
651 } else {
652 err = printMRDetail(cmd.OutOrStdout(), d, withDiff, sinceLastRead)
653 }
654 if err != nil {
655 return err
656 }
657 if markRead && d.GetMergeRequest().GetHeadId() != "" {
658 _, err = c.MergeRequests().MarkRead(ctx, connect.NewRequest(&ermite.MarkReadRequest{
659 Repo: args[0], Number: n, HeadId: d.GetMergeRequest().GetHeadId(),
660 }))
661 if err != nil {
662 return c.Describe(err)
663 }
664 }
665 if d.GetLastReadLost() {
666 noticef(cmd, "the head you last read is gone (force-push?): showing the whole change")
667 }
668 return nil
669 }
670 return cmd
671}
672
673func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, since bool) error {
674 m := d.GetMergeRequest()
675 desc := m.GetDescription()
676 if desc == "" {
677 desc = "-"
678 }
679 pairs := [][2]string{
680 {"merge request", fmt.Sprintf("%s!%d", m.GetRepo(), m.GetNumber())},
681 {"title", m.GetTitle()},
682 {"state", stateName(m.GetState())},
683 {"branches", m.GetSourceBranch() + " -> " + m.GetTargetBranch()},
684 {"author", m.GetAuthor()},
685 {"created", shortTime(m.GetCreatedAt(), "-")},
686 {"updated", shortTime(m.GetUpdatedAt(), "-")},
687 {"head", orDash(m.GetHeadId())},
688 }
689 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {
690 merged := orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"
691 pairs = append(pairs, [2]string{"merged", merged})
692 if m.GetAutoMerged() {
693 pairs = append(pairs, [2]string{"auto-merged", "yes, by the server once the checks passed"})
694 }
695 }
696 if am := m.GetAutoMerge(); am != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
697 pairs = append(pairs, [2]string{"auto-merge", modeName(am.GetMode()) + " when ready (enabled by " + am.GetEnabledBy() + ")"})
698 }
699 if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
700 pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)})
701 }
702 if len(d.GetVerdicts()) > 0 {
703 var verdicts []string
704 for _, v := range d.GetVerdicts() {
705 var label string
706 switch v.GetVerdict() {
707 case ermite.Verdict_VERDICT_APPROVE:
708 label = "approved"
709 case ermite.Verdict_VERDICT_CHANGES_REQUESTED:
710 label = "changes requested"
711 default:
712 label = fmt.Sprintf("verdict %d", v.GetVerdict())
713 }
714 if v.GetStale() {
715 label += " (stale)"
716 }
717 verdicts = append(verdicts, v.GetAuthor()+" "+label)
718 }
719 pairs = append(pairs, [2]string{"verdicts", strings.Join(verdicts, ", ")})
720 }
721 if err := fields(w, pairs); err != nil {
722 return err
723 }
724 _, _ = fmt.Fprintf(w, "\n%s\n", indentText(desc))
725
726 _, _ = fmt.Fprintf(w, "\ncommits (%d%s)\n", len(d.GetCommits()), plus(d.GetCommitsTruncated()))
727 for _, c := range d.GetCommits() {
728 _, _ = fmt.Fprintf(w, " %s %s %s\n", shortID(c.GetId()), clean(c.GetAuthor().GetName()), clean(c.GetSummary()))
729 }
730
731 st := d.GetStats()
732 what := "changes"
733 if since && !d.GetLastReadLost() && d.GetLastReadId() != "" {
734 what = "changes since " + shortID(d.GetLastReadId())
735 }
736 _, _ = fmt.Fprintf(w, "\n%s: %d file(s), +%d -%d, from %s\n", what, st.GetFilesChanged(), st.GetAdditions(), st.GetDeletions(), orDash(shortID(d.GetDiffBaseId())))
737 for _, f := range d.GetDiff().GetFiles() {
738 path := f.GetNewPath()
739 if path == "" {
740 path = f.GetOldPath()
741 }
742 note := ""
743 if f.GetTruncated() {
744 note = " (too large, not shown)"
745 }
746 _, _ = fmt.Fprintf(w, " %-8s +%d -%d %s%s\n", fileStatusName(f.GetStatus()), f.GetAdditions(), f.GetDeletions(), clean(path), note)
747 }
748 if d.GetDiff().GetTruncated() {
749 _, _ = fmt.Fprintln(w, " (the diff is truncated)")
750 }
751
752 _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments()))
753 var ordered []*ermite.Comment
754 for _, root := range d.GetComments() {
755 if root.GetReplyTo() != "" {
756 continue
757 }
758 ordered = append(ordered, root)
759 for _, reply := range d.GetComments() {
760 if reply.GetReplyTo() == root.GetId() {
761 ordered = append(ordered, reply)
762 }
763 }
764 }
765 for _, c := range ordered {
766 where := "general"
767 if c.GetPath() != "" {
768 where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide()))
769 }
770 if c.GetStartLine() > 0 {
771 where = fmt.Sprintf("%s:lines %d-%d (%s)", clean(c.GetPath()), c.GetStartLine(), c.GetLine(), sideName(c.GetSide()))
772 }
773 mark := ""
774 if c.GetEditedAt() != nil {
775 mark += " (edited)"
776 }
777 if c.GetDeleted() {
778 mark += " [deleted]"
779 }
780 if c.GetVerdict() != ermite.Verdict_VERDICT_UNSPECIFIED {
781 mark += " [" + verdictName(c.GetVerdict()) + "]"
782 }
783 if c.GetResolved() {
784 mark += " [resolved]"
785 }
786 prefix := " "
787 body := indentText(c.GetText())
788 if c.GetReplyTo() != "" {
789 prefix += " "
790 body = " " + strings.ReplaceAll(body, "\n", "\n ")
791 }
792 _, _ = fmt.Fprintf(w, "%s#%s %s, %s, %s%s\n%s\n", prefix, c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, body)
793 }
794 if withDiff {
795 printDiff(w, d.GetDiff())
796 }
797 return nil
798}
799
800func orDash(s string) string {
801 if s == "" {
802 return "-"
803 }
804 return s
805}
806
807func plus(truncated bool) string {
808 if truncated {
809 return "+"
810 }
811 return ""
812}
813
814func shortID(id string) string {
815 if len(id) > 10 {
816 return id[:10]
817 }
818 return id
819}
820
821// indentText indents a multi-line text by four spaces, making each line safe
822// for a terminal.
823func indentText(s string) string {
824 lines := strings.Split(s, "\n")
825 for i, l := range lines {
826 lines[i] = " " + client.Clean(l)
827 }
828 return strings.Join(lines, "\n")
829}
830
831// printDiff prints the diff with a gutter of the old and new line numbers,
832// which are the numbers `mr comment --line` takes with --side old or new.
833func printDiff(w io.Writer, d *ermite.Diff) {
834 _, _ = fmt.Fprintln(w, "\ndiff (old and new line numbers on the left)")
835 num := func(n int32) string {
836 if n == 0 {
837 return ""
838 }
839 return strconv.Itoa(int(n))
840 }
841 for _, f := range d.GetFiles() {
842 _, _ = fmt.Fprintf(w, "\n--- %s\n+++ %s\n", clean(orDash(f.GetOldPath())), clean(orDash(f.GetNewPath())))
843 if f.GetBinary() {
844 _, _ = fmt.Fprintln(w, "(binary file)")
845 }
846 if f.GetTruncated() {
847 _, _ = fmt.Fprintln(w, "(too large, not shown)")
848 }
849 for _, h := range f.GetHunks() {
850 _, _ = fmt.Fprintf(w, "@@ -%d,%d +%d,%d @@ %s\n", h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines(), clean(h.GetHeader()))
851 for _, l := range h.GetLines() {
852 sign := map[ermite.LineKind]string{
853 ermite.LineKind_LINE_KIND_ADDITION: "+", ermite.LineKind_LINE_KIND_DELETION: "-",
854 }[l.GetKind()]
855 if sign == "" {
856 sign = " "
857 }
858 _, _ = fmt.Fprintf(w, "%5s %5s %s%s\n", num(l.GetOldNumber()), num(l.GetNewNumber()), sign, clean(l.GetText()))
859 }
860 }
861 }
862}
863
864func newMRCommentCmd(env *cliEnv) *cobra.Command {
865 var message, path, side, line, reply string
866 var approve, requestChanges bool
867 cmd := &cobra.Command{
868 Use: "comment <repo> <number>",
869 Short: "Comment on a merge request",
870 Long: "Add a Markdown note (at most 8000 characters); ^N refers to a note in this merge request.\n" +
871 "--reply replies to a root note. --line accepts N or A-B. --approve and --request-changes\n" +
872 "set a verdict (needs mr:review) and allow an empty message.\n" +
873 "-m - reads it from standard input. Without --path it is a general comment. With --path, --line and\n" +
874 "--side (default new) it is attached to a line of the current diff: the path as in `mr show --diff`\n" +
875 "(the new path, the old one for a deleted file), the line number on the old or new side; a line\n" +
876 "that is not in the diff is refused. Needs the mr:write scope." + jsonCommentHelp,
877 Args: cobra.ExactArgs(2),
878 ValidArgsFunction: completeMRRepoThenNumber(env),
879 }
880 cmd.Flags().StringVarP(&message, "message", "m", "", "Markdown text, or - to read standard input (required unless setting a verdict)")
881 cmd.Flags().StringVar(&path, "path", "", "file the comment is attached to")
882 cmd.Flags().StringVar(&line, "line", "", "1-based line N or range A-B on --side")
883 cmd.Flags().StringVar(&reply, "reply", "", "reply to root note (^12, #12 or 12)")
884 cmd.Flags().BoolVar(&approve, "approve", false, "approve (needs mr:review)")
885 cmd.Flags().BoolVar(&requestChanges, "request-changes", false, "request changes (needs mr:review)")
886 cmd.Flags().StringVar(&side, "side", sideNew, "side the line counts on: old or new")
887 cmd.MarkFlagsMutuallyExclusive("approve", "request-changes")
888 for _, flag := range []string{"path", "line", "side", "approve", "request-changes"} {
889 cmd.MarkFlagsMutuallyExclusive("reply", flag)
890 }
891 // A verdict note may omit its message.
892 _ = cmd.RegisterFlagCompletionFunc("side", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
893 return []string{sideOld, sideNew}, cobra.ShellCompDirectiveNoFileComp
894 })
895 asJSON := addJSONFlag(cmd)
896 cmd.RunE = func(cmd *cobra.Command, args []string) error {
897 n, err := parseNumber(args[1])
898 if err != nil {
899 return err
900 }
901 text, err := readText(cmd, message)
902 if err != nil {
903 return err
904 }
905 if !cmd.Flags().Changed("message") && !approve && !requestChanges {
906 return errors.New("--message is required unless --approve or --request-changes is set")
907 }
908 req := &ermite.AddCommentRequest{Repo: args[0], Number: n, Text: text}
909 if approve {
910 req.Verdict = ermite.Verdict_VERDICT_APPROVE
911 }
912 if requestChanges {
913 req.Verdict = ermite.Verdict_VERDICT_CHANGES_REQUESTED
914 }
915 if cmd.Flags().Changed("reply") {
916 req.ReplyTo, err = parseNoteID(reply)
917 if err != nil {
918 return err
919 }
920 }
921 switch {
922 case path != "":
923 if line == "" {
924 return errors.New("--line is required with --path: give the line number")
925 }
926 req.StartLine, req.Line, err = parseLineRange(line)
927 if err != nil {
928 return err
929 }
930 if req.Side, err = parseSide(side); err != nil {
931 return err
932 }
933 req.Path = path
934 case cmd.Flags().Changed("line") || cmd.Flags().Changed("side"):
935 return errors.New("--line and --side need --path")
936 }
937 c, err := env.client(cmd)
938 if err != nil {
939 return err
940 }
941 res, err := c.MergeRequests().AddComment(env.ctx(cmd), connect.NewRequest(req))
942 if err != nil {
943 return c.Describe(err)
944 }
945 if *asJSON {
946 return writeJSON(cmd.OutOrStdout(), toJSONComment(res.Msg.GetComment()))
947 }
948 _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s added to %s!%d\n", res.Msg.GetComment().GetId(), clean(args[0]), n)
949 return err
950 }
951 return cmd
952}
953
954func parseNoteID(s string) (string, error) {
955 id := strings.TrimLeft(strings.TrimSpace(s), "^#")
956 n, err := strconv.ParseInt(id, 10, 64)
957 if err != nil || n < 1 {
958 return "", fmt.Errorf("invalid note id %q: use ^12, #12 or 12", s)
959 }
960 return strconv.FormatInt(n, 10), nil
961}
962
963func parseLineRange(s string) (start, end int32, err error) {
964 parts := strings.Split(s, "-")
965 invalid := fmt.Errorf("invalid --line %q: use N or A-B with 1 <= A < B", s)
966 if len(parts) < 1 || len(parts) > 2 {
967 return 0, 0, invalid
968 }
969 parse := func(v string) (int32, error) {
970 n, e := strconv.ParseInt(v, 10, 32)
971 if e != nil || n < 1 {
972 return 0, invalid
973 }
974 return int32(n), nil
975 }
976 end, err = parse(parts[len(parts)-1])
977 if err != nil {
978 return 0, 0, err
979 }
980 if len(parts) == 2 {
981 start, err = parse(parts[0])
982 if err != nil || start >= end {
983 return 0, 0, invalid
984 }
985 }
986 return start, end, nil
987}
988
989func newMREditCommentCmd(env *cliEnv) *cobra.Command { return newMRNoteMutationCmd(env, true) }
990func newMRDeleteCommentCmd(env *cliEnv) *cobra.Command { return newMRNoteMutationCmd(env, false) }
991
992func newMRNoteMutationCmd(env *cliEnv, edit bool) *cobra.Command {
993 verb := "delete"
994 if edit {
995 verb = "edit"
996 }
997 var message string
998 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)}
999 if edit {
1000 cmd.Flags().StringVarP(&message, "message", "m", "", "Markdown text, or - to read standard input (required)")
1001 _ = cmd.MarkFlagRequired("message")
1002 }
1003 asJSON := addJSONFlag(cmd)
1004 cmd.RunE = func(cmd *cobra.Command, args []string) error {
1005 n, err := parseNumber(args[1])
1006 if err != nil {
1007 return err
1008 }
1009 id, err := parseNoteID(args[2])
1010 if err != nil {
1011 return err
1012 }
1013 text, err := readText(cmd, message)
1014 if err != nil {
1015 return err
1016 }
1017 c, err := env.client(cmd)
1018 if err != nil {
1019 return err
1020 }
1021 var note *ermite.Comment
1022 if edit {
1023 res, e := c.MergeRequests().EditComment(env.ctx(cmd), connect.NewRequest(&ermite.EditCommentRequest{Repo: args[0], Number: n, CommentId: id, Text: text}))
1024 if e != nil {
1025 return c.Describe(e)
1026 }
1027 note = res.Msg.GetComment()
1028 } else {
1029 res, e := c.MergeRequests().DeleteComment(env.ctx(cmd), connect.NewRequest(&ermite.DeleteCommentRequest{Repo: args[0], Number: n, CommentId: id}))
1030 if e != nil {
1031 return c.Describe(e)
1032 }
1033 note = res.Msg.GetComment()
1034 }
1035 if *asJSON {
1036 return writeJSON(cmd.OutOrStdout(), toJSONComment(note))
1037 }
1038 past := "deleted"
1039 if edit {
1040 past = "edited"
1041 }
1042 _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s of %s!%d %s\n", clean(id), clean(args[0]), n, past)
1043 return err
1044 }
1045 return cmd
1046}
1047
1048func newMRResolveCmd(env *cliEnv) *cobra.Command {
1049 var undo bool
1050 cmd := &cobra.Command{
1051 Use: "resolve <repo> <number> <comment-id>",
1052 Short: "Mark a comment resolved",
1053 Long: "Mark a comment of a merge request resolved, or unresolved with --undo. Doing it twice is not an\n" +
1054 "error. Needs the mr:write scope." + jsonCommentHelp,
1055 Args: cobra.ExactArgs(3),
1056 ValidArgsFunction: completeMRRepoThenNumber(env),
1057 }
1058 cmd.Flags().BoolVar(&undo, "undo", false, "mark the comment unresolved instead")
1059 asJSON := addJSONFlag(cmd)
1060 cmd.RunE = func(cmd *cobra.Command, args []string) error {
1061 n, err := parseNumber(args[1])
1062 if err != nil {
1063 return err
1064 }
1065 id := strings.TrimPrefix(strings.TrimSpace(args[2]), "#")
1066 c, err := env.client(cmd)
1067 if err != nil {
1068 return err
1069 }
1070 res, err := c.MergeRequests().ResolveComment(env.ctx(cmd), connect.NewRequest(&ermite.ResolveCommentRequest{
1071 Repo: args[0], Number: n, CommentId: id, Resolved: !undo,
1072 }))
1073 if err != nil {
1074 return c.Describe(err)
1075 }
1076 if *asJSON {
1077 return writeJSON(cmd.OutOrStdout(), toJSONComment(res.Msg.GetComment()))
1078 }
1079 verb := "resolved"
1080 if undo {
1081 verb = "reopened"
1082 }
1083 _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s of %s!%d %s\n", clean(id), clean(args[0]), n, verb)
1084 return err
1085 }
1086 return cmd
1087}
1088
1089func newMRMergeCmd(env *cliEnv) *cobra.Command {
1090 var mode string
1091 var deleteBranch bool
1092 var head string
1093 var auto, cancelAuto, ignoreChecks bool
1094 cmd := &cobra.Command{
1095 Use: "merge <repo> <number>",
1096 Short: "Merge a merge request",
1097 Long: "Merge an open merge request with --mode (default fast-forward; the repository settings may allow\n" +
1098 "only some modes, see `ermite repo edit --merge-modes`):\n" +
1099 " fast-forward the target branch moves to the head and no commit is created. The target must be\n" +
1100 " an ancestor of the head, else rebase the branch and push it again, or use another mode.\n" +
1101 " squash one new commit with the tree of the merge of the head and the target, the target tip\n" +
1102 " as only parent. The branch must merge without conflicts.\n" +
1103 " merge a merge commit with two parents, the target tip and the head. The branch must merge\n" +
1104 " without conflicts.\n" +
1105 "A squash and a merge commit are signed with the server's own key, generated automatically when the\n" +
1106 "server first starts; `ermite signing-key show` prints it. The server never rebases. `ermite mr show`\n" +
1107 "prints the mergeability (up to date, behind, conflict); the server checks again when merging and\n" +
1108 "refuses a merge that is not possible, saying why.\n\n" +
1109 "The server merges only if the source branch still points to the head you reviewed, so --head is\n" +
1110 "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +
1111 "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +
1112 "mr:merge scope.\n\n" +
1113 "A merge needs the checks on the head to pass: none failed, none queued or running. A head\n" +
1114 "without checks can be merged. --ignore-checks merges anyway and needs the mr:force-merge scope\n" +
1115 "(the cli preset has it, the agent preset does not).\n\n" +
1116 "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" +
1117 "the source branch right after, in the background. The merge is refused when the branch is the\n" +
1118 "default branch or used by another open merge request. If the deletion later fails (the branch\n" +
1119 "moved, say), the merge stands and the branch is kept.\n\n" +
1120 "--auto does not merge now: it asks the server to merge by itself, with --mode, once CI has settled\n" +
1121 "with at least one successful check on the head and every check allows it. Needs the mr:merge scope,\n" +
1122 "a server with CI, and `ermite repo edit --allow-auto-merge` on the repository. A new push to the\n" +
1123 "source branch, a change of the settings or of your permissions cancels it. --auto takes --head\n" +
1124 "like a plain merge: it is enabled only if the source branch still points to it. --cancel-auto cancels a pending auto-merge." + jsonMRHelp +
1125 "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).",
1126 Args: cobra.ExactArgs(2),
1127 ValidArgsFunction: completeMRRepoThenNumber(env),
1128 }
1129 cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)")
1130 cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")
1131 cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")
1132 cmd.Flags().BoolVar(&auto, "auto", false, "merge by itself once the checks passed (needs `repo edit --allow-auto-merge`)")
1133 cmd.Flags().BoolVar(&cancelAuto, "cancel-auto", false, "cancel the pending auto-merge")
1134 cmd.Flags().BoolVar(&ignoreChecks, "ignore-checks", false, "merge despite failing or unfinished checks (needs mr:force-merge)")
1135 cmd.MarkFlagsMutuallyExclusive("ignore-checks", "auto")
1136 cmd.MarkFlagsMutuallyExclusive("ignore-checks", "cancel-auto")
1137 cmd.MarkFlagsMutuallyExclusive("auto", "cancel-auto")
1138 cmd.MarkFlagsMutuallyExclusive("cancel-auto", "delete-branch")
1139 cmd.MarkFlagsMutuallyExclusive("cancel-auto", "head")
1140 cmd.MarkFlagsMutuallyExclusive("auto", "delete-branch")
1141 _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
1142 return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
1143 })
1144 asJSON := addJSONFlag(cmd)
1145 cmd.RunE = func(cmd *cobra.Command, args []string) error {
1146 n, err := parseNumber(args[1])
1147 if err != nil {
1148 return err
1149 }
1150 if cancelAuto {
1151 return runMRCancelAuto(env, cmd, args[0], n, *asJSON)
1152 }
1153 pbMode, err := parseMode(mode)
1154 if err != nil {
1155 return err
1156 }
1157 head = strings.TrimSpace(head)
1158 if head == "" {
1159 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
1160 "so that only the commit you reviewed is merged", args[0], n)
1161 }
1162 if auto {
1163 return runMREnableAuto(env, cmd, args[0], n, pbMode, head, *asJSON)
1164 }
1165 c, err := env.client(cmd)
1166 if err != nil {
1167 return err
1168 }
1169 ctx := env.ctx(cmd)
1170 if ignoreChecks {
1171 if err := requireFeature(ctx, c, client.FeatureIgnoreChecks, "cannot bypass checks (--ignore-checks)"); err != nil {
1172 return err
1173 }
1174 }
1175 if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT {
1176 if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil {
1177 return err
1178 }
1179 }
1180 res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{
1181 Repo: args[0], Number: n, Mode: pbMode, HeadId: head, DeleteSourceBranch: deleteBranch, IgnoreChecks: ignoreChecks,
1182 }))
1183 if err != nil {
1184 return c.Describe(err)
1185 }
1186 m := res.Msg.GetMergeRequest()
1187 deleted := res.Msg.GetSourceBranchDeleted()
1188 if *asJSON {
1189 return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted})
1190 }
1191 out := cmd.OutOrStdout()
1192 _, err = fmt.Fprintf(out, "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),
1193 clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId()))
1194 if err == nil && pbMode != ermite.MergeMode_MERGE_MODE_FAST_FORWARD {
1195 _, err = fmt.Fprintln(out, signedNote(ctx, c))
1196 }
1197 if err == nil && deleted {
1198 _, err = fmt.Fprintf(out, "deleting branch %s (done by the server after the merge)\n", clean(m.GetSourceBranch()))
1199 }
1200 return err
1201 }
1202 return cmd
1203}
1204
1205// runMREnableAuto is `mr merge --auto`: it asks the server to merge once ready.
1206func runMREnableAuto(env *cliEnv, cmd *cobra.Command, repo string, n int64, mode ermite.MergeMode, head string, asJSON bool) error {
1207 c, err := env.client(cmd)
1208 if err != nil {
1209 return err
1210 }
1211 ctx := env.ctx(cmd)
1212 if err := requireFeature(ctx, c, client.FeatureAutoMerge, "cannot merge by itself (--auto)"); err != nil {
1213 return err
1214 }
1215 res, err := c.MergeRequests().EnableAutoMerge(ctx, connect.NewRequest(&ermite.EnableAutoMergeRequest{Repo: repo, Number: n, Mode: mode, HeadId: head}))
1216 if err != nil {
1217 return c.Describe(err)
1218 }
1219 m := res.Msg.GetMergeRequest()
1220 if asJSON {
1221 return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, c.URL()))
1222 }
1223 _, 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(),
1224 modeName(m.GetAutoMerge().GetMode()), clean(m.GetTargetBranch()), clean(m.GetAutoMerge().GetEnabledBy()))
1225 return err
1226}
1227
1228// runMRCancelAuto is `mr merge --cancel-auto`.
1229func runMRCancelAuto(env *cliEnv, cmd *cobra.Command, repo string, n int64, asJSON bool) error {
1230 c, err := env.client(cmd)
1231 if err != nil {
1232 return err
1233 }
1234 ctx := env.ctx(cmd)
1235 if err := requireFeature(ctx, c, client.FeatureAutoMerge, "has no auto-merge to cancel (--cancel-auto)"); err != nil {
1236 return err
1237 }
1238 res, err := c.MergeRequests().CancelAutoMerge(ctx, connect.NewRequest(&ermite.CancelAutoMergeRequest{Repo: repo, Number: n}))
1239 if err != nil {
1240 return c.Describe(err)
1241 }
1242 m := res.Msg.GetMergeRequest()
1243 if asJSON {
1244 return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, c.URL()))
1245 }
1246 _, err = fmt.Fprintf(cmd.OutOrStdout(), "auto-merge cancelled on %s!%d\n", clean(m.GetRepo()), m.GetNumber())
1247 return err
1248}
1249
1250// signedNote says that a squash or merge commit is signed by the server's
1251// key, with its fingerprint when the server tells it.
1252func signedNote(ctx context.Context, c *client.Client) string {
1253 const how = "; `ermite signing-key show` prints the key to verify it"
1254 res, err := c.Server().GetSigningKey(ctx, connect.NewRequest(&ermite.GetSigningKeyRequest{}))
1255 if err != nil || res.Msg.GetFingerprint() == "" {
1256 return "the commit is signed by the server's key" + how
1257 }
1258 return "the commit is signed by the server's key " + clean(res.Msg.GetFingerprint()) + how
1259}
1260
1261// requireFeature refuses to go on with a server that does not list the
1262// feature. A server whose features cannot be read is refused too.
1263func requireFeature(ctx context.Context, c *client.Client, feature, what string) error {
1264 res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
1265 if err != nil {
1266 return c.Describe(err)
1267 }
1268 if !slices.Contains(res.Msg.GetFeatures(), feature) {
1269 return fmt.Errorf("the server does not support %s, so it %s (upgrade the server, see `ermite version`)", feature, what)
1270 }
1271 return nil
1272}
1273
1274func newMRCloseCmd(env *cliEnv) *cobra.Command {
1275 cmd := &cobra.Command{
1276 Use: "close <repo> <number>",
1277 Short: "Close a merge request without merging it",
1278 Long: "Close an open merge request without merging it. A closed merge request cannot be reopened: open a\n" +
1279 "new one. Prints a confirmation, then the full web URL on its own line. Needs the mr:write scope." + jsonMRHelp,
1280 Args: cobra.ExactArgs(2),
1281 ValidArgsFunction: completeMRRepoThenNumber(env),
1282 }
1283 asJSON := addJSONFlag(cmd)
1284 cmd.RunE = func(cmd *cobra.Command, args []string) error {
1285 n, err := parseNumber(args[1])
1286 if err != nil {
1287 return err
1288 }
1289 c, err := env.client(cmd)
1290 if err != nil {
1291 return err
1292 }
1293 res, err := c.MergeRequests().CloseMergeRequest(env.ctx(cmd), connect.NewRequest(&ermite.CloseMergeRequestRequest{Repo: args[0], Number: n}))
1294 if err != nil {
1295 return c.Describe(err)
1296 }
1297 return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed", c.URL())
1298 }
1299 return cmd
1300}
1301
1302func newMRReadyCmd(env *cliEnv) *cobra.Command {
1303 cmd := &cobra.Command{
1304 Use: "ready <repo> <number>",
1305 Short: "Mark a draft merge request ready for review",
1306 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,
1307 Args: cobra.ExactArgs(2),
1308 ValidArgsFunction: completeMRRepoThenNumber(env),
1309 }
1310 asJSON := addJSONFlag(cmd)
1311 cmd.RunE = func(cmd *cobra.Command, args []string) error {
1312 n, err := parseNumber(args[1])
1313 if err != nil {
1314 return err
1315 }
1316 c, err := env.client(cmd)
1317 if err != nil {
1318 return err
1319 }
1320 ctx := env.ctx(cmd)
1321 if err := requireFeature(ctx, c, client.FeatureMarkReady, "cannot mark drafts ready (mr ready)"); err != nil {
1322 return err
1323 }
1324 res, err := c.MergeRequests().MarkReady(ctx, connect.NewRequest(&ermite.MarkReadyRequest{Repo: args[0], Number: n}))
1325 if err != nil {
1326 return c.Describe(err)
1327 }
1328 return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "marked ready", c.URL())
1329 }
1330 return cmd
1331}