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