Commit 421defaffc
Verified · cmc
Layout: unified · split
cmd/gitbay/local.go +231
| @@ -6,12 +6,15 @@ import ( | |||
| 6 | "os" | 6 | "os" |
| 7 | "os/exec" | 7 | "os/exec" |
| 8 | "path/filepath" | 8 | "path/filepath" |
| 9 | "strconv" | ||
| 9 | "strings" | 10 | "strings" |
| 10 | 11 | ||
| 12 | "github.com/spf13/cobra" | ||
| 11 | "golang.org/x/term" | 13 | "golang.org/x/term" |
| 12 | 14 | ||
| 13 | "gitbay.org/gitbay/internal/cliconfig" | 15 | "gitbay.org/gitbay/internal/cliconfig" |
| 14 | "gitbay.org/gitbay/internal/protocol" | 16 | "gitbay.org/gitbay/internal/protocol" |
| 17 | "gitbay.org/gitbay/internal/suggest" | ||
| 15 | "gitbay.org/gitbay/internal/toolpath" | 18 | "gitbay.org/gitbay/internal/toolpath" |
| 16 | ) | 19 | ) |
| 17 | 20 | ||
| @@ -413,3 +416,231 @@ func worktreeDirty() (bool, error) { | |||
| 413 | } | 416 | } |
| 414 | return strings.TrimSpace(string(out)) != "", nil | 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 | pass("diff-comment", passOpts{server: []string{"mr", "diff-comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), | 683 | pass("diff-comment", passOpts{server: []string{"mr", "diff-comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), |
| 684 | pass("threads", passOpts{server: []string{"mr", "threads"}, needsRepo: true}), | 684 | pass("threads", passOpts{server: []string{"mr", "threads"}, needsRepo: true}), |
| 685 | pass("resolve", passOpts{server: []string{"mr", "resolve"}, needsRepo: true}), | 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 | pass("unresolve", passOpts{server: []string{"mr", "unresolve"}, needsRepo: true}), | 687 | pass("unresolve", passOpts{server: []string{"mr", "unresolve"}, needsRepo: true}), |
| 688 | review, | 688 | review, |
| 689 | pass("merge", passOpts{server: []string{"mr", "merge"}, needsRepo: true}), | 689 | pass("merge", passOpts{server: []string{"mr", "merge"}, needsRepo: true}), |
e2e/cli_test.go +2
| @@ -16,6 +16,7 @@ type cli struct { | |||
| 16 | configDir string | 16 | configDir string |
| 17 | inst *instance | 17 | inst *instance |
| 18 | key string | 18 | key string |
| 19 | env []string // appended last, so it overrides the defaults | ||
| 19 | } | 20 | } |
| 20 | 21 | ||
| 21 | func (c *cli) run(t *testing.T, dir, stdin string, args ...string) (string, string, int) { | 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 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test", | 30 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test", |
| 30 | "EDITOR=", // no editor in tests: bodies come from flags | 31 | "EDITOR=", // no editor in tests: bodies come from flags |
| 31 | ) | 32 | ) |
| 33 | cmd.Env = append(cmd.Env, c.env...) | ||
| 32 | if stdin != "" { | 34 | if stdin != "" { |
| 33 | cmd.Stdin = strings.NewReader(stdin) | 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 | if bytes.Equal(updated, content) { | 246 | if bytes.Equal(updated, content) { |
| 247 | return c.fail(protocol.ExitUsage, "the suggestion in thread %d changes nothing", threadID) | 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 | sha, err := gitutil.CommitWithFile(srcDir, tip, s.Path, mode, updated, c.User.Username, email, message) | 250 | sha, err := gitutil.CommitWithFile(srcDir, tip, s.Path, mode, updated, c.User.Username, email, message) |
| 251 | if err != nil { | 251 | if err != nil { |
| 252 | return c.failErr(err) | 252 | return c.failErr(err) |
| @@ -267,10 +267,3 @@ func runMRApplySuggestion(c *Ctx, args []string) int { | |||
| 267 | fmt.Fprintf(w, "applied thread %d to %s at %.10s; thread resolved\n", threadID, source, sha) | 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 | return b.Bytes(), nil | 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 | } | ||