Commit 421defaffc

421defaffc19658b600d40ba4e0dc434042225a8

parent: 89a6bbd4c1

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 05:06 UTC

cli: mr apply-suggestion commits and signs locally where the repository requires signed commits

Ref #288

Layout: unified · split

cmd/gitbay/local.go +231
@@ -6,12 +6,15 @@ import (
66 "os"
77 "os/exec"
88 "path/filepath"
9 "strconv"
910 "strings"
1011
12 "github.com/spf13/cobra"
1113 "golang.org/x/term"
1214
1315 "gitbay.org/gitbay/internal/cliconfig"
1416 "gitbay.org/gitbay/internal/protocol"
17 "gitbay.org/gitbay/internal/suggest"
1518 "gitbay.org/gitbay/internal/toolpath"
1619)
1720
@@ -413,3 +416,231 @@ func worktreeDirty() (bool, error) {
413416 }
414417 return strings.TrimSpace(string(out)) != "", nil
415418}
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.
423func 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.
446type 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.
468func 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.
623func 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.
630func 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 {
683683 pass("diff-comment", passOpts{server: []string{"mr", "diff-comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
684684 pass("threads", passOpts{server: []string{"mr", "threads"}, needsRepo: true}),
685685 pass("resolve", passOpts{server: []string{"mr", "resolve"}, needsRepo: true}),
686 pass("apply-suggestion", passOpts{server: []string{"mr", "apply-suggestion"}, needsRepo: true}),
686 mrApplySuggestionCmd(),
687687 pass("unresolve", passOpts{server: []string{"mr", "unresolve"}, needsRepo: true}),
688688 review,
689689 pass("merge", passOpts{server: []string{"mr", "merge"}, needsRepo: true}),
e2e/cli_test.go +2
@@ -16,6 +16,7 @@ type cli struct {
1616 configDir string
1717 inst *instance
1818 key string
19 env []string // appended last, so it overrides the defaults
1920}
2021
2122func (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
2930 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test",
3031 "EDITOR=", // no editor in tests: bodies come from flags
3132 )
33 cmd.Env = append(cmd.Env, c.env...)
3234 if stdin != "" {
3335 cmd.Stdin = strings.NewReader(stdin)
3436 }
e2e/suggestion_test.go added +197
@@ -0,0 +1,197 @@
1package e2e
2
3import (
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.
13func 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.
29func 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
46type 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
59func 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.
77func 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.
135func 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 {
246246 if bytes.Equal(updated, content) {
247247 return c.fail(protocol.ExitUsage, "the suggestion in thread %d changes nothing", threadID)
248248 }
249 message := SuggestionMessage(repo.Path(), mr.Number, threadID, cm.Author)
249 message := suggest.Message(repo.Path(), mr.Number, threadID, cm.Author)
250250 sha, err := gitutil.CommitWithFile(srcDir, tip, s.Path, mode, updated, c.User.Username, email, message)
251251 if err != nil {
252252 return c.failErr(err)
@@ -267,10 +267,3 @@ func runMRApplySuggestion(c *Ctx, args []string) int {
267267 fmt.Fprintf(w, "applied thread %d to %s at %.10s; thread resolved\n", threadID, source, sha)
268268 })
269269}
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.
274func 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) {
167167 }
168168 return b.Bytes(), nil
169169}
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.
174func 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}