Commit 421defaffc
Verified · cmc
Layout: unified · split
cmd/gitbay/local.go +231
| @@ -6,12 +6,15 @@ import ( | ||
| 6 | 6 | "os" |
| 7 | 7 | "os/exec" |
| 8 | 8 | "path/filepath" |
| 9 | "strconv" | |
| 9 | 10 | "strings" |
| 10 | 11 | |
| 12 | "github.com/spf13/cobra" | |
| 11 | 13 | "golang.org/x/term" |
| 12 | 14 | |
| 13 | 15 | "gitbay.org/gitbay/internal/cliconfig" |
| 14 | 16 | "gitbay.org/gitbay/internal/protocol" |
| 17 | "gitbay.org/gitbay/internal/suggest" | |
| 15 | 18 | "gitbay.org/gitbay/internal/toolpath" |
| 16 | 19 | ) |
| 17 | 20 | |
| @@ -413,3 +416,231 @@ func worktreeDirty() (bool, error) { | ||
| 413 | 416 | } |
| 414 | 417 | return strings.TrimSpace(string(out)) != "", nil |
| 415 | 418 | } |
| 419 | ||
| 420 | // mrApplySuggestionCmd is `gitbay mr apply-suggestion`: the server's | |
| 421 | // command where the server can commit the suggestion, and a local commit | |
| 422 | // where it cannot. | |
| 423 | func mrApplySuggestionCmd() *cobra.Command { | |
| 424 | server := []string{"mr", "apply-suggestion"} | |
| 425 | return &cobra.Command{ | |
| 426 | Use: "apply-suggestion", | |
| 427 | Short: summaries["mr apply-suggestion"], | |
| 428 | Annotations: map[string]string{ | |
| 429 | serverPath: "mr apply-suggestion", | |
| 430 | stdinMode: "none", | |
| 431 | }, | |
| 432 | DisableFlagParsing: true, | |
| 433 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 434 | for _, a := range args { | |
| 435 | if a == "--help" || a == "-h" { | |
| 436 | os.Exit(runServerHelp(passOpts{server: server}, cliPathOf(cmd))) | |
| 437 | } | |
| 438 | } | |
| 439 | os.Exit(cmdMRApplySuggestion(args)) | |
| 440 | return nil | |
| 441 | }, | |
| 442 | } | |
| 443 | } | |
| 444 | ||
| 445 | // suggestionJSON is the suggestion `mr threads --json` carries. | |
| 446 | type suggestionJSON struct { | |
| 447 | Path string `json:"path"` | |
| 448 | StartLine int `json:"start_line"` | |
| 449 | EndLine int `json:"end_line"` | |
| 450 | Original string `json:"original"` | |
| 451 | Replacement string `json:"replacement"` | |
| 452 | Outdated bool `json:"outdated"` | |
| 453 | Reason string `json:"reason"` | |
| 454 | Apply string `json:"apply"` | |
| 455 | } | |
| 456 | ||
| 457 | // cmdMRApplySuggestion implements `gitbay mr apply-suggestion [<owner/name>] | |
| 458 | // <n> <thread>`. The thread's suggestion says where it applies. Where the | |
| 459 | // server can commit it, the server's command does. On a repository | |
| 460 | // requiring signed commits the server has no key to sign with, so the | |
| 461 | // commit is made here, signed by whatever the user's git config signs | |
| 462 | // with (#288): the source branch is fetched into this clone's objects, | |
| 463 | // the anchored lines are checked against what the suggestion was made | |
| 464 | // against, the commit is built with plumbing (the working tree and | |
| 465 | // branches are not touched) and signed with commit-tree -S, and pushed | |
| 466 | // as a fast-forward, which fails if the branch moved meanwhile. Then the | |
| 467 | // thread is resolved. | |
| 468 | func cmdMRApplySuggestion(args []string) int { | |
| 469 | t, err := resolveTarget() | |
| 470 | if err != nil { | |
| 471 | fmt.Fprintln(os.Stderr, "gitbay:", err) | |
| 472 | return protocol.ExitFailure | |
| 473 | } | |
| 474 | if args, err = withRepo(t, args); err != nil { | |
| 475 | fmt.Fprintln(os.Stderr, "gitbay:", err) | |
| 476 | return protocol.ExitUsage | |
| 477 | } | |
| 478 | if len(args) != 3 { | |
| 479 | fmt.Fprintln(os.Stderr, "usage: gitbay mr apply-suggestion [<owner/name>] <n> <thread-id>") | |
| 480 | return protocol.ExitUsage | |
| 481 | } | |
| 482 | repo, n, thread := args[0], args[1], args[2] | |
| 483 | out, code := captureSSH(t, []string{"mr", "threads", repo, n, "--json"}) | |
| 484 | if code != 0 { | |
| 485 | return code | |
| 486 | } | |
| 487 | var threads struct { | |
| 488 | Data []struct { | |
| 489 | ID int64 `json:"id"` | |
| 490 | Comments []struct { | |
| 491 | Author string `json:"author"` | |
| 492 | } `json:"comments"` | |
| 493 | Suggestion *suggestionJSON `json:"suggestion"` | |
| 494 | } `json:"data"` | |
| 495 | } | |
| 496 | if err := json.Unmarshal([]byte(out), &threads); err != nil { | |
| 497 | fmt.Fprintln(os.Stderr, "gitbay: reading threads:", err) | |
| 498 | return protocol.ExitProtocol | |
| 499 | } | |
| 500 | var author string | |
| 501 | var s *suggestionJSON | |
| 502 | found := false | |
| 503 | for _, th := range threads.Data { | |
| 504 | if strconv.FormatInt(th.ID, 10) == thread { | |
| 505 | found, s = true, th.Suggestion | |
| 506 | if len(th.Comments) > 0 { | |
| 507 | author = th.Comments[0].Author | |
| 508 | } | |
| 509 | } | |
| 510 | } | |
| 511 | switch { | |
| 512 | case !found: | |
| 513 | fmt.Fprintf(os.Stderr, "gitbay: no thread %s on %s!%s\n", thread, repo, n) | |
| 514 | return protocol.ExitNotFound | |
| 515 | case s == nil: | |
| 516 | fmt.Fprintf(os.Stderr, "gitbay: thread %s carries no suggestion\n", thread) | |
| 517 | return protocol.ExitUsage | |
| 518 | case s.Apply != "local": | |
| 519 | return runSSH(t, []string{"mr", "apply-suggestion", repo, n, thread}, strings.NewReader("")) | |
| 520 | case s.Outdated: | |
| 521 | fmt.Fprintf(os.Stderr, "gitbay: suggestion in thread %s is outdated: %s\n", thread, s.Reason) | |
| 522 | return protocol.ExitUsage | |
| 523 | } | |
| 524 | if _, code := gitOutput("", "rev-parse", "--git-dir"); code != 0 { | |
| 525 | fmt.Fprintln(os.Stderr, "gitbay: run this in a git clone; the commit is made and signed here") | |
| 526 | return protocol.ExitUsage | |
| 527 | } | |
| 528 | ||
| 529 | out, code = captureSSH(t, []string{"mr", "show", repo, n, "--json"}) | |
| 530 | if code != 0 { | |
| 531 | return code | |
| 532 | } | |
| 533 | var mr struct { | |
| 534 | Data struct { | |
| 535 | Source string `json:"source"` | |
| 536 | State string `json:"state"` | |
| 537 | } `json:"data"` | |
| 538 | } | |
| 539 | if err := json.Unmarshal([]byte(out), &mr); err != nil { | |
| 540 | fmt.Fprintln(os.Stderr, "gitbay: reading merge request:", err) | |
| 541 | return protocol.ExitProtocol | |
| 542 | } | |
| 543 | if mr.Data.State != "open" { | |
| 544 | fmt.Fprintf(os.Stderr, "gitbay: !%s is %s\n", n, mr.Data.State) | |
| 545 | return protocol.ExitUsage | |
| 546 | } | |
| 547 | srcRepo, branch := repo, mr.Data.Source | |
| 548 | if r, b, ok := strings.Cut(mr.Data.Source, ":"); ok { | |
| 549 | srcRepo, branch = r, b | |
| 550 | } | |
| 551 | url := t.inst.CloneURL(srcRepo) | |
| 552 | if len(t.inst.SSHOptions) > 0 { | |
| 553 | os.Setenv("GIT_SSH_COMMAND", "ssh "+strings.Join(quoteAll(t.inst.SSHOptions), " ")) | |
| 554 | } | |
| 555 | if code := runGitLocal("fetch", "--quiet", url, "refs/heads/"+branch); code != 0 { | |
| 556 | return code | |
| 557 | } | |
| 558 | tip, code := gitOutput("", "rev-parse", "FETCH_HEAD^{commit}") | |
| 559 | if code != 0 { | |
| 560 | return code | |
| 561 | } | |
| 562 | entry, code := gitOutput("", "ls-tree", tip, "--", s.Path) | |
| 563 | mode, _, _ := strings.Cut(entry, " ") | |
| 564 | if code != 0 || (mode != "100644" && mode != "100755") { | |
| 565 | fmt.Fprintf(os.Stderr, "gitbay: %s is not a regular file at the head of %s; it was renamed or deleted\n", s.Path, mr.Data.Source) | |
| 566 | return protocol.ExitUsage | |
| 567 | } | |
| 568 | content, code := gitRaw("", "", "cat-file", "blob", tip+":"+s.Path) | |
| 569 | if code != 0 { | |
| 570 | return code | |
| 571 | } | |
| 572 | if now, ok := suggest.Range([]byte(content), s.StartLine, s.EndLine); !ok || string(now) != s.Original { | |
| 573 | fmt.Fprintf(os.Stderr, "gitbay: suggestion in thread %s is outdated: the lines it replaces have changed\n", thread) | |
| 574 | return protocol.ExitUsage | |
| 575 | } | |
| 576 | updated, err := suggest.Apply([]byte(content), s.StartLine, s.EndLine, suggest.FromText(s.Replacement)) | |
| 577 | if err != nil { | |
| 578 | fmt.Fprintln(os.Stderr, "gitbay:", err) | |
| 579 | return protocol.ExitUsage | |
| 580 | } | |
| 581 | blob, code := gitOutput(string(updated), "hash-object", "-w", "--stdin") | |
| 582 | if code != 0 { | |
| 583 | return code | |
| 584 | } | |
| 585 | idx, err := os.CreateTemp("", "gitbay-index-*") | |
| 586 | if err != nil { | |
| 587 | fmt.Fprintln(os.Stderr, "gitbay:", err) | |
| 588 | return protocol.ExitFailure | |
| 589 | } | |
| 590 | idx.Close() | |
| 591 | defer os.Remove(idx.Name()) | |
| 592 | indexEnv := "GIT_INDEX_FILE=" + idx.Name() | |
| 593 | if _, code := gitRaw(indexEnv, "", "read-tree", tip); code != 0 { | |
| 594 | return code | |
| 595 | } | |
| 596 | if _, code := gitRaw(indexEnv, "", "update-index", "--add", "--cacheinfo", mode+","+blob+","+s.Path); code != 0 { | |
| 597 | return code | |
| 598 | } | |
| 599 | tree, code := gitRaw(indexEnv, "", "write-tree") | |
| 600 | if code != 0 { | |
| 601 | return code | |
| 602 | } | |
| 603 | tree = strings.TrimSpace(tree) | |
| 604 | nr, _ := strconv.ParseInt(n, 10, 64) | |
| 605 | tn, _ := strconv.ParseInt(thread, 10, 64) | |
| 606 | sha, code := gitOutput(suggest.Message(repo, nr, tn, author), "commit-tree", "-S", tree, "-p", tip, "-F", "-") | |
| 607 | if code != 0 { | |
| 608 | fmt.Fprintln(os.Stderr, "gitbay: signing the commit failed; the repository requires signed commits, so set user.signingkey (and gpg.format) in git config") | |
| 609 | return code | |
| 610 | } | |
| 611 | if code := runGitLocal("push", "--quiet", url, sha+":refs/heads/"+branch); code != 0 { | |
| 612 | return code | |
| 613 | } | |
| 614 | if _, code := captureSSH(t, []string{"mr", "resolve", repo, n, thread}); code != 0 { | |
| 615 | return code | |
| 616 | } | |
| 617 | fmt.Printf("applied thread %s to %s at %.10s; thread resolved\n", thread, mr.Data.Source, sha) | |
| 618 | return 0 | |
| 619 | } | |
| 620 | ||
| 621 | // gitOutput runs git with stdin and returns its stdout without the | |
| 622 | // trailing newline; git's stderr goes to the terminal. | |
| 623 | func gitOutput(stdin string, args ...string) (string, int) { | |
| 624 | out, code := gitRaw("", stdin, args...) | |
| 625 | return strings.TrimRight(out, "\n"), code | |
| 626 | } | |
| 627 | ||
| 628 | // gitRaw runs git with one extra environment variable (none when env is | |
| 629 | // "") and returns its stdout as written. | |
| 630 | func gitRaw(env, stdin string, args ...string) (string, int) { | |
| 631 | cmd := exec.Command(toolpath.Look("git"), args...) | |
| 632 | if env != "" { | |
| 633 | cmd.Env = append(os.Environ(), env) | |
| 634 | } | |
| 635 | cmd.Stdin = strings.NewReader(stdin) | |
| 636 | cmd.Stderr = os.Stderr | |
| 637 | out, err := cmd.Output() | |
| 638 | if err != nil { | |
| 639 | if ee, ok := err.(*exec.ExitError); ok { | |
| 640 | return "", ee.ExitCode() | |
| 641 | } | |
| 642 | fmt.Fprintln(os.Stderr, "gitbay:", err) | |
| 643 | return "", protocol.ExitFailure | |
| 644 | } | |
| 645 | return string(out), 0 | |
| 646 | } | |
cmd/gitbay/main.go +1 −1
| @@ -683,7 +683,7 @@ func mrCmd() *cobra.Command { | ||
| 683 | 683 | pass("diff-comment", passOpts{server: []string{"mr", "diff-comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), |
| 684 | 684 | pass("threads", passOpts{server: []string{"mr", "threads"}, needsRepo: true}), |
| 685 | 685 | pass("resolve", passOpts{server: []string{"mr", "resolve"}, needsRepo: true}), |
| 686 | pass("apply-suggestion", passOpts{server: []string{"mr", "apply-suggestion"}, needsRepo: true}), | |
| 686 | mrApplySuggestionCmd(), | |
| 687 | 687 | pass("unresolve", passOpts{server: []string{"mr", "unresolve"}, needsRepo: true}), |
| 688 | 688 | review, |
| 689 | 689 | pass("merge", passOpts{server: []string{"mr", "merge"}, needsRepo: true}), |
e2e/cli_test.go +2
| @@ -16,6 +16,7 @@ type cli struct { | ||
| 16 | 16 | configDir string |
| 17 | 17 | inst *instance |
| 18 | 18 | key string |
| 19 | env []string // appended last, so it overrides the defaults | |
| 19 | 20 | } |
| 20 | 21 | |
| 21 | 22 | func (c *cli) run(t *testing.T, dir, stdin string, args ...string) (string, string, int) { |
| @@ -29,6 +30,7 @@ func (c *cli) run(t *testing.T, dir, stdin string, args ...string) (string, stri | ||
| 29 | 30 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test", |
| 30 | 31 | "EDITOR=", // no editor in tests: bodies come from flags |
| 31 | 32 | ) |
| 33 | cmd.Env = append(cmd.Env, c.env...) | |
| 32 | 34 | if stdin != "" { |
| 33 | 35 | cmd.Stdin = strings.NewReader(stdin) |
| 34 | 36 | } |
e2e/suggestion_test.go added +197
| @@ -0,0 +1,197 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "encoding/json" | |
| 5 | "fmt" | |
| 6 | "os" | |
| 7 | "path/filepath" | |
| 8 | "strings" | |
| 9 | "testing" | |
| 10 | ) | |
| 11 | ||
| 12 | // suggestionCLI is a CLI configured as key against inst. | |
| 13 | func suggestionCLI(t *testing.T, inst *instance, key string) *cli { | |
| 14 | t.Helper() | |
| 15 | c := &cli{bin: buildGitbayCLI(t), configDir: t.TempDir(), inst: inst, key: key} | |
| 16 | c.must(t, "", "", "remote", "add", "test", "127.0.0.1", | |
| 17 | "--port", fmt.Sprint(inst.port), | |
| 18 | "--ssh-option", "-i", "--ssh-option", key, | |
| 19 | "--ssh-option", "-oIdentitiesOnly=yes", | |
| 20 | "--ssh-option", "-oStrictHostKeyChecking=no", | |
| 21 | "--ssh-option", "-oUserKnownHostsFile="+filepath.Join(inst.sshDir, "kh"), | |
| 22 | "--ssh-option", "-oBatchMode=yes", | |
| 23 | "--default") | |
| 24 | return c | |
| 25 | } | |
| 26 | ||
| 27 | // postSuggestion opens a thread on lib.txt line 2 of !1 in repo whose | |
| 28 | // suggestion replaces it with two lines, and returns the thread id. | |
| 29 | func postSuggestion(t *testing.T, inst *instance, key, repo string) string { | |
| 30 | t.Helper() | |
| 31 | body := "split this\n```suggestion\nTWO\nTWO AND A HALF\n```\n" | |
| 32 | out, errOut, code := inst.ssh(t, key, body, "mr", "diff-comment", repo, "1", | |
| 33 | "--path", "lib.txt", "--line", "2", "--file", "-", "--json") | |
| 34 | if code != 0 { | |
| 35 | t.Fatalf("diff-comment: exit %d %s", code, errOut) | |
| 36 | } | |
| 37 | var env struct { | |
| 38 | Data struct { | |
| 39 | Thread int64 `json:"thread"` | |
| 40 | } `json:"data"` | |
| 41 | } | |
| 42 | json.Unmarshal([]byte(out), &env) | |
| 43 | return fmt.Sprint(env.Data.Thread) | |
| 44 | } | |
| 45 | ||
| 46 | type e2eThread struct { | |
| 47 | ID int64 `json:"id"` | |
| 48 | Resolved string `json:"resolved_by"` | |
| 49 | Suggestion *struct { | |
| 50 | StartLine int `json:"start_line"` | |
| 51 | EndLine int `json:"end_line"` | |
| 52 | Original string `json:"original"` | |
| 53 | Replacement string `json:"replacement"` | |
| 54 | Outdated bool `json:"outdated"` | |
| 55 | Apply string `json:"apply"` | |
| 56 | } `json:"suggestion"` | |
| 57 | } | |
| 58 | ||
| 59 | func threadsOf(t *testing.T, inst *instance, key, repo string) []e2eThread { | |
| 60 | t.Helper() | |
| 61 | out, errOut, code := inst.ssh(t, key, "", "mr", "threads", repo, "1", "--json") | |
| 62 | if code != 0 { | |
| 63 | t.Fatalf("mr threads: %s", errOut) | |
| 64 | } | |
| 65 | var env struct { | |
| 66 | Data []e2eThread `json:"data"` | |
| 67 | } | |
| 68 | if err := json.Unmarshal([]byte(out), &env); err != nil { | |
| 69 | t.Fatalf("threads JSON: %v\n%s", err, out) | |
| 70 | } | |
| 71 | return env.Data | |
| 72 | } | |
| 73 | ||
| 74 | // A reviewer's suggestion, applied by the author through the CLI, which | |
| 75 | // asks the server to commit it: the source branch gets one commit by the | |
| 76 | // author with the suggested lines, and the thread is resolved. | |
| 77 | func TestSuggestionAppliedByServer(t *testing.T) { | |
| 78 | t.Parallel() | |
| 79 | inst := startInstance(t) | |
| 80 | aliceKey := inst.newKey(t, "alice") | |
| 81 | bobKey := inst.newKey(t, "bob") | |
| 82 | inst.admin(t, "admin", "user", "create", "alice", | |
| 83 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 84 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 85 | c := suggestionCLI(t, inst, aliceKey) | |
| 86 | c.must(t, "", "", "repo", "create", "alice/lib") | |
| 87 | ||
| 88 | env := inst.gitEnv(aliceKey) | |
| 89 | work := t.TempDir() | |
| 90 | mustGit(t, work, env, "clone", inst.sshURL("alice/lib"), "w") | |
| 91 | dir := filepath.Join(work, "w") | |
| 92 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 93 | os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("one\n"), 0o644) | |
| 94 | mustGit(t, dir, env, "add", ".") | |
| 95 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | |
| 96 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 97 | mustGit(t, dir, env, "checkout", "-q", "-b", "feat") | |
| 98 | os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("one\ntwo\nthree\n"), 0o644) | |
| 99 | mustGit(t, dir, env, "commit", "-q", "-am", "more") | |
| 100 | mustGit(t, dir, env, "push", "-q", "origin", "feat") | |
| 101 | c.must(t, dir, "", "mr", "create", "alice/lib", "--source", "feat", "--target", "main", "--title", "more") | |
| 102 | ||
| 103 | thread := postSuggestion(t, inst, bobKey, "alice/lib") | |
| 104 | th := threadsOf(t, inst, aliceKey, "alice/lib") | |
| 105 | if len(th) != 1 || th[0].Suggestion == nil || th[0].Suggestion.Original != "two\n" || | |
| 106 | th[0].Suggestion.Replacement != "TWO\nTWO AND A HALF\n" || th[0].Suggestion.Apply != "server" { | |
| 107 | t.Fatalf("threads = %+v", th) | |
| 108 | } | |
| 109 | ||
| 110 | // bob reads the repository and cannot push to it, so he cannot apply. | |
| 111 | if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "apply-suggestion", "alice/lib", "1", thread); code != 4 { | |
| 112 | t.Fatalf("reader applied a suggestion: exit %d %s", code, errOut) | |
| 113 | } | |
| 114 | ||
| 115 | out, errOut, code := c.run(t, dir, "", "mr", "apply-suggestion", "1", thread) | |
| 116 | if code != 0 { | |
| 117 | t.Fatalf("apply-suggestion: exit %d\n%s\n%s", code, out, errOut) | |
| 118 | } | |
| 119 | mustGit(t, dir, env, "fetch", "-q", "origin", "feat") | |
| 120 | if got := mustGit(t, dir, env, "show", "FETCH_HEAD:lib.txt"); got != "one\nTWO\nTWO AND A HALF\nthree\n" { | |
| 121 | t.Fatalf("lib.txt = %q", got) | |
| 122 | } | |
| 123 | if who := mustGit(t, dir, env, "log", "-1", "--format=%an <%ae>", "FETCH_HEAD"); strings.TrimSpace(who) != "alice <alice@example.test>" { | |
| 124 | t.Errorf("commit by %q", who) | |
| 125 | } | |
| 126 | if th := threadsOf(t, inst, aliceKey, "alice/lib"); th[0].Resolved != "alice" || !th[0].Suggestion.Outdated { | |
| 127 | t.Errorf("after apply: thread = %+v", th[0]) | |
| 128 | } | |
| 129 | } | |
| 130 | ||
| 131 | // On a repository requiring signed commits the server refuses, and the | |
| 132 | // CLI applies the suggestion in the clone with the user's own signing | |
| 133 | // key: the push passes the signed-commit check, and the working tree is | |
| 134 | // left as it was. | |
| 135 | func TestSuggestionAppliedLocallyWhenSigned(t *testing.T) { | |
| 136 | t.Parallel() | |
| 137 | inst := startInstance(t) | |
| 138 | aliceKey := inst.newKey(t, "alice") | |
| 139 | inst.admin(t, "admin", "user", "create", "alice", | |
| 140 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 141 | c := suggestionCLI(t, inst, aliceKey) | |
| 142 | c.must(t, "", "", "repo", "create", "alice/sec") | |
| 143 | c.must(t, "", "", "repo", "settings", "require-signed", "alice/sec", "on") | |
| 144 | ||
| 145 | // alice signs with her SSH key, as git's own config says to. | |
| 146 | ident := []string{"GIT_AUTHOR_NAME=alice", "GIT_AUTHOR_EMAIL=alice@example.test", | |
| 147 | "GIT_COMMITTER_NAME=alice", "GIT_COMMITTER_EMAIL=alice@example.test"} | |
| 148 | env := append(inst.gitEnv(aliceKey), ident...) | |
| 149 | c.env = ident | |
| 150 | work := t.TempDir() | |
| 151 | mustGit(t, work, env, "clone", inst.sshURL("alice/sec"), "w") | |
| 152 | dir := filepath.Join(work, "w") | |
| 153 | mustGit(t, dir, env, "config", "gpg.format", "ssh") | |
| 154 | mustGit(t, dir, env, "config", "user.signingkey", aliceKey) | |
| 155 | mustGit(t, dir, env, "config", "commit.gpgsign", "true") | |
| 156 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 157 | os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("one\n"), 0o644) | |
| 158 | mustGit(t, dir, env, "add", ".") | |
| 159 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | |
| 160 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 161 | mustGit(t, dir, env, "checkout", "-q", "-b", "feat") | |
| 162 | os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("one\ntwo\nthree\n"), 0o644) | |
| 163 | mustGit(t, dir, env, "commit", "-q", "-am", "more") | |
| 164 | mustGit(t, dir, env, "push", "-q", "origin", "feat") | |
| 165 | c.must(t, dir, "", "mr", "create", "alice/sec", "--source", "feat", "--target", "main", "--title", "more") | |
| 166 | mustGit(t, dir, env, "checkout", "-q", "main") | |
| 167 | ||
| 168 | thread := postSuggestion(t, inst, aliceKey, "alice/sec") | |
| 169 | if th := threadsOf(t, inst, aliceKey, "alice/sec"); th[0].Suggestion == nil || th[0].Suggestion.Apply != "local" { | |
| 170 | t.Fatalf("threads = %+v, want a suggestion applied locally", th) | |
| 171 | } | |
| 172 | _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "apply-suggestion", "alice/sec", "1", thread) | |
| 173 | if code != 4 || !strings.Contains(errOut, "gitbay mr apply-suggestion alice/sec 1 "+thread) { | |
| 174 | t.Fatalf("server-side apply on a require-signed repository: exit %d %s", code, errOut) | |
| 175 | } | |
| 176 | ||
| 177 | out, errOut, code := c.run(t, dir, "", "mr", "apply-suggestion", "1", thread) | |
| 178 | if code != 0 { | |
| 179 | t.Fatalf("local apply-suggestion: exit %d\n%s\n%s", code, out, errOut) | |
| 180 | } | |
| 181 | mustGit(t, dir, env, "fetch", "-q", "origin", "feat") | |
| 182 | if got := mustGit(t, dir, env, "show", "FETCH_HEAD:lib.txt"); got != "one\nTWO\nTWO AND A HALF\nthree\n" { | |
| 183 | t.Fatalf("lib.txt = %q", got) | |
| 184 | } | |
| 185 | if msg := mustGit(t, dir, env, "log", "-1", "--format=%B", "FETCH_HEAD"); !strings.Contains(msg, "Thread "+thread+" on alice/sec!1") { | |
| 186 | t.Errorf("commit message = %q", msg) | |
| 187 | } | |
| 188 | if branch := strings.TrimSpace(mustGit(t, dir, env, "branch", "--show-current")); branch != "main" { | |
| 189 | t.Errorf("checked-out branch moved to %q", branch) | |
| 190 | } | |
| 191 | if st := mustGit(t, dir, env, "status", "--porcelain"); st != "" { | |
| 192 | t.Errorf("working tree touched:\n%s", st) | |
| 193 | } | |
| 194 | if th := threadsOf(t, inst, aliceKey, "alice/sec"); th[0].Resolved != "alice" { | |
| 195 | t.Errorf("thread not resolved: %+v", th[0]) | |
| 196 | } | |
| 197 | } | |
internal/control/suggestion.go +1 −8
| @@ -246,7 +246,7 @@ func runMRApplySuggestion(c *Ctx, args []string) int { | ||
| 246 | 246 | if bytes.Equal(updated, content) { |
| 247 | 247 | return c.fail(protocol.ExitUsage, "the suggestion in thread %d changes nothing", threadID) |
| 248 | 248 | } |
| 249 | message := SuggestionMessage(repo.Path(), mr.Number, threadID, cm.Author) | |
| 249 | message := suggest.Message(repo.Path(), mr.Number, threadID, cm.Author) | |
| 250 | 250 | sha, err := gitutil.CommitWithFile(srcDir, tip, s.Path, mode, updated, c.User.Username, email, message) |
| 251 | 251 | if err != nil { |
| 252 | 252 | return c.failErr(err) |
| @@ -267,10 +267,3 @@ func runMRApplySuggestion(c *Ctx, args []string) int { | ||
| 267 | 267 | fmt.Fprintf(w, "applied thread %d to %s at %.10s; thread resolved\n", threadID, source, sha) |
| 268 | 268 | }) |
| 269 | 269 | } |
| 270 | ||
| 271 | // SuggestionMessage is the commit message of an applied suggestion, | |
| 272 | // naming the merge request and the thread. The CLI's local apply writes | |
| 273 | // the same one. | |
| 274 | func SuggestionMessage(repoPath string, mr, thread int64, author string) string { | |
| 275 | return fmt.Sprintf("Apply suggestion from %s\n\nThread %d on %s!%d.\n", author, thread, repoPath, mr) | |
| 276 | } | |
internal/suggest/suggest.go +7
| @@ -167,3 +167,10 @@ func Apply(content []byte, start, end int, repl []string) ([]byte, error) { | ||
| 167 | 167 | } |
| 168 | 168 | return b.Bytes(), nil |
| 169 | 169 | } |
| 170 | ||
| 171 | // Message is the commit message of an applied suggestion, naming the | |
| 172 | // merge request and the thread. The server and the CLI's local apply | |
| 173 | // both write it. | |
| 174 | func Message(repoPath string, mr, thread int64, author string) string { | |
| 175 | return fmt.Sprintf("Apply suggestion from %s\n\nThread %d on %s!%d.\n", author, thread, repoPath, mr) | |
| 176 | } | |