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