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