mr: suggested changes in review comments !528

merged merged by cmc on 2026-09-29 05:36 UTC · krz/gitbay:mr-suggestions-288 into main

33 files changed, +2473 −263

Layout: unified · split

.gitbay/wiki/Parity.org +8
@@ -39,6 +39,8 @@ browser-only and the iOS build screen unable to say more than the log.
3939| review (approve etc.) | yes | yes | yes |
4040| resolve a thread | yes | yes | yes |
4141| comment on a diff line | yes | yes | yes |
42| suggest a change | yes | yes | no |
43| apply a suggestion | yes | yes | no |
4244| merge (all strategies) | yes | yes | yes |
4345| merge when ready, cancel | yes | yes | no |
4446| close | yes | yes | yes |
@@ -480,6 +482,12 @@ now a page waiting to be built rather than a rule. A credential still
480482travels on stdin wherever it is set, since argv is world-readable in
481483/proc and the audit log keeps flag values.
482484
485Applying a suggestion on a repository that requires signed commits is
486CLI-only by mechanism: the server has no key to sign the commit with,
487so =gitbay mr apply-suggestion= makes and signs it in a clone with the
488user's own git signing configuration and pushes it. The web shows that
489command in place of the button.
490
483491Deleting or transferring a repository stays CLI-only on purpose, as
484492does deleting an organization and pruning merge request heads (=admin
485493mr prune=): each removes or moves what clone URLs point at, and wants a
.gitbay/wiki/Users.org +42
@@ -566,6 +566,48 @@ Threads render inline on the MR page. A force-push marks them stale
566566anchors; =mr show= reports the unresolved count. Resolving is for the
567567thread author, the MR author, or anyone with write.
568568
569A thread can span lines: =--start-line 12 --line 13= anchors it to
570lines 12 and 13 of the new file. A fenced =suggestion= block in the
571comment proposes replacement lines for that range; an empty block
572proposes deleting it.
573
574#+begin_src sh
575gitbay mr diff-comment 4 --path main.go --start-line 12 --line 13 --file - < suggestion.md
576gitbay mr apply-suggestion 4 9 # commit thread 9's suggestion
577#+end_src
578
579where =suggestion.md= is
580
581#+begin_example
582one call does both
583```suggestion
584 log.Printf("starting %s", name)
585```
586#+end_example
587
588The page and =mr threads= show a suggestion as the lines it replaces
589and the lines it proposes; =mr threads --json= carries it as
590=suggestion= with the path, the line range, the commit and blob it was
591made against, the original and replacement text, =outdated= with a
592reason, and =apply= (=server= or =local=). A suggestion is outdated
593once the lines it replaces differ at the head from what it was made
594against, or the file is renamed or deleted; it cannot be applied then.
595
596Applying commits the replacement to the source branch as the applying
597user, with a message naming the merge request and thread, and resolves
598the thread if the applier could resolve it by hand (the thread author,
599the MR author, or a writer of the target); otherwise the thread stays
600open and the output says so. It is a push: only the source branch's
601writers can apply (for a merge request from a fork, the fork's
602writers), the branch's protection, =require-mr= and the owner's storage
603quota apply, and a queued merge treats it as a push by the applier. The web's Apply suggestion button and =mr
604apply-suggestion= commit it on the server. The server cannot sign, so
605where the source or target requires signed commits (=apply= is
606=local=) the CLI fetches the source branch into the clone it runs in,
607builds the commit without touching the working tree, signs it with
608=git commit-tree -S= under your git signing configuration, pushes it,
609and resolves the thread; the page shows that command.
610
569611* CI builds
570612
571613A =.gitbay/ci.yml= in the repo runs jobs on every branch push:
CHANGELOG.org +14
@@ -12,6 +12,20 @@ anything beyond "replace the binary and restart" is needed.
1212 layout puts old and new side by side, each column's line numbers
1313 comment on their own side, and a narrow window stacks the rows as a
1414 unified diff. Migration 0068 adds =users.diff_layout=. (#290)
15- Suggested changes in review threads: a fenced =suggestion= block in
16 an =mr diff-comment= body proposes replacement lines for the
17 anchored range, and =--start-line= anchors a thread to a range.
18 =mr threads= and the page show it as a diff, marked outdated once
19 the lines change at the head or the file is renamed or deleted;
20 the JSON carries it as =suggestion=. =mr apply-suggestion= and the
21 page's button commit it to the source branch as the applier, under
22 the branch's push policy and write rules; a queued merge sees it as
23 a push. The thread is resolved when the applier could resolve it by
24 hand, and otherwise stays open with the output saying so. On a
25 repository requiring signed commits the CLI commits and signs it in
26 the clone instead. =repo commit-file= and applying a suggestion
27 refuse once the owner's storage quota is used up, as a push does.
28 Migration 0069 (#288).
1529
1630* v1.38.0 — 2026-09-29
1731
cmd/gitbay/local.go +259
@@ -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,259 @@ 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 asJSON := false
475 var rest []string
476 for _, a := range args {
477 if a == "--json" {
478 asJSON = true
479 continue
480 }
481 rest = append(rest, a)
482 }
483 if args, err = withRepo(t, rest); err != nil {
484 fmt.Fprintln(os.Stderr, "gitbay:", err)
485 return protocol.ExitUsage
486 }
487 if len(args) != 3 {
488 fmt.Fprintln(os.Stderr, "usage: gitbay mr apply-suggestion [<owner/name>] <n> <thread-id> [--json]")
489 return protocol.ExitUsage
490 }
491 repo, n, thread := args[0], args[1], args[2]
492 out, code := captureSSH(t, []string{"mr", "threads", repo, n, "--json"})
493 if code != 0 {
494 return code
495 }
496 var threads struct {
497 Data []struct {
498 ID int64 `json:"id"`
499 Comments []struct {
500 Author string `json:"author"`
501 } `json:"comments"`
502 Suggestion *suggestionJSON `json:"suggestion"`
503 } `json:"data"`
504 }
505 if err := json.Unmarshal([]byte(out), &threads); err != nil {
506 fmt.Fprintln(os.Stderr, "gitbay: reading threads:", err)
507 return protocol.ExitProtocol
508 }
509 var author string
510 var s *suggestionJSON
511 found := false
512 for _, th := range threads.Data {
513 if strconv.FormatInt(th.ID, 10) == thread {
514 found, s = true, th.Suggestion
515 if len(th.Comments) > 0 {
516 author = th.Comments[0].Author
517 }
518 }
519 }
520 switch {
521 case !found:
522 fmt.Fprintf(os.Stderr, "gitbay: no thread %s on %s!%s\n", thread, repo, n)
523 return protocol.ExitNotFound
524 case s == nil:
525 fmt.Fprintf(os.Stderr, "gitbay: thread %s carries no suggestion\n", thread)
526 return protocol.ExitUsage
527 case s.Apply != "local":
528 argv := []string{"mr", "apply-suggestion", repo, n, thread}
529 if asJSON {
530 argv = append(argv, "--json")
531 }
532 return runSSH(t, argv, strings.NewReader(""))
533 case s.Outdated:
534 fmt.Fprintf(os.Stderr, "gitbay: suggestion in thread %s is outdated: %s\n", thread, s.Reason)
535 return protocol.ExitUsage
536 }
537 if _, code := gitOutput("", "rev-parse", "--git-dir"); code != 0 {
538 fmt.Fprintln(os.Stderr, "gitbay: run this in a git clone; the commit is made and signed here")
539 return protocol.ExitUsage
540 }
541
542 out, code = captureSSH(t, []string{"mr", "show", repo, n, "--json"})
543 if code != 0 {
544 return code
545 }
546 var mr struct {
547 Data struct {
548 Source string `json:"source"`
549 State string `json:"state"`
550 } `json:"data"`
551 }
552 if err := json.Unmarshal([]byte(out), &mr); err != nil {
553 fmt.Fprintln(os.Stderr, "gitbay: reading merge request:", err)
554 return protocol.ExitProtocol
555 }
556 if mr.Data.State != "open" {
557 fmt.Fprintf(os.Stderr, "gitbay: !%s is %s\n", n, mr.Data.State)
558 return protocol.ExitUsage
559 }
560 srcRepo, branch := repo, mr.Data.Source
561 if r, b, ok := strings.Cut(mr.Data.Source, ":"); ok {
562 srcRepo, branch = r, b
563 }
564 url := t.inst.CloneURL(srcRepo)
565 if len(t.inst.SSHOptions) > 0 {
566 os.Setenv("GIT_SSH_COMMAND", "ssh "+strings.Join(quoteAll(t.inst.SSHOptions), " "))
567 }
568 if code := runGitLocal("fetch", "--quiet", url, "refs/heads/"+branch); code != 0 {
569 return code
570 }
571 tip, code := gitOutput("", "rev-parse", "FETCH_HEAD^{commit}")
572 if code != 0 {
573 return code
574 }
575 entry, code := gitOutput("", "ls-tree", tip, "--", s.Path)
576 mode, _, _ := strings.Cut(entry, " ")
577 if code != 0 || (mode != "100644" && mode != "100755") {
578 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)
579 return protocol.ExitUsage
580 }
581 content, code := gitRaw("", "", "cat-file", "blob", tip+":"+s.Path)
582 if code != 0 {
583 return code
584 }
585 if now, ok := suggest.Range([]byte(content), s.StartLine, s.EndLine); !ok || string(now) != s.Original {
586 fmt.Fprintf(os.Stderr, "gitbay: suggestion in thread %s is outdated: the lines it replaces have changed\n", thread)
587 return protocol.ExitUsage
588 }
589 updated, err := suggest.Apply([]byte(content), s.StartLine, s.EndLine, suggest.FromText(s.Replacement))
590 if err != nil {
591 fmt.Fprintln(os.Stderr, "gitbay:", err)
592 return protocol.ExitUsage
593 }
594 blob, code := gitOutput(string(updated), "hash-object", "-w", "--stdin")
595 if code != 0 {
596 return code
597 }
598 idx, err := os.CreateTemp("", "gitbay-index-*")
599 if err != nil {
600 fmt.Fprintln(os.Stderr, "gitbay:", err)
601 return protocol.ExitFailure
602 }
603 idx.Close()
604 defer os.Remove(idx.Name())
605 indexEnv := "GIT_INDEX_FILE=" + idx.Name()
606 if _, code := gitRaw(indexEnv, "", "read-tree", tip); code != 0 {
607 return code
608 }
609 if _, code := gitRaw(indexEnv, "", "update-index", "--add", "--cacheinfo", mode+","+blob+","+s.Path); code != 0 {
610 return code
611 }
612 tree, code := gitRaw(indexEnv, "", "write-tree")
613 if code != 0 {
614 return code
615 }
616 tree = strings.TrimSpace(tree)
617 nr, _ := strconv.ParseInt(n, 10, 64)
618 tn, _ := strconv.ParseInt(thread, 10, 64)
619 sha, code := gitOutput(suggest.Message(repo, nr, tn, author), "commit-tree", "-S", tree, "-p", tip, "-F", "-")
620 if code != 0 {
621 fmt.Fprintln(os.Stderr, "gitbay: signing the commit failed; the repository requires signed commits, so set user.signingkey (and gpg.format) in git config")
622 return code
623 }
624 if code := runGitLocal("push", "--quiet", url, sha+":refs/heads/"+branch); code != 0 {
625 return code
626 }
627 // The commit has landed, so a thread that cannot be resolved (the
628 // server's resolve rule is narrower than who can push) is a warning,
629 // not a failure; captureSSH has already printed the server's reason.
630 _, code = captureSSH(t, []string{"mr", "resolve", repo, n, thread})
631 resolved := code == 0
632 if asJSON {
633 out, _ := json.Marshal(protocol.Envelope{ProtocolVersion: protocol.Version, Data: map[string]any{
634 "thread": tn, "sha": sha, "source": mr.Data.Source, "branch": branch, "resolved": resolved}})
635 fmt.Println(string(out))
636 } else {
637 fmt.Printf("applied thread %s to %s at %.10s", thread, mr.Data.Source, sha)
638 if resolved {
639 fmt.Print("; thread resolved")
640 }
641 fmt.Println()
642 }
643 if !resolved {
644 fmt.Fprintf(os.Stderr, "gitbay: warning: thread %s is still open\n", thread)
645 }
646 return 0
647}
648
649// gitOutput runs git with stdin and returns its stdout without the
650// trailing newline; git's stderr goes to the terminal.
651func gitOutput(stdin string, args ...string) (string, int) {
652 out, code := gitRaw("", stdin, args...)
653 return strings.TrimRight(out, "\n"), code
654}
655
656// gitRaw runs git with one extra environment variable (none when env is
657// "") and returns its stdout as written.
658func gitRaw(env, stdin string, args ...string) (string, int) {
659 cmd := exec.Command(toolpath.Look("git"), args...)
660 if env != "" {
661 cmd.Env = append(os.Environ(), env)
662 }
663 cmd.Stdin = strings.NewReader(stdin)
664 cmd.Stderr = os.Stderr
665 out, err := cmd.Output()
666 if err != nil {
667 if ee, ok := err.(*exec.ExitError); ok {
668 return "", ee.ExitCode()
669 }
670 fmt.Fprintln(os.Stderr, "gitbay:", err)
671 return "", protocol.ExitFailure
672 }
673 return string(out), 0
674}
cmd/gitbay/main.go +1
@@ -683,6 +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 mrApplySuggestionCmd(),
686687 pass("unresolve", passOpts{server: []string{"mr", "unresolve"}, needsRepo: true}),
687688 review,
688689 pass("merge", passOpts{server: []string{"mr", "merge"}, needsRepo: true}),
cmd/gitbay/summaries_gen.go +1
@@ -64,6 +64,7 @@ var summaries = map[string]string{
6464 "milestone create": "create a milestone",
6565 "milestone list": "list milestones with progress",
6666 "milestone reopen": "reopen a milestone",
67 "mr apply-suggestion": "commit a review thread's suggestion to the source branch",
6768 "mr close": "close without merging",
6869 "mr comment": "add a comment",
6970 "mr create": "open a merge request",
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 +213
@@ -0,0 +1,213 @@
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, "--json")
116 if code != 0 {
117 t.Fatalf("apply-suggestion: exit %d\n%s\n%s", code, out, errOut)
118 }
119 if !strings.Contains(out, `"resolved":true`) {
120 t.Errorf("apply-suggestion --json = %s", out)
121 }
122 mustGit(t, dir, env, "fetch", "-q", "origin", "feat")
123 if got := mustGit(t, dir, env, "show", "FETCH_HEAD:lib.txt"); got != "one\nTWO\nTWO AND A HALF\nthree\n" {
124 t.Fatalf("lib.txt = %q", got)
125 }
126 if who := mustGit(t, dir, env, "log", "-1", "--format=%an <%ae>", "FETCH_HEAD"); strings.TrimSpace(who) != "alice <alice@example.test>" {
127 t.Errorf("commit by %q", who)
128 }
129 if th := threadsOf(t, inst, aliceKey, "alice/lib"); th[0].Resolved != "alice" || !th[0].Suggestion.Outdated {
130 t.Errorf("after apply: thread = %+v", th[0])
131 }
132}
133
134// On a repository requiring signed commits the server refuses, and the
135// CLI applies the suggestion in the clone with the user's own signing
136// key: the push passes the signed-commit check, and the working tree is
137// left as it was.
138func TestSuggestionAppliedLocallyWhenSigned(t *testing.T) {
139 t.Parallel()
140 inst := startInstance(t)
141 aliceKey := inst.newKey(t, "alice")
142 inst.admin(t, "admin", "user", "create", "alice",
143 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
144 c := suggestionCLI(t, inst, aliceKey)
145 c.must(t, "", "", "repo", "create", "alice/sec")
146 c.must(t, "", "", "repo", "settings", "require-signed", "alice/sec", "on")
147
148 // alice signs with her SSH key, as git's own config says to.
149 ident := []string{"GIT_AUTHOR_NAME=alice", "GIT_AUTHOR_EMAIL=alice@example.test",
150 "GIT_COMMITTER_NAME=alice", "GIT_COMMITTER_EMAIL=alice@example.test"}
151 env := append(inst.gitEnv(aliceKey), ident...)
152 c.env = ident
153 work := t.TempDir()
154 mustGit(t, work, env, "clone", inst.sshURL("alice/sec"), "w")
155 dir := filepath.Join(work, "w")
156 mustGit(t, dir, env, "config", "gpg.format", "ssh")
157 mustGit(t, dir, env, "config", "user.signingkey", aliceKey)
158 mustGit(t, dir, env, "config", "commit.gpgsign", "true")
159 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
160 os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("one\n"), 0o644)
161 mustGit(t, dir, env, "add", ".")
162 mustGit(t, dir, env, "commit", "-q", "-m", "base")
163 mustGit(t, dir, env, "push", "-q", "origin", "main")
164 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
165 os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("one\ntwo\nthree\n"), 0o644)
166 mustGit(t, dir, env, "commit", "-q", "-am", "more")
167 mustGit(t, dir, env, "push", "-q", "origin", "feat")
168 c.must(t, dir, "", "mr", "create", "alice/sec", "--source", "feat", "--target", "main", "--title", "more")
169 mustGit(t, dir, env, "checkout", "-q", "main")
170
171 thread := postSuggestion(t, inst, aliceKey, "alice/sec")
172 if th := threadsOf(t, inst, aliceKey, "alice/sec"); th[0].Suggestion == nil || th[0].Suggestion.Apply != "local" {
173 t.Fatalf("threads = %+v, want a suggestion applied locally", th)
174 }
175 _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "apply-suggestion", "alice/sec", "1", thread)
176 if code != 4 || !strings.Contains(errOut, "gitbay mr apply-suggestion alice/sec 1 "+thread) {
177 t.Fatalf("server-side apply on a require-signed repository: exit %d %s", code, errOut)
178 }
179
180 out, errOut, code := c.run(t, dir, "", "mr", "apply-suggestion", "1", thread, "--json")
181 if code != 0 {
182 t.Fatalf("local apply-suggestion: exit %d\n%s\n%s", code, out, errOut)
183 }
184 var res struct {
185 Data struct {
186 SHA string `json:"sha"`
187 Branch string `json:"branch"`
188 Resolved bool `json:"resolved"`
189 } `json:"data"`
190 }
191 if err := json.Unmarshal([]byte(out), &res); err != nil || res.Data.Branch != "feat" || !res.Data.Resolved {
192 t.Fatalf("local apply-suggestion --json = %s (%v)", out, err)
193 }
194 mustGit(t, dir, env, "fetch", "-q", "origin", "feat")
195 if tip := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "FETCH_HEAD")); tip != res.Data.SHA {
196 t.Errorf("reported sha %s, branch at %s", res.Data.SHA, tip)
197 }
198 if got := mustGit(t, dir, env, "show", "FETCH_HEAD:lib.txt"); got != "one\nTWO\nTWO AND A HALF\nthree\n" {
199 t.Fatalf("lib.txt = %q", got)
200 }
201 if msg := mustGit(t, dir, env, "log", "-1", "--format=%B", "FETCH_HEAD"); !strings.Contains(msg, "Thread "+thread+" on alice/sec!1") {
202 t.Errorf("commit message = %q", msg)
203 }
204 if branch := strings.TrimSpace(mustGit(t, dir, env, "branch", "--show-current")); branch != "main" {
205 t.Errorf("checked-out branch moved to %q", branch)
206 }
207 if st := mustGit(t, dir, env, "status", "--porcelain"); st != "" {
208 t.Errorf("working tree touched:\n%s", st)
209 }
210 if th := threadsOf(t, inst, aliceKey, "alice/sec"); th[0].Resolved != "alice" {
211 t.Errorf("thread not resolved: %+v", th[0])
212 }
213}
internal/control/commitfile.go +3
@@ -72,6 +72,9 @@ func runCommitFile(c *Ctx, args []string) int {
7272 "%s requires signed commits; this writes an unsigned one — push a signed commit instead",
7373 repo.Path())
7474 }
75 if code := checkStorageQuota(c, repo); code >= 0 {
76 return code
77 }
7578 // A commit carries an identity, and an unverified address is not one.
7679 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
7780 if err != nil {
internal/control/diffcomment.go +104 −27
@@ -12,15 +12,17 @@ import (
1212 "gitbay.org/gitbay/internal/policy"
1313 "gitbay.org/gitbay/internal/protocol"
1414 "gitbay.org/gitbay/internal/store"
15 "gitbay.org/gitbay/internal/suggest"
1516)
1617
1718func init() {
1819 register(Command{Path: []string{"mr", "diff-comment"},
1920 Summary: "comment on a diff line",
20 Usage: "mr diff-comment <owner/name> <n> --path <file> --line <l> [--old] [--pending] [--reply <id>] [--message <m> | --file -]",
21 Usage: "mr diff-comment <owner/name> <n> --path <file> --line <l> [--start-line <s>] [--old] [--pending] [--reply <id>] [--message <m> | --file -]",
2122 Flags: []Flag{
2223 {"--path", "<file>", "the file the comment is on", ""},
23 {"--line", "<l>", "the line the comment is on", ""},
24 {"--line", "<l>", "the line the comment is on, or the last line of a range", ""},
25 {"--start-line", "<s>", "the first line of a range ending at --line", ""},
2426 {"--old", "", "the line is on the old side of the diff", ""},
2527 {"--pending", "", "hold the comment for `mr review --comment`", ""},
2628 {"--reply", "<id>", "reply to this thread instead of opening one", ""},
@@ -30,6 +32,7 @@ func init() {
3032 Examples: []string{
3133 `mr diff-comment krz/gitbay 431 --path internal/control/build.go --line 42 --message "why is this a switch"`,
3234 "mr diff-comment krz/gitbay 431 --reply 12 --file - < notes.md",
35 "mr diff-comment krz/gitbay 431 --path go.mod --start-line 3 --line 4 --file - < suggestion.md",
3336 },
3437 ReadsStdin: true, Run: runDiffComment})
3538 register(Command{Path: []string{"mr", "threads"},
@@ -50,15 +53,15 @@ func init() {
5053}
5154
5255func runDiffComment(c *Ctx, args []string) int {
53 f, err := c.parseArgs(args, flagSpec{Values: []string{"--path", "--line", "--reply", "--message", "--file"},
56 f, err := c.parseArgs(args, flagSpec{Values: []string{"--path", "--line", "--start-line", "--reply", "--message", "--file"},
5457 Bools: []string{"--old", "--pending"}, MaxPos: -1,
55 Usage: "mr diff-comment <owner/name> <n> --path <file> --line <l> [--old] [--pending] [--reply <id>] [--message <m> | --file -]"})
58 Usage: "mr diff-comment <owner/name> <n> --path <file> --line <l> [--start-line <s>] [--old] [--pending] [--reply <id>] [--message <m> | --file -]"})
5659 if err != nil {
5760 return c.fail(protocol.ExitUsage, "%v", err)
5861 }
5962 rest := f.Pos
6063 path, message, file, old := f.Value("--path"), f.Value("--message"), f.Value("--file"), f.Has("--old")
61 var line, replyTo int64
64 var line, startLine, replyTo int64
6265 if f.Has("--line") {
6366 n, err := strconv.ParseInt(f.Value("--line"), 10, 64)
6467 if err != nil || n < 1 {
@@ -66,6 +69,13 @@ func runDiffComment(c *Ctx, args []string) int {
6669 }
6770 line = n
6871 }
72 if f.Has("--start-line") {
73 n, err := strconv.ParseInt(f.Value("--start-line"), 10, 64)
74 if err != nil || n < 1 || (line != 0 && n > line) {
75 return c.fail(protocol.ExitUsage, "--start-line must be a positive number no greater than --line")
76 }
77 startLine = n
78 }
6979 if f.Has("--reply") {
7080 n, err := strconv.ParseInt(f.Value("--reply"), 10, 64)
7181 if err != nil || n < 1 {
@@ -90,6 +100,19 @@ func runDiffComment(c *Ctx, args []string) int {
90100 if strings.TrimSpace(body) == "" {
91101 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
92102 }
103 if replyTo != 0 && startLine != 0 {
104 return c.fail(protocol.ExitUsage, "a reply takes its thread's lines; drop --start-line")
105 }
106 _, hasSuggestion, err := suggest.Parse(body)
107 if err != nil {
108 return c.fail(protocol.ExitUsage, "%v", err)
109 }
110 if hasSuggestion && replyTo != 0 {
111 return c.fail(protocol.ExitUsage, "a suggestion opens its own thread: post it with --path and --line, not --reply")
112 }
113 if hasSuggestion && old {
114 return c.fail(protocol.ExitUsage, "a suggestion replaces lines of the new file; drop --old")
115 }
93116
94117 side := "new"
95118 if old {
@@ -113,10 +136,20 @@ func runDiffComment(c *Ctx, args []string) int {
113136 if !slices.Contains(files, path) {
114137 return c.fail(protocol.ExitUsage, "%s is not part of this merge request's diff", path)
115138 }
139 if hasSuggestion {
140 first := firstNonZero(startLine, line)
141 content, _, err := readAnchored(dir, mr.HeadSHA, path)
142 if err != nil {
143 return c.fail(protocol.ExitUsage, "%v", err)
144 }
145 if _, ok := suggest.Range(content, int(first), int(line)); !ok {
146 return c.fail(protocol.ExitUsage, "%s has no lines %d-%d at the head", path, first, line)
147 }
148 }
116149 }
117150
118151 pending := f.Has("--pending")
119 id, err := c.Store.AddDiffComment(mr.ID, c.User.ID, mr.HeadSHA, path, side, line, body, replyTo, pending)
152 id, err := c.Store.AddDiffComment(mr.ID, c.User.ID, mr.HeadSHA, path, side, line, startLine, body, replyTo, pending)
120153 if err != nil {
121154 if errors.Is(err, store.ErrNotFound) {
122155 return c.fail(protocol.ExitNotFound, "%v", err)
@@ -175,22 +208,26 @@ func runMRThreads(c *Ctx, args []string) int {
175208 CreatedAt string `json:"created_at"`
176209 }
177210 type threadOut struct {
178 ID int64 `json:"id"`
179 Path string `json:"path"`
180 Side string `json:"side"`
181 Line int64 `json:"line"`
182 Stale bool `json:"stale"`
183 Resolved string `json:"resolved_by,omitempty"`
184 Comments []commentOut `json:"comments"`
211 ID int64 `json:"id"`
212 Path string `json:"path"`
213 Side string `json:"side"`
214 StartLine int64 `json:"start_line,omitempty"`
215 Line int64 `json:"line"`
216 Stale bool `json:"stale"`
217 Resolved string `json:"resolved_by,omitempty"`
218 Suggestion *SuggestionOut `json:"suggestion,omitempty"`
219 Comments []commentOut `json:"comments"`
185220 }
221 suggestions := Suggestions(c.Store, c.Cfg.Server.Root, repo, mr, comments)
186222 byRoot := map[int64]*threadOut{}
187223 var order []int64
188224 for _, cm := range comments {
189225 if cm.ReplyTo == 0 {
190226 byRoot[cm.ID] = &threadOut{
191 ID: cm.ID, Path: cm.Path, Side: cm.Side, Line: cm.Line,
227 ID: cm.ID, Path: cm.Path, Side: cm.Side, StartLine: cm.StartLine, Line: cm.Line,
192228 Stale: cm.HeadSHA != mr.HeadSHA, Resolved: cm.ResolvedBy,
193 Comments: []commentOut{{cm.ID, cm.Author, cm.Body, cm.CreatedAt}},
229 Suggestion: suggestions[cm.ID],
230 Comments: []commentOut{{cm.ID, cm.Author, cm.Body, cm.CreatedAt}},
194231 }
195232 order = append(order, cm.ID)
196233 } else if th, ok := byRoot[cm.ReplyTo]; ok {
@@ -201,7 +238,6 @@ func runMRThreads(c *Ctx, args []string) int {
201238 for _, id := range order {
202239 ds = append(ds, *byRoot[id])
203240 }
204 _ = repo
205241 return c.emit(ds, func(w io.Writer) {
206242 for _, th := range ds {
207243 marks := ""
@@ -211,14 +247,44 @@ func runMRThreads(c *Ctx, args []string) int {
211247 if th.Stale {
212248 marks += " [stale]"
213249 }
214 fmt.Fprintf(w, "thread %d %s:%d (%s)%s\n", th.ID, th.Path, th.Line, th.Side, marks)
215 for _, cm := range th.Comments {
216 fmt.Fprintf(w, " %s: %s\n", cm.Author, cm.Body)
250 lines := fmt.Sprint(th.Line)
251 if th.StartLine != 0 && th.StartLine != th.Line {
252 lines = fmt.Sprintf("%d-%d", th.StartLine, th.Line)
253 }
254 fmt.Fprintf(w, "thread %d %s:%s (%s)%s\n", th.ID, th.Path, lines, th.Side, marks)
255 for i, cm := range th.Comments {
256 body := cm.Body
257 if i == 0 && th.Suggestion != nil {
258 body = suggest.Strip(body)
259 }
260 if body != "" {
261 fmt.Fprintf(w, " %s: %s\n", cm.Author, body)
262 }
263 if i == 0 && th.Suggestion != nil {
264 writeSuggestion(w, repo, mr, th.ID, cm.Author, th.Suggestion)
265 }
217266 }
218267 }
219268 })
220269}
221270
271// writeSuggestion prints a suggestion as the diff it proposes and how to
272// apply it, or why it can no longer be applied.
273func writeSuggestion(w io.Writer, repo store.Repo, mr store.MR, thread int64, author string, s *SuggestionOut) {
274 switch {
275 case s.Outdated:
276 fmt.Fprintf(w, " %s suggests (outdated: %s):\n", author, s.Reason)
277 default:
278 fmt.Fprintf(w, " %s suggests (gitbay mr apply-suggestion %s %d %d):\n", author, repo.Path(), mr.Number, thread)
279 }
280 for _, l := range suggest.FromText(strings.ReplaceAll(s.Original, "\r\n", "\n")) {
281 fmt.Fprintf(w, " - %s\n", l)
282 }
283 for _, l := range suggest.FromText(s.Replacement) {
284 fmt.Fprintf(w, " + %s\n", l)
285 }
286}
287
222288func setThreadResolved(c *Ctx, args []string, resolved bool) int {
223289 if len(args) != 3 {
224290 return c.usage()
@@ -234,20 +300,15 @@ func setThreadResolved(c *Ctx, args []string, resolved bool) int {
234300 if err != nil {
235301 return c.fail(protocol.ExitUsage, "bad thread id %q", args[2])
236302 }
237 // Thread author, MR author, or anyone with write may resolve.
238 author, err := c.Store.DiffCommentAuthor(mr.ID, threadID)
303 ok, err := canResolveThread(c, repo, mr, threadID)
239304 if errors.Is(err, store.ErrNotFound) {
240305 return c.fail(protocol.ExitNotFound, "no thread %d on %s!%d", threadID, repo.Path(), mr.Number)
241306 }
242307 if err != nil {
243308 return c.fail(protocol.ExitFailure, "%v", err)
244309 }
245 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
246 if err != nil {
247 return c.fail(protocol.ExitFailure, "%v", err)
248 }
249 if author != c.User.ID && mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
250 return c.fail(protocol.ExitDenied, "only the thread author, the MR author, or users with write access can resolve threads")
310 if !ok {
311 return c.fail(protocol.ExitDenied, "%s", cannotResolve)
251312 }
252313 if err := c.Store.SetThreadResolved(mr.ID, threadID, c.User.ID, resolved); err != nil {
253314 if errors.Is(err, store.ErrNotFound) {
@@ -267,5 +328,21 @@ func setThreadResolved(c *Ctx, args []string, resolved bool) int {
267328 })
268329}
269330
331const cannotResolve = "only the thread author, the MR author, or users with write access can resolve threads"
332
333// canResolveThread reports whether the caller may resolve a thread: its
334// author, the MR author, or anyone with write on the target.
335func canResolveThread(c *Ctx, repo store.Repo, mr store.MR, threadID int64) (bool, error) {
336 author, err := c.Store.DiffCommentAuthor(mr.ID, threadID)
337 if err != nil {
338 return false, err
339 }
340 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
341 if err != nil {
342 return false, err
343 }
344 return author == c.User.ID || mr.Author == c.User.Username || policy.CanWrite(c.User, repo, grant), nil
345}
346
270347func runMRResolve(c *Ctx, args []string) int { return setThreadResolved(c, args, true) }
271348func runMRUnresolve(c *Ctx, args []string) int { return setThreadResolved(c, args, false) }
internal/control/mergequeue_test.go +1 −1
@@ -232,7 +232,7 @@ func TestWhenReadyReviewMerges(t *testing.T) {
232232// Resolving the last open thread merges the queued request.
233233func TestWhenReadyThreadResolveMerges(t *testing.T) {
234234 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireResolved = true })
235 id, err := f.st.AddDiffComment(f.mr().ID, f.alice.ID, f.headSHA, "feature.txt", "new", 1, "why?", 0, false)
235 id, err := f.st.AddDiffComment(f.mr().ID, f.alice.ID, f.headSHA, "feature.txt", "new", 1, 0, "why?", 0, false)
236236 if err != nil {
237237 t.Fatal(err)
238238 }
internal/control/quota.go +19
@@ -81,6 +81,25 @@ func checkRepoQuota(c *Ctx) int {
8181 return -1
8282}
8383
84// checkStorageQuota refuses a server-side write into repo once its
85// owner's storage quota is used up, the check sshd makes before a push.
86// Repositories an org owns have no quota.
87func checkStorageQuota(c *Ctx, repo store.Repo) int {
88 if repo.OwnerKind != "user" {
89 return -1
90 }
91 limit := ByteLimit(c.Store, limitsOf(c), repo.OwnerID)
92 if limit <= 0 {
93 return -1
94 }
95 if used := OwnedBytes(c.Store, c.Cfg.Server.Root, repo.OwnerID); used >= limit {
96 return c.fail(protocol.ExitDenied,
97 "%s's storage quota is used up (%d of %d bytes); delete something, or ask an admin to raise the limit",
98 repo.OwnerName, used, limit)
99 }
100 return -1
101}
102
84103func init() {
85104 register(Command{Path: []string{"admin", "user", "limits"},
86105 Summary: "show or set an account's repository and storage caps (instance admins)",
internal/control/refsupdated.go added +212
@@ -0,0 +1,212 @@
1package control
2
3import (
4 "encoding/json"
5 "fmt"
6 "log/slog"
7 "path"
8 "strings"
9 "time"
10
11 "gitbay.org/gitbay/internal/ci"
12 "gitbay.org/gitbay/internal/config"
13 "gitbay.org/gitbay/internal/gitutil"
14 "gitbay.org/gitbay/internal/policy"
15 "gitbay.org/gitbay/internal/store"
16)
17
18// RefsUpdated is the work that follows a ref update in repoID by userID,
19// with a key or token of scope: post-receive runs it for every push, and
20// a server-side write to a branch (an applied suggestion) runs it after
21// its own ref update, so nothing a push triggers is skipped. A push to a
22// source branch refreshes refs/merge-requests/N/head in every target
23// repo, by fetching — the target owns the objects, so the MR outlives the
24// fork. This is the only place a push writes outside its own repository.
25func RefsUpdated(st *store.Store, cfg config.Config, repoID, userID int64, scope string, updates []policy.RefUpdate) {
26 pushedRepo, pushedRepoErr := st.RepoByID(repoID)
27 if pushedRepoErr == nil {
28 adoptDefaultBranch(st, cfg, &pushedRepo, updates)
29 }
30 for _, u := range updates {
31 // Every ref update is an event webhooks can subscribe to.
32 st.RecordEvent(repoID, userID, "push", fmt.Sprintf(
33 `{"ref":%q,"old":%q,"new":%q,"forced":%v,"deleted":%v}`,
34 u.Ref, u.Old, u.New, u.IsForce, u.IsDelete))
35
36 // Any ref update — branch or tag — schedules the push mirrors.
37 st.MarkMirrorsDirty(repoID, "push")
38
39 // Tag pushes run the tag-triggered CI jobs.
40 if tag, ok := strings.CutPrefix(u.Ref, "refs/tags/"); ok && !u.IsDelete && pushedRepoErr == nil {
41 QueueTagBuilds(st, cfg, pushedRepo, userID, tag, u.New)
42 }
43
44 branch, ok := cutHeads(u.Ref)
45 if !ok {
46 continue
47 }
48 // Commits landing on the default branch act on issue references
49 // in their messages (closes #N, plain #N).
50 if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete {
51 dir := RepoDir(cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name)
52 ProcessCommitMessages(st, dir, pushedRepo, userID, scope, u.Old, u.New)
53 RecordLandedCommits(st, dir, pushedRepo, u.Old, u.New)
54 }
55 // A branch push with a .gitbay/ci.yml queues one build per job.
56 if pushedRepoErr == nil && !u.IsDelete {
57 QueueBranchBuilds(st, cfg.Server.Root, cfg.Server.SiteURL,
58 pushedRepo, userID, branch, u.Old, u.New, time.Now())
59 }
60 if u.IsForce {
61 st.Audit(userID, "push.forced", map[string]any{
62 "repo": repoID, "ref": u.Ref, "old": u.Old, "new": u.New})
63 }
64 mrs, err := st.OpenMRsBySource(repoID, branch)
65 if err != nil {
66 slog.Error("post-receive: listing MRs", "err", err)
67 continue
68 }
69 srcRepo, err := st.RepoByID(repoID)
70 if err != nil {
71 continue
72 }
73 srcDir := RepoDir(cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
74 for _, mr := range mrs {
75 target, err := st.RepoByID(mr.RepoID)
76 if err != nil {
77 continue
78 }
79 if u.IsDelete {
80 if mr.State == "open" {
81 st.SetMRState(mr.ID, "source_gone")
82 }
83 if mr.QueuedAt != "" {
84 TryQueuedMerge(st, cfg, mr.ID) // dequeues: the source is gone
85 }
86 continue // head ref retained: the diff stays viewable
87 }
88 dstDir := RepoDir(cfg.Server.Root, target.OwnerName, target.Name)
89 headRef := fmt.Sprintf("refs/merge-requests/%d/head", mr.Number)
90 if err := gitutil.FetchInto(dstDir, srcDir, u.New, headRef); err != nil {
91 slog.Error("post-receive: refreshing MR head", "mr", mr.Number, "err", err)
92 continue
93 }
94 // The merge base as it stands now, so a later range-diff
95 // compares each revision against the target it was written
96 // on rather than against today's. Best-effort: a base that
97 // cannot be worked out costs precision, not the record.
98 base, err := gitutil.MergeBase(dstDir, "refs/heads/"+mr.TargetRef, headRef)
99 if err != nil {
100 base = ""
101 }
102 if err := st.UpdateMRHead(mr.ID, u.New, base, sameChange(dstDir, mr, base, u.New)); err != nil {
103 slog.Error("post-receive: recording MR head", "mr", mr.Number, "err", err)
104 }
105 if srcRepo.ID != target.ID {
106 QueueMRBuilds(st, cfg.Server.Root, cfg.Server.SiteURL,
107 target, userID, mr.Number, u.New)
108 }
109 if mr.State == "source_gone" {
110 st.SetMRState(mr.ID, "open") // branch came back
111 }
112 // A queued merge stays queued across a push by someone who can
113 // merge it, and the new head has to pass the gates on its own.
114 if mr.QueuedAt != "" {
115 QueuedMergePushed(st, cfg, mr.ID, userID, scope)
116 }
117 }
118 }
119}
120
121// adoptDefaultBranch moves an unborn HEAD to the first branch a push
122// creates. A repository is initialised with HEAD at the stored default,
123// and a first push of master or trunk left HEAD naming a branch that did
124// not exist: clones checked out nothing and every surface asked git for
125// a branch that was not there (#189). A push that includes the default
126// branch itself needs nothing.
127func adoptDefaultBranch(st *store.Store, cfg config.Config, repo *store.Repo, updates []policy.RefUpdate) {
128 dir := RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
129 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch); err == nil {
130 return
131 }
132 for _, u := range updates {
133 branch, ok := cutHeads(u.Ref)
134 if !ok || u.IsDelete || !gitutil.ZeroSHA(u.Old) {
135 continue
136 }
137 if err := gitutil.SetHead(dir, branch); err != nil {
138 slog.Error("post-receive: moving HEAD", "repo", repo.Path(), "err", err)
139 return
140 }
141 if err := st.UpdateDefaultBranch(repo.ID, branch); err != nil {
142 slog.Error("post-receive: recording default branch", "repo", repo.Path(), "err", err)
143 return
144 }
145 repo.DefaultBranch = branch
146 return
147 }
148}
149
150// sameChange reports whether the new head proposes the diff the old one
151// did: the patch-id of each revision against its own merge base. A
152// rebase onto a moved target changes every sha and nothing about the
153// change, and the reviews of it should not go stale for that (#198).
154// Any doubt answers false, which is the old behaviour.
155func sameChange(dir string, mr store.MR, newBase, newHead string) bool {
156 if mr.HeadSHA == "" || newBase == "" || mr.HeadSHA == newHead {
157 return false
158 }
159 oldBase, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mr.HeadSHA)
160 if err != nil {
161 return false
162 }
163 oldID, err := gitutil.PatchID(dir, oldBase, mr.HeadSHA)
164 if err != nil || oldID == "" {
165 return false
166 }
167 newID, err := gitutil.PatchID(dir, newBase, newHead)
168 return err == nil && newID == oldID
169}
170
171// QueueTagBuilds runs the jobs whose tag pattern matches a pushed tag.
172// The build records the tag as its ref and the peeled commit as its sha,
173// so statuses land on the commit, not an annotated tag object.
174func QueueTagBuilds(st *store.Store, cfg config.Config, repo store.Repo, userID int64, tag, pushed string) {
175 dir := RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
176 sha, err := gitutil.PeelToCommit(dir, pushed)
177 if err != nil {
178 return
179 }
180 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
181 if err != nil {
182 return
183 }
184 jobs, err := ci.Parse(raw)
185 if err != nil {
186 return // the branch push already reported ci/config
187 }
188 for _, j := range jobs {
189 if j.Tags == "" {
190 continue
191 }
192 if ok, _ := path.Match(j.Tags, tag); !ok {
193 continue
194 }
195 steps, _ := json.Marshal(j.Steps)
196 n, err := st.CreateBuild(repo.ID, j.Name, sha, tag, string(steps), j.Image, "", true)
197 if err != nil {
198 slog.Error("queueing tag build", "repo", repo.Path(), "job", j.Name, "err", err)
199 continue
200 }
201 url := fmt.Sprintf("%s/%s/builds/%d", cfg.Server.SiteURL, repo.Path(), n)
202 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "tag "+tag, url, userID)
203 }
204}
205
206func cutHeads(ref string) (string, bool) {
207 const p = "refs/heads/"
208 if len(ref) > len(p) && ref[:len(p)] == p {
209 return ref[len(p):], true
210 }
211 return "", false
212}
internal/control/suggestion.go added +374
@@ -0,0 +1,374 @@
1package control
2
3import (
4 "bytes"
5 "errors"
6 "fmt"
7 "io"
8 "strconv"
9
10 "gitbay.org/gitbay/internal/gitutil"
11 "gitbay.org/gitbay/internal/policy"
12 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/store"
14 "gitbay.org/gitbay/internal/suggest"
15)
16
17func init() {
18 register(Command{Path: []string{"mr", "apply-suggestion"},
19 Summary: "commit a review thread's suggestion to the source branch",
20 Usage: "mr apply-suggestion <owner/name> <n> <thread-id>",
21 Examples: []string{"mr apply-suggestion krz/gitbay 431 12"},
22 Run: runMRApplySuggestion})
23}
24
25// SuggestionOut is the change a review thread's ```suggestion block
26// proposes: lines StartLine through EndLine of Path, as they were at
27// Commit (Original), replaced by Replacement. Both texts end every line
28// with its terminator, so "" is no lines. Apply is "server" where `mr
29// apply-suggestion` commits it, or "local" on a repository requiring
30// signed commits, where the CLI commits it with the user's own key.
31type SuggestionOut struct {
32 Path string `json:"path"`
33 StartLine int64 `json:"start_line"`
34 EndLine int64 `json:"end_line"`
35 Commit string `json:"commit"`
36 Blob string `json:"blob,omitempty"`
37 Original string `json:"original"`
38 Replacement string `json:"replacement"`
39 Outdated bool `json:"outdated"`
40 Reason string `json:"reason,omitempty"`
41 Apply string `json:"apply"`
42}
43
44// maxSuggestionBytes bounds the file a suggestion rewrites, the same as
45// a single-file commit over the control plane.
46const maxSuggestionBytes = maxCommitFileBytes
47
48// Reasons a suggestion cannot be applied at a head.
49const (
50 reasonGone = "the file is not at the head: it was renamed or deleted"
51 reasonChanged = "the lines it replaces have changed since it was made"
52 reasonNoBase = "the commit it was made against is no longer available"
53)
54
55// anchoredFiles reads the files suggestions anchor in, for one page or
56// listing: one ls-tree per (commit, path), and every blob through one
57// cat-file --batch process started on first use, so a merge request with
58// many suggestions on a file costs no more processes than one with a
59// single suggestion. Blobs are kept by id up to cacheCap bytes in all;
60// past that one is read again through the same process.
61type anchoredFiles struct {
62 dir string
63 entries map[[2]string]anchoredEntry
64 blobs map[string][]byte
65 cached int64
66 cacheCap int64
67 batch *gitutil.BlobBatch
68 spawned int // git processes started, for the test that bounds it
69}
70
71// anchoredCacheCap bounds the blobs one render keeps.
72const anchoredCacheCap = 8 << 20
73
74type anchoredEntry struct {
75 e gitutil.TreeEntry
76 ok bool
77}
78
79func newAnchoredFiles(dir string) *anchoredFiles {
80 return &anchoredFiles{dir: dir, entries: map[[2]string]anchoredEntry{}, blobs: map[string][]byte{},
81 cacheCap: anchoredCacheCap}
82}
83
84func (f *anchoredFiles) close() {
85 if f.batch != nil {
86 f.batch.Close()
87 }
88}
89
90// read returns the regular file path at commit, with its mode and blob id.
91func (f *anchoredFiles) read(commit, path string) ([]byte, string, string, error) {
92 key := [2]string{commit, path}
93 ent, seen := f.entries[key]
94 if !seen {
95 f.spawned++
96 ent.e, ent.ok = gitutil.StatPath(f.dir, commit, path)
97 f.entries[key] = ent
98 }
99 e := ent.e
100 if !ent.ok {
101 return nil, "", "", fmt.Errorf("%s", reasonGone)
102 }
103 if e.Type != "blob" || (e.Mode != "100644" && e.Mode != "100755") {
104 return nil, "", "", fmt.Errorf("%s is not a regular file", path)
105 }
106 if e.Size > maxSuggestionBytes {
107 return nil, "", "", fmt.Errorf("%s is larger than %d bytes", path, maxSuggestionBytes)
108 }
109 if content, ok := f.blobs[e.SHA]; ok {
110 return content, e.Mode, e.SHA, nil
111 }
112 if f.batch == nil {
113 b, err := gitutil.NewBlobBatch(f.dir)
114 if err != nil {
115 return nil, "", "", err
116 }
117 f.spawned++
118 f.batch = b
119 }
120 content, err := f.batch.Read(e.SHA, maxSuggestionBytes)
121 if err != nil {
122 return nil, "", "", err
123 }
124 if f.cached+int64(len(content)) <= f.cacheCap {
125 f.blobs[e.SHA] = content
126 f.cached += int64(len(content))
127 }
128 return content, e.Mode, e.SHA, nil
129}
130
131// readAnchored reads the regular file path at commit, with its mode.
132func readAnchored(dir, commit, path string) ([]byte, string, error) {
133 f := newAnchoredFiles(dir)
134 defer f.close()
135 content, mode, _, err := f.read(commit, path)
136 return content, mode, err
137}
138
139// suggestionRange is a thread's first and last line.
140func suggestionRange(cm store.DiffComment) (int, int) {
141 return int(firstNonZero(cm.StartLine, cm.Line)), int(cm.Line)
142}
143
144// Suggestions are the suggestions the thread roots among comments carry,
145// by thread id, each checked against the merge request's head. The
146// original lines are read from the commit the comment was made on, in
147// the target repository, which holds every head the merge request has
148// had until gc prunes an abandoned one.
149func Suggestions(st *store.Store, root string, repo store.Repo, mr store.MR, comments []store.DiffComment) map[int64]*SuggestionOut {
150 f := newAnchoredFiles(RepoDir(root, repo.OwnerName, repo.Name))
151 defer f.close()
152 return suggestionsWith(f, func() bool { return signedOnly(st, repo, mr) }, mr, comments)
153}
154
155// suggestionsWith is Suggestions reading through f. signed is asked
156// once, and only when there is a suggestion.
157func suggestionsWith(f *anchoredFiles, signed func() bool, mr store.MR, comments []store.DiffComment) map[int64]*SuggestionOut {
158 out := map[int64]*SuggestionOut{}
159 apply := ""
160 for _, cm := range comments {
161 if cm.ReplyTo != 0 || cm.Side != "new" {
162 continue
163 }
164 lines, found, err := suggest.Parse(cm.Body)
165 if err != nil || !found {
166 continue
167 }
168 if apply == "" {
169 apply = "server"
170 if signed() {
171 apply = "local"
172 }
173 }
174 start, end := suggestionRange(cm)
175 s := &SuggestionOut{Path: cm.Path, StartLine: int64(start), EndLine: int64(end), Commit: cm.HeadSHA,
176 Replacement: suggest.Text(lines), Apply: apply}
177 out[cm.ID] = s
178 base, _, blob, err := f.read(cm.HeadSHA, cm.Path)
179 s.Blob = blob
180 orig, ok := suggest.Range(base, start, end)
181 if err != nil || !ok {
182 s.Outdated, s.Reason = true, reasonNoBase
183 continue
184 }
185 s.Original = string(orig)
186 s.Reason = anchorReason(f, mr.HeadSHA, s)
187 s.Outdated = s.Reason != ""
188 }
189 return out
190}
191
192// anchorReason says why s cannot be applied to the file at head, or ""
193// when the lines it replaces are still what it was made against.
194func anchorReason(f *anchoredFiles, head string, s *SuggestionOut) string {
195 content, _, _, err := f.read(head, s.Path)
196 if err != nil {
197 return err.Error()
198 }
199 now, ok := suggest.Range(content, int(s.StartLine), int(s.EndLine))
200 if !ok || !bytes.Equal(now, []byte(s.Original)) {
201 return reasonChanged
202 }
203 return ""
204}
205
206// signedOnly reports whether the server may not commit a suggestion for
207// this merge request: the source branch or the target it merges into
208// requires signed commits, and the server has no key to sign with.
209func signedOnly(st *store.Store, repo store.Repo, mr store.MR) bool {
210 if repo.Settings.RequireSignedCommits {
211 return true
212 }
213 if mr.SourceRepoID == repo.ID {
214 return false
215 }
216 src, err := st.RepoByID(mr.SourceRepoID)
217 return err != nil || src.Settings.RequireSignedCommits
218}
219
220// runMRApplySuggestion commits a thread's suggestion to the merge
221// request's source branch as the caller, and resolves the thread.
222//
223// It is a push by the caller, and is held to what a push is: the caller
224// needs write on the source repository (for a fork, the fork's writers;
225// write on the target grants nothing there), the update goes through the
226// pre-receive ref policy (policy.CheckPush: protected branches,
227// require-mr) before a compare-and-swap ref update, and RefsUpdated then
228// does what post-receive does, which moves the merge request's head and
229// reaches a queued merge. The server has no signing key, so where either
230// repository requires signed commits it refuses, and the CLI applies the
231// suggestion locally instead.
232func runMRApplySuggestion(c *Ctx, args []string) int {
233 if len(args) != 3 {
234 return c.usage()
235 }
236 repo, mr, code := mrRef(c, args[:2], policy.CanRead)
237 if code >= 0 {
238 return code
239 }
240 if code := refuseArchived(c, repo); code >= 0 {
241 return code
242 }
243 threadID, err := strconv.ParseInt(args[2], 10, 64)
244 if err != nil {
245 return c.fail(protocol.ExitUsage, "bad thread id %q", args[2])
246 }
247 cm, err := c.Store.DiffCommentByID(mr.ID, threadID)
248 if errors.Is(err, store.ErrNotFound) || (err == nil && cm.Pending && cm.Author != c.User.Username) {
249 return c.fail(protocol.ExitNotFound, "no thread %d on %s!%d", threadID, repo.Path(), mr.Number)
250 }
251 if err != nil {
252 return c.failErr(err)
253 }
254 if cm.ReplyTo != 0 {
255 return c.fail(protocol.ExitUsage, "%d is a reply; name the thread root %d", threadID, cm.ReplyTo)
256 }
257 if cm.Pending {
258 return c.fail(protocol.ExitUsage, "thread %d is in your unsubmitted review; submit it with `mr review` first", threadID)
259 }
260 if mr.State != "open" {
261 return c.fail(protocol.ExitUsage, "%s!%d is %s", repo.Path(), mr.Number, mr.State)
262 }
263 s := Suggestions(c.Store, c.Cfg.Server.Root, repo, mr, []store.DiffComment{cm})[cm.ID]
264 if s == nil {
265 return c.fail(protocol.ExitUsage, "thread %d carries no suggestion", threadID)
266 }
267
268 src, err := c.Store.RepoByID(mr.SourceRepoID)
269 if err != nil {
270 return c.failErr(err)
271 }
272 grant, err := c.Store.AccessRole(src.ID, c.User.ID)
273 if err != nil {
274 return c.failErr(err)
275 }
276 source := mr.SourceRef
277 if src.ID != repo.ID {
278 source = src.Path() + ":" + mr.SourceRef
279 }
280 if !policy.CanWrite(c.User, src, grant) {
281 return c.fail(protocol.ExitDenied, "applying a suggestion pushes to %s; only its writers can", source)
282 }
283 if src.Settings.Archived {
284 return c.fail(protocol.ExitDenied, "%s is archived and read-only", src.Path())
285 }
286 if mirrored, err := c.Store.PullMirrored(src.ID); err != nil {
287 return c.failErr(err)
288 } else if mirrored {
289 return c.fail(protocol.ExitDenied, "%s is a pull mirror: its refs come from the upstream", src.Path())
290 }
291 if s.Apply == "local" {
292 return c.fail(protocol.ExitDenied,
293 "%s requires signed commits and the server cannot sign one; apply it from a clone, which commits with your own key: gitbay mr apply-suggestion %s %d %d",
294 source, repo.Path(), mr.Number, threadID)
295 }
296 if code := checkStorageQuota(c, src); code >= 0 {
297 return code
298 }
299 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
300 if err != nil {
301 return c.failErr(err)
302 }
303 if email == "" {
304 return c.fail(protocol.ExitDenied, "commits carry your identity: your account needs a verified primary email")
305 }
306
307 srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name)
308 ref := "refs/heads/" + mr.SourceRef
309 tip, err := gitutil.ResolveRef(srcDir, ref)
310 if err != nil {
311 return c.fail(protocol.ExitUsage, "the source branch %s is gone", source)
312 }
313 if s.Reason == reasonNoBase {
314 return c.fail(protocol.ExitUsage, "suggestion in thread %d cannot be applied: %s", threadID, s.Reason)
315 }
316 files := newAnchoredFiles(srcDir)
317 defer files.close()
318 if reason := anchorReason(files, tip, s); reason != "" {
319 return c.fail(protocol.ExitUsage, "suggestion in thread %d is outdated: %s", threadID, reason)
320 }
321 content, mode, _, err := files.read(tip, s.Path)
322 if err != nil {
323 return c.fail(protocol.ExitUsage, "%v", err)
324 }
325 updated, err := suggest.Apply(content, int(s.StartLine), int(s.EndLine), suggest.FromText(s.Replacement))
326 if err != nil {
327 return c.fail(protocol.ExitUsage, "%v", err)
328 }
329 if bytes.Equal(updated, content) {
330 return c.fail(protocol.ExitUsage, "the suggestion in thread %d changes nothing", threadID)
331 }
332 message := suggest.Message(repo.Path(), mr.Number, threadID, cm.Author)
333 sha, err := gitutil.CommitWithFile(srcDir, tip, s.Path, mode, updated, c.User.Username, email, message)
334 if err != nil {
335 return c.failErr(err)
336 }
337 updates := []policy.RefUpdate{{Ref: ref, Old: tip, New: sha}}
338 if msg := policy.CheckPush(src, updates); msg != "" {
339 return c.fail(protocol.ExitDenied, "%s", msg)
340 }
341 if err := gitutil.UpdateRefCAS(srcDir, ref, sha, tip); err != nil {
342 return c.fail(protocol.ExitFailure, "the source branch moved; reload and retry")
343 }
344 RefsUpdated(c.Store, c.Cfg, src.ID, c.User.ID, c.Scope, updates)
345
346 // The commit has landed, so from here nothing fails the command: a
347 // thread that cannot be resolved is left open and the output says so.
348 resolved, warning := false, ""
349 switch ok, err := canResolveThread(c, repo, mr, threadID); {
350 case err != nil:
351 warning = fmt.Sprintf("thread %d is still open: %v", threadID, err)
352 case !ok:
353 warning = fmt.Sprintf("thread %d is still open: %s", threadID, cannotResolve)
354 default:
355 if err := c.Store.SetThreadResolved(mr.ID, threadID, c.User.ID, true); err != nil {
356 warning = fmt.Sprintf("thread %d is still open: %v", threadID, err)
357 } else {
358 resolved = true
359 TryQueuedMerge(c.Store, c.Cfg, mr.ID)
360 }
361 }
362 d := map[string]any{"thread": threadID, "sha": sha, "source": source, "resolved": resolved}
363 if warning != "" {
364 d["warning"] = warning
365 }
366 return c.emit(d, func(w io.Writer) {
367 if resolved {
368 fmt.Fprintf(w, "applied thread %d to %s at %.10s; thread resolved\n", threadID, source, sha)
369 return
370 }
371 fmt.Fprintf(w, "applied thread %d to %s at %.10s\n", threadID, source, sha)
372 fmt.Fprintln(c.Stderr, "warning:", warning)
373 })
374}
internal/control/suggestion_test.go added +565
@@ -0,0 +1,565 @@
1package control
2
3import (
4 "encoding/json"
5 "strconv"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13// suggestFixture is a queueFixture whose feature branch carries a
14// multi-line file, lib.txt, for suggestions to anchor in.
15func newSuggestFixture(t *testing.T, set func(*store.RepoSettings)) *queueFixture {
16 t.Helper()
17 f := newQueueFixture(t, set)
18 f.write("lib.txt", "one\ntwo\nthree\nfour\nfive\n")
19 f.write("dos.txt", "a\r\nb\r\nc\r\n")
20 f.write("tail.txt", "x\nlast")
21 f.git(f.src, "add", ".")
22 f.git(f.src, "commit", "-q", "-m", "lib")
23 f.moveHead()
24 return f
25}
26
27// moveHead pushes the fixture's feature branch to the bare repository
28// and points the merge request at it, as post-receive would.
29func (f *queueFixture) moveHead() {
30 f.t.Helper()
31 f.git(f.src, "push", "-q", "--force", f.dir, "feature")
32 f.headSHA = strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD"))
33 f.git(f.dir, "update-ref", mrHeadRef(1), f.headSHA)
34 if err := f.st.UpdateMRHead(f.mr().ID, f.headSHA, f.targetSH, false); err != nil {
35 f.t.Fatal(err)
36 }
37}
38
39// suggest opens a thread on lib.txt start-end with a suggestion block
40// holding lines, returning the thread id.
41func (f *queueFixture) suggest(u store.User, path string, start, end int, lines ...string) string {
42 f.t.Helper()
43 body := "try this\n```suggestion\n" + strings.Join(lines, "\n")
44 if len(lines) > 0 {
45 body += "\n"
46 }
47 body += "```\n"
48 var out, errOut strings.Builder
49 c := &Ctx{User: u, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut, JSON: true,
50 Stdin: strings.NewReader(body)}
51 c.Cfg.Server.Root = f.root
52 c.Cfg.Limits.WriteRate = -1
53 argv := []string{"mr", "diff-comment", f.repo.Path(), "1", "--path", path,
54 "--start-line", strconv.Itoa(start), "--line", strconv.Itoa(end), "--file", "-"}
55 if code := Dispatch(c, argv); code != protocol.ExitOK {
56 f.t.Fatalf("diff-comment: exit %d, %s", code, errOut.String())
57 }
58 var env struct {
59 Data struct {
60 Thread int64 `json:"thread"`
61 } `json:"data"`
62 }
63 if err := json.Unmarshal([]byte(out.String()), &env); err != nil {
64 f.t.Fatal(err)
65 }
66 return strconv.Itoa(int(env.Data.Thread))
67}
68
69type threadJSON struct {
70 ID int64 `json:"id"`
71 StartLine int64 `json:"start_line"`
72 Line int64 `json:"line"`
73 Resolved string `json:"resolved_by"`
74 Suggestion *SuggestionOut `json:"suggestion"`
75}
76
77// unlimited lifts the per-account write limit, which these tests would
78// otherwise spend for every test in the package that writes as uid 1.
79func unlimited(c *Ctx) { c.Cfg.Limits.WriteRate = -1 }
80
81func (f *queueFixture) mustWrite(u store.User, argv ...string) string {
82 f.t.Helper()
83 code, out, errOut := f.runWith(u, unlimited, argv...)
84 if code != protocol.ExitOK {
85 f.t.Fatalf("%v: exit %d, %s", argv, code, errOut)
86 }
87 return out
88}
89
90func (f *queueFixture) threads(u store.User) []threadJSON {
91 f.t.Helper()
92 out := f.mustRun(u, "mr", "threads", f.repo.Path(), "1", "--json")
93 var env struct {
94 Data []threadJSON `json:"data"`
95 }
96 if err := json.Unmarshal([]byte(out), &env); err != nil {
97 f.t.Fatalf("threads JSON: %v\n%s", err, out)
98 }
99 return env.Data
100}
101
102func (f *queueFixture) suggestion(u store.User, thread string) *SuggestionOut {
103 f.t.Helper()
104 for _, th := range f.threads(u) {
105 if strconv.Itoa(int(th.ID)) == thread {
106 return th.Suggestion
107 }
108 }
109 f.t.Fatalf("no thread %s", thread)
110 return nil
111}
112
113// A suggestion over a range reads back from mr threads as structure:
114// the anchor, the commit and blob it was made against, the lines it
115// replaces and the replacement.
116func TestSuggestionInThreads(t *testing.T) {
117 f := newSuggestFixture(t, nil)
118 id := f.suggest(f.alice, "lib.txt", 2, 3, "TWO", "THREE", "extra")
119 s := f.suggestion(f.alice, id)
120 if s == nil {
121 t.Fatal("thread carries no suggestion")
122 }
123 blob := strings.TrimSpace(f.git(f.dir, "rev-parse", f.headSHA+":lib.txt"))
124 want := SuggestionOut{Path: "lib.txt", StartLine: 2, EndLine: 3, Commit: f.headSHA, Blob: blob,
125 Original: "two\nthree\n", Replacement: "TWO\nTHREE\nextra\n", Apply: "server"}
126 if *s != want {
127 t.Fatalf("suggestion = %+v\nwant %+v", *s, want)
128 }
129 text := f.mustRun(f.alice, "mr", "threads", f.repo.Path(), "1")
130 for _, w := range []string{"lib.txt:2-3", "- two", "+ THREE", "mr apply-suggestion alice/app 1 " + id} {
131 if !strings.Contains(text, w) {
132 t.Errorf("threads text lacks %q:\n%s", w, text)
133 }
134 }
135 if strings.Contains(text, "```suggestion") {
136 t.Errorf("threads text repeats the raw block:\n%s", text)
137 }
138}
139
140// The suggestion goes stale when the lines it replaces change, and not
141// when the file changes elsewhere.
142func TestSuggestionOutdated(t *testing.T) {
143 f := newSuggestFixture(t, nil)
144 id := f.suggest(f.alice, "lib.txt", 2, 2, "TWO")
145 f.write("lib.txt", "one\ntwo\nthree\nfour\nFIVE\n")
146 f.git(f.src, "commit", "-q", "-am", "elsewhere")
147 f.moveHead()
148 if s := f.suggestion(f.alice, id); s.Outdated {
149 t.Fatalf("a change below the range outdated the suggestion: %+v", s)
150 }
151 f.write("lib.txt", "zero\none\ntwo\nthree\nfour\nFIVE\n")
152 f.git(f.src, "commit", "-q", "-am", "shift")
153 f.moveHead()
154 if s := f.suggestion(f.alice, id); !s.Outdated || s.Reason != reasonChanged {
155 t.Fatalf("suggestion after its lines moved = %+v, want outdated", s)
156 }
157 f.git(f.src, "rm", "-q", "lib.txt")
158 f.git(f.src, "commit", "-q", "-m", "gone")
159 f.moveHead()
160 if s := f.suggestion(f.alice, id); !s.Outdated || s.Reason != reasonGone {
161 t.Fatalf("suggestion on a deleted file = %+v, want outdated", s)
162 }
163}
164
165// On a repository requiring signed commits the server does not commit a
166// suggestion, and the thread says the CLI applies it locally.
167func TestSuggestionSignedIsLocal(t *testing.T) {
168 f := newSuggestFixture(t, func(s *store.RepoSettings) { s.RequireSignedCommits = true })
169 id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
170 if s := f.suggestion(f.alice, id); s.Apply != "local" {
171 t.Fatalf("apply = %q, want local", s.Apply)
172 }
173}
174
175// What diff-comment refuses before storing a suggestion it could never
176// apply.
177func TestSuggestionRefusals(t *testing.T) {
178 f := newSuggestFixture(t, nil)
179 id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
180 run := func(body string, extra ...string) (int, string) {
181 var out, errOut strings.Builder
182 c := &Ctx{User: f.alice, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut,
183 Stdin: strings.NewReader(body)}
184 c.Cfg.Server.Root = f.root
185 c.Cfg.Limits.WriteRate = -1
186 return Dispatch(c, append([]string{"mr", "diff-comment", f.repo.Path(), "1", "--file", "-"}, extra...)), errOut.String()
187 }
188 block := "```suggestion\nx\n```\n"
189 cases := []struct {
190 name string
191 body string
192 args []string
193 want string
194 }{
195 {"old side", block, []string{"--path", "lib.txt", "--line", "1", "--old"}, "drop --old"},
196 {"reply", block, []string{"--reply", id}, "its own thread"},
197 {"past the end", block, []string{"--path", "lib.txt", "--start-line", "5", "--line", "6"}, "no lines 5-6"},
198 {"unclosed", "```suggestion\nx\n", []string{"--path", "lib.txt", "--line", "1"}, "not closed"},
199 {"start after line", "plain", []string{"--path", "lib.txt", "--start-line", "3", "--line", "2"}, "no greater than --line"},
200 }
201 for _, c := range cases {
202 code, errOut := run(c.body, c.args...)
203 if code != protocol.ExitUsage || !strings.Contains(errOut, c.want) {
204 t.Errorf("%s: exit %d %q, want usage with %q", c.name, code, errOut, c.want)
205 }
206 }
207}
208
209// verified gives u a verified primary address, which a commit needs.
210func (f *queueFixture) verified(u store.User) {
211 f.t.Helper()
212 if err := f.st.AddEmail(u.ID, u.Username+"@example.test", "admin", true); err != nil {
213 f.t.Fatal(err)
214 }
215}
216
217// applyOK applies thread as u and returns the new commit, checking what
218// every successful apply must have done: one commit on the old head by
219// u, naming the merge request and thread, the merge request moved to
220// it, and the thread resolved.
221func (f *queueFixture) applyOK(u store.User, thread string) string {
222 f.t.Helper()
223 old := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature"))
224 out := f.mustWrite(u, "mr", "apply-suggestion", f.repo.Path(), "1", thread, "--json")
225 var env struct {
226 Data struct {
227 SHA string `json:"sha"`
228 } `json:"data"`
229 }
230 json.Unmarshal([]byte(out), &env)
231 sha := env.Data.SHA
232 if tip := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature")); tip != sha || sha == "" {
233 f.t.Fatalf("feature = %s, apply reported %q", tip, sha)
234 }
235 if parent := strings.TrimSpace(f.git(f.dir, "rev-parse", sha+"^")); parent != old {
236 f.t.Fatalf("parent = %s, want the old head %s", parent, old)
237 }
238 meta := f.git(f.dir, "log", "-1", "--format=%an <%ae>|%cn <%ce>|%B", sha)
239 who := u.Username + " <" + u.Username + "@example.test>"
240 if !strings.HasPrefix(meta, who+"|"+who+"|") || !strings.Contains(meta, "Thread "+thread+" on alice/app!1") {
241 f.t.Fatalf("commit = %q", meta)
242 }
243 if mr := f.mr(); mr.HeadSHA != sha {
244 f.t.Fatalf("MR head = %s, want %s", mr.HeadSHA, sha)
245 }
246 if head := strings.TrimSpace(f.git(f.dir, "rev-parse", mrHeadRef(1))); head != sha {
247 f.t.Fatalf("MR head ref = %s, want %s", head, sha)
248 }
249 for _, th := range f.threads(u) {
250 if strconv.Itoa(int(th.ID)) == thread && th.Resolved != u.Username {
251 f.t.Fatalf("thread %s resolved by %q, want %s", thread, th.Resolved, u.Username)
252 }
253 }
254 return sha
255}
256
257func (f *queueFixture) file(sha, path string) string {
258 f.t.Helper()
259 return f.git(f.dir, "show", sha+":"+path)
260}
261
262// Each shape of range: several lines to more, deletion, the last line of
263// a file with no final newline, and a CRLF file.
264func TestApplySuggestion(t *testing.T) {
265 f := newSuggestFixture(t, nil)
266 f.verified(f.alice)
267 cases := []struct {
268 path string
269 start, end int
270 lines []string
271 want string
272 }{
273 {"lib.txt", 2, 3, []string{"TWO", "THREE", "3.5"}, "one\nTWO\nTHREE\n3.5\nfour\nfive\n"},
274 {"lib.txt", 5, 6, nil, "one\nTWO\nTHREE\n3.5\n"},
275 {"tail.txt", 2, 2, []string{"LAST", "more"}, "x\nLAST\nmore"},
276 {"dos.txt", 2, 2, []string{"B", "B2"}, "a\r\nB\r\nB2\r\nc\r\n"},
277 }
278 for _, c := range cases {
279 id := f.suggest(f.alice, c.path, c.start, c.end, c.lines...)
280 sha := f.applyOK(f.alice, id)
281 if got := f.file(sha, c.path); got != c.want {
282 t.Errorf("%s %d-%d: file = %q, want %q", c.path, c.start, c.end, got, c.want)
283 }
284 }
285}
286
287// A suggestion whose lines changed, or whose file is gone, is refused;
288// so is one on a repository requiring signed commits, with the command
289// that applies it locally.
290func TestApplySuggestionRefusals(t *testing.T) {
291 f := newSuggestFixture(t, nil)
292 f.verified(f.alice)
293 stale := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
294 gone := f.suggest(f.alice, "tail.txt", 1, 1, "X")
295 plain := f.suggest(f.alice, "lib.txt", 2, 2, "two")
296 f.write("lib.txt", "uno\ntwo\nthree\nfour\nfive\n")
297 f.git(f.src, "rm", "-q", "tail.txt")
298 f.git(f.src, "commit", "-q", "-am", "moved on")
299 f.moveHead()
300 cases := []struct {
301 thread string
302 code int
303 want string
304 }{
305 {stale, protocol.ExitUsage, "outdated: " + reasonChanged},
306 {gone, protocol.ExitUsage, reasonGone},
307 {plain, protocol.ExitUsage, "changes nothing"},
308 {"999", protocol.ExitNotFound, "no thread 999"},
309 }
310 for _, c := range cases {
311 code, _, errOut := f.runWith(f.alice, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", c.thread)
312 if code != c.code || !strings.Contains(errOut, c.want) {
313 t.Errorf("thread %s: exit %d %q, want %d with %q", c.thread, code, errOut, c.code, c.want)
314 }
315 }
316
317 if _, err := f.st.UpdateRepoSettings(f.repo.ID, func(s *store.RepoSettings) { s.RequireSignedCommits = true }); err != nil {
318 t.Fatal(err)
319 }
320 ok := f.suggest(f.alice, "lib.txt", 2, 2, "TWO")
321 code, _, errOut := f.runWith(f.alice, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", ok)
322 if code != protocol.ExitDenied || !strings.Contains(errOut, "gitbay mr apply-suggestion alice/app 1 "+ok) {
323 t.Fatalf("signed repo: exit %d %q", code, errOut)
324 }
325}
326
327// The update is held to the pre-receive ref policy a push is: a source
328// branch that is protected under require-mr refuses it.
329func TestApplySuggestionHonoursRefPolicy(t *testing.T) {
330 f := newSuggestFixture(t, func(s *store.RepoSettings) {
331 s.ProtectedBranches = []string{"feature"}
332 s.RequireMR = true
333 })
334 f.verified(f.alice)
335 id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
336 before := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature"))
337 code, _, errOut := f.runWith(f.alice, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", id)
338 if code != protocol.ExitDenied || !strings.Contains(errOut, "merge requests only") {
339 t.Fatalf("exit %d %q, want the require-mr refusal", code, errOut)
340 }
341 if after := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature")); after != before {
342 t.Fatal("refused apply moved the branch")
343 }
344}
345
346// Only the source branch's writers apply: a reader cannot. A thread in
347// an unsubmitted review is not applied, and to anyone but its author it
348// does not exist.
349func TestApplySuggestionNeedsWrite(t *testing.T) {
350 f := newSuggestFixture(t, nil)
351 carol := f.user("carol", "read")
352 f.verified(carol)
353 id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
354 code, _, errOut := f.runWith(carol, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", id)
355 if code != protocol.ExitDenied || !strings.Contains(errOut, "only its writers") {
356 t.Fatalf("reader: exit %d %q", code, errOut)
357 }
358
359 var out, stderr strings.Builder
360 c := &Ctx{User: f.alice, Scope: "full", Store: f.st, Stdout: &out, Stderr: &stderr, JSON: true,
361 Stdin: strings.NewReader("```suggestion\nONE\n```\n")}
362 c.Cfg.Server.Root = f.root
363 c.Cfg.Limits.WriteRate = -1
364 if code := Dispatch(c, []string{"mr", "diff-comment", f.repo.Path(), "1", "--path", "lib.txt", "--line", "1", "--pending", "--file", "-"}); code != protocol.ExitOK {
365 t.Fatalf("pending diff-comment: %s", stderr.String())
366 }
367 var env struct {
368 Data struct {
369 Thread int64 `json:"thread"`
370 } `json:"data"`
371 }
372 json.Unmarshal([]byte(out.String()), &env)
373 pending := strconv.Itoa(int(env.Data.Thread))
374 if code, _, errOut := f.runWith(f.alice, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", pending); code != protocol.ExitUsage || !strings.Contains(errOut, "unsubmitted") {
375 t.Errorf("own pending thread: exit %d %q", code, errOut)
376 }
377 if code, _, _ := f.runWith(carol, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", pending); code != protocol.ExitNotFound {
378 t.Errorf("someone else's pending thread: exit %d, want not found", code)
379 }
380}
381
382// Applying is a push by the applier, so a queued merge sees it: a
383// writer's apply keeps the queue, and resolving the thread it came from
384// lets the queued merge land on the new head.
385func TestApplySuggestionReachesQueuedMerge(t *testing.T) {
386 f := newSuggestFixture(t, func(s *store.RepoSettings) { s.RequireResolved = true })
387 f.verified(f.alice)
388 id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
389 f.mustWrite(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
390 f.wantQueued("threads resolved")
391 sha := f.applyOK(f.alice, id)
392 f.wantMergedBy("alice")
393 if main := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/main")); main != sha {
394 t.Fatalf("main = %s, want the applied commit %s", main, sha)
395 }
396}
397
398// On a merge request from a fork the source branch is the fork's, so its
399// writers apply and the target's do not. The fork writer's apply is a
400// push by someone who cannot merge into the target, which dequeues a
401// merge queued there.
402func TestApplySuggestionFork(t *testing.T) {
403 f := newSuggestFixture(t, nil)
404 bobID, err := f.st.CreateUser("bob", false)
405 if err != nil {
406 t.Fatal(err)
407 }
408 bob, _ := f.st.UserByID(bobID)
409 f.verified(f.alice)
410 f.verified(bob)
411 forkID, err := f.st.CreateRepo("user", bobID, "app", "public")
412 if err != nil {
413 t.Fatal(err)
414 }
415 fork, _ := f.st.RepoByID(forkID)
416 forkDir := RepoDir(f.root, fork.OwnerName, fork.Name)
417 f.git(f.root, "clone", "-q", "--bare", f.src, forkDir)
418 f.git(f.dir, "update-ref", mrHeadRef(2), f.headSHA)
419 if _, err := f.st.CreateMR(f.repo.ID, bobID, forkID, "feature", "main", "forked", "", f.headSHA, "md", false); err != nil {
420 t.Fatal(err)
421 }
422 var out, errOut strings.Builder
423 c := &Ctx{User: f.alice, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut, JSON: true,
424 Stdin: strings.NewReader("```suggestion\nONE\n```\n")}
425 c.Cfg.Server.Root = f.root
426 c.Cfg.Limits.WriteRate = -1
427 if code := Dispatch(c, []string{"mr", "diff-comment", f.repo.Path(), "2", "--path", "lib.txt", "--line", "1", "--file", "-"}); code != protocol.ExitOK {
428 t.Fatalf("diff-comment: %s", errOut.String())
429 }
430 var env struct {
431 Data struct {
432 Thread int64 `json:"thread"`
433 } `json:"data"`
434 }
435 json.Unmarshal([]byte(out.String()), &env)
436 thread := strconv.Itoa(int(env.Data.Thread))
437
438 code, _, stderr := f.runWith(f.alice, unlimited, "mr", "apply-suggestion", f.repo.Path(), "2", thread)
439 if code != protocol.ExitDenied || !strings.Contains(stderr, "bob/app:feature") {
440 t.Fatalf("target owner on a fork's branch: exit %d %q", code, stderr)
441 }
442
443 if _, err := f.st.UpdateRepoSettings(f.repo.ID, func(s *store.RepoSettings) { s.RequireApprovals = 1 }); err != nil {
444 t.Fatal(err)
445 }
446 f.mustWrite(f.alice, "mr", "merge", f.repo.Path(), "2", "--when-ready", "--strategy", "merge")
447 f.mustWrite(bob, "mr", "apply-suggestion", f.repo.Path(), "2", thread)
448 tip := strings.TrimSpace(f.git(forkDir, "rev-parse", "refs/heads/feature"))
449 if got := f.git(forkDir, "show", tip+":lib.txt"); !strings.HasPrefix(got, "ONE\ntwo\n") {
450 t.Fatalf("fork's lib.txt = %q", got)
451 }
452 mr, _ := f.st.MRByNumber(f.repo.ID, 2)
453 if mr.HeadSHA != tip {
454 t.Fatalf("MR head = %s, want the fork's new tip %s", mr.HeadSHA, tip)
455 }
456 if mr.QueuedAt != "" || mr.State != "open" {
457 t.Fatalf("queued merge after the fork writer's apply: state %s queued %q", mr.State, mr.QueuedAt)
458 }
459 cs, _ := f.st.ListMRComments(mr.ID)
460 said := false
461 for _, c := range cs {
462 said = said || (c.Kind == "system" && strings.Contains(c.Body, "bob pushed and cannot merge"))
463 }
464 if !said {
465 t.Fatalf("timeline does not say why the merge was dequeued: %+v", cs)
466 }
467 // carol writes to the fork and is neither the thread's author, the
468 // merge request's, nor a writer of the target: her apply lands and
469 // leaves the thread open, saying so.
470 carolID, _ := f.st.CreateUser("carol", false)
471 carol, _ := f.st.UserByID(carolID)
472 f.verified(carol)
473 if err := f.st.GrantAccess(forkID, carolID, "write"); err != nil {
474 t.Fatal(err)
475 }
476 out.Reset()
477 errOut.Reset()
478 c.Stdin = strings.NewReader("```suggestion\nTWO\n```\n")
479 if code := Dispatch(c, []string{"mr", "diff-comment", f.repo.Path(), "2", "--path", "lib.txt", "--line", "2", "--file", "-"}); code != protocol.ExitOK {
480 t.Fatalf("diff-comment: %s", errOut.String())
481 }
482 json.Unmarshal([]byte(out.String()), &env)
483 second := strconv.Itoa(int(env.Data.Thread))
484 stdout := f.mustWrite(carol, "mr", "apply-suggestion", f.repo.Path(), "2", second, "--json")
485 if !strings.Contains(stdout, `"resolved":false`) || !strings.Contains(stdout, "still open") {
486 t.Fatalf("carol's apply = %s, want it applied and the thread left open", stdout)
487 }
488 if n, _ := f.st.UnresolvedThreadCount(mr.ID); n != 1 {
489 t.Fatalf("unresolved threads = %d, want carol's left open", n)
490 }
491}
492
493// Reading suggestions costs git processes per file and commit, not per
494// thread: six suggestions on one file read it as one does, whether or
495// not the blobs fit the cache.
496func TestSuggestionsProcessCountPerFile(t *testing.T) {
497 f := newSuggestFixture(t, nil)
498 spawnedWith := func(cacheCap int64) int {
499 t.Helper()
500 comments, err := f.st.ListDiffComments(f.mr().ID, f.alice.ID)
501 if err != nil {
502 t.Fatal(err)
503 }
504 files := newAnchoredFiles(f.dir)
505 files.cacheCap = cacheCap
506 defer files.close()
507 got := suggestionsWith(files, func() bool { return false }, f.mr(), comments)
508 for _, s := range got {
509 if s.Outdated {
510 t.Fatalf("suggestion outdated: %+v", s)
511 }
512 }
513 if files.cached > cacheCap {
514 t.Fatalf("cached %d bytes past a cap of %d", files.cached, cacheCap)
515 }
516 return files.spawned
517 }
518 spawned := func() int { return spawnedWith(anchoredCacheCap) }
519 f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
520 one := spawned()
521 for i := 2; i <= 5; i++ {
522 f.suggest(f.alice, "lib.txt", i, i, "X")
523 }
524 f.suggest(f.alice, "lib.txt", 1, 2, "Y")
525 if six := spawned(); six != one || one > 2 {
526 t.Fatalf("git processes: %d for one suggestion, %d for six on the same file", one, six)
527 }
528 // With no room to cache, blobs are read again through the same
529 // process, and every suggestion still reads right.
530 if none := spawnedWith(0); none != one {
531 t.Fatalf("git processes with no cache: %d, want %d", none, one)
532 }
533}
534
535// A server-side write stops at the owner's storage quota as a push does:
536// both apply-suggestion and repo commit-file.
537func TestServerWritesHonourStorageQuota(t *testing.T) {
538 f := newSuggestFixture(t, nil)
539 f.verified(f.alice)
540 id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
541 full := func(c *Ctx) {
542 c.Cfg.Limits.WriteRate = -1
543 c.Cfg.Limits.MaxBytesPerUser = 1
544 }
545 before := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature"))
546 code, _, errOut := f.runWith(f.alice, full, "mr", "apply-suggestion", f.repo.Path(), "1", id)
547 if code != protocol.ExitDenied || !strings.Contains(errOut, "storage quota is used up") {
548 t.Errorf("apply-suggestion over quota: exit %d %q", code, errOut)
549 }
550 code, _, errOut = f.runWith(f.alice, func(c *Ctx) { full(c); c.Stdin = strings.NewReader("x\n") },
551 "repo", "commit-file", f.repo.Path(), "new.txt", "--ref", "feature", "--file", "-")
552 if code != protocol.ExitDenied || !strings.Contains(errOut, "storage quota is used up") {
553 t.Errorf("commit-file over quota: exit %d %q", code, errOut)
554 }
555 if after := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature")); after != before {
556 t.Fatal("a refused write moved the branch")
557 }
558 // Under the quota both go through.
559 f.mustWrite(f.alice, "mr", "apply-suggestion", f.repo.Path(), "1", id)
560 code, _, errOut = f.runWith(f.alice, func(c *Ctx) { unlimited(c); c.Stdin = strings.NewReader("x\n") },
561 "repo", "commit-file", f.repo.Path(), "new.txt", "--ref", "feature", "--file", "-")
562 if code != protocol.ExitOK {
563 t.Errorf("commit-file under quota: exit %d %q", code, errOut)
564 }
565}
internal/gitutil/batch.go added +81
@@ -0,0 +1,81 @@
1package gitutil
2
3import (
4 "bufio"
5 "fmt"
6 "io"
7 "os/exec"
8 "strconv"
9 "strings"
10
11 "gitbay.org/gitbay/internal/toolpath"
12)
13
14// BlobBatch reads blobs by object id through one `git cat-file --batch`
15// process, for a caller reading many blobs in one request.
16type BlobBatch struct {
17 cmd *exec.Cmd
18 in io.WriteCloser
19 out *bufio.Reader
20}
21
22// NewBlobBatch starts the cat-file process in dir. Close ends it.
23func NewBlobBatch(dir string) (*BlobBatch, error) {
24 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "cat-file", "--batch")
25 in, err := cmd.StdinPipe()
26 if err != nil {
27 return nil, err
28 }
29 out, err := cmd.StdoutPipe()
30 if err != nil {
31 return nil, err
32 }
33 if err := cmd.Start(); err != nil {
34 return nil, err
35 }
36 return &BlobBatch{cmd: cmd, in: in, out: bufio.NewReader(out)}, nil
37}
38
39// Read returns the blob oid, refusing one larger than limit bytes.
40func (b *BlobBatch) Read(oid string, limit int64) ([]byte, error) {
41 if strings.ContainsAny(oid, " \n") {
42 return nil, fmt.Errorf("bad object id %q", oid)
43 }
44 if _, err := io.WriteString(b.in, oid+"\n"); err != nil {
45 return nil, err
46 }
47 head, err := b.out.ReadString('\n')
48 if err != nil {
49 return nil, err
50 }
51 f := strings.Fields(head)
52 if len(f) != 3 {
53 return nil, fmt.Errorf("cat-file %s: %s", oid, strings.TrimSpace(head))
54 }
55 size, err := strconv.ParseInt(f[2], 10, 64)
56 if err != nil {
57 return nil, fmt.Errorf("cat-file %s: %s", oid, strings.TrimSpace(head))
58 }
59 // A refused object is still on the stream, with its trailing
60 // newline; skipping it keeps the next Read in step.
61 if f[1] != "blob" || size > limit {
62 if _, err := io.CopyN(io.Discard, b.out, size+1); err != nil {
63 return nil, err
64 }
65 if f[1] != "blob" {
66 return nil, fmt.Errorf("%s is a %s, not a blob", oid, f[1])
67 }
68 return nil, fmt.Errorf("%s is larger than %d bytes", oid, limit)
69 }
70 data := make([]byte, size+1) // the object and its trailing newline
71 if _, err := io.ReadFull(b.out, data); err != nil {
72 return nil, err
73 }
74 return data[:size], nil
75}
76
77// Close ends the process.
78func (b *BlobBatch) Close() error {
79 b.in.Close()
80 return b.cmd.Wait()
81}
internal/gitutil/batch_test.go added +53
@@ -0,0 +1,53 @@
1package gitutil
2
3import (
4 "os"
5 "os/exec"
6 "strings"
7 "testing"
8)
9
10func TestBlobBatch(t *testing.T) {
11 dir := t.TempDir()
12 run := func(stdin string, args ...string) string {
13 t.Helper()
14 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
15 cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1")
16 cmd.Stdin = strings.NewReader(stdin)
17 out, err := cmd.CombinedOutput()
18 if err != nil {
19 t.Fatalf("git %v: %v\n%s", args, err, out)
20 }
21 return strings.TrimSpace(string(out))
22 }
23 run("", "init", "-q", "--bare")
24 a := run("one\ntwo", "hash-object", "-w", "--stdin")
25 b := run("", "hash-object", "-w", "--stdin")
26 tree := run("100644 blob "+a+"\tf\n", "mktree")
27 batch, err := NewBlobBatch(dir)
28 if err != nil {
29 t.Fatal(err)
30 }
31 defer batch.Close()
32 for _, c := range []struct{ oid, want string }{{a, "one\ntwo"}, {b, ""}, {a, "one\ntwo"}} {
33 got, err := batch.Read(c.oid, 100)
34 if err != nil || string(got) != c.want {
35 t.Fatalf("Read(%s) = %q, %v; want %q", c.oid, got, err, c.want)
36 }
37 }
38 if _, err := batch.Read(a, 3); err == nil {
39 t.Error("a blob over the limit was read")
40 }
41 if got, err := batch.Read(b, 100); err != nil || string(got) != "" {
42 t.Fatalf("read after a blob over the limit = %q, %v", got, err)
43 }
44 if _, err := batch.Read(tree, 100); err == nil {
45 t.Error("a tree was read as a blob")
46 }
47 if _, err := batch.Read(strings.Repeat("0", 40), 100); err == nil {
48 t.Error("a missing object was read")
49 }
50 if got, err := batch.Read(a, 100); err != nil || string(got) != "one\ntwo" {
51 t.Fatalf("read after refusals = %q, %v", got, err)
52 }
53}
internal/gitutil/merge.go +17 −9
@@ -211,6 +211,21 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes
211211 parent = ""
212212 }
213213
214 sha, err := CommitWithFile(dir, parent, path, "100644", content, name, email, message)
215 if err != nil {
216 return "", err
217 }
218 if err := UpdateRefCAS(dir, branchRef, sha, parent); err != nil {
219 return "", fmt.Errorf("branch moved during edit; reload and retry: %w", err)
220 }
221 return sha, nil
222}
223
224// CommitWithFile writes a commit on parent ("" for a root commit) whose
225// tree is parent's with content at path, as a file of mode (100644 or
226// 100755), authored and committed as name <email>. No ref moves: the
227// caller updates one, and enforces policy, since no hook runs.
228func CommitWithFile(dir, parent, path, mode string, content []byte, name, email, message string) (string, error) {
214229 // Hash the new blob.
215230 hb := exec.Command(toolpath.Look("git"), "-C", dir, "hash-object", "-w", "--stdin")
216231 hb.Stdin = strings.NewReader(string(content))
@@ -239,7 +254,7 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes
239254 if out, err := rt.CombinedOutput(); err != nil {
240255 return "", fmt.Errorf("read-tree: %v\n%s", err, out)
241256 }
242 ui := exec.Command(toolpath.Look("git"), "-C", dir, "update-index", "--add", "--cacheinfo", "100644,"+blob+","+path)
257 ui := exec.Command(toolpath.Look("git"), "-C", dir, "update-index", "--add", "--cacheinfo", mode+","+blob+","+path)
243258 ui.Env = env
244259 if out, err := ui.CombinedOutput(); err != nil {
245260 return "", fmt.Errorf("update-index: %v\n%s", err, out)
@@ -256,14 +271,7 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes
256271 if parent != "" {
257272 parents = []string{parent}
258273 }
259 sha, err := CommitTree(dir, tree, parents, name, email, message)
260 if err != nil {
261 return "", err
262 }
263 if err := UpdateRefCAS(dir, branchRef, sha, parent); err != nil {
264 return "", fmt.Errorf("branch moved during edit; reload and retry: %w", err)
265 }
266 return sha, nil
274 return CommitTree(dir, tree, parents, name, email, message)
267275}
268276
269277// isEmptyRepo reports whether dir has no refs at all — a repository
internal/hookd/hookd.go +3 −200
@@ -15,12 +15,9 @@ import (
1515 "log/slog"
1616 "net"
1717 "os"
18 "path"
1918 "path/filepath"
2019 "strings"
21 "time"
2220
23 "gitbay.org/gitbay/internal/ci"
2421 "gitbay.org/gitbay/internal/config"
2522 "gitbay.org/gitbay/internal/control"
2623 "gitbay.org/gitbay/internal/gitutil"
@@ -275,204 +272,10 @@ func (s *Server) releaseAnchors(repo store.Repo, updates []policy.RefUpdate) str
275272 return ""
276273}
277274
278// postReceive applies the cross-repo MR effect: a push to a source branch
279// refreshes refs/merge-requests/N/head in every target repo, by fetching —
280// the target owns the objects, so the MR outlives the fork. This is the only
281// place a hook writes outside its own repository.
275// postReceive runs the ref-update work for a push; see
276// control.RefsUpdated, which server-side writes to a branch share.
282277func (s *Server) postReceive(req Request) {
283 pushedRepo, pushedRepoErr := s.st.RepoByID(req.RepoID)
284 if pushedRepoErr == nil {
285 s.adoptDefaultBranch(&pushedRepo, req.Updates)
286 }
287 for _, u := range req.Updates {
288 // Every ref update is an event webhooks can subscribe to.
289 s.st.RecordEvent(req.RepoID, req.UserID, "push", fmt.Sprintf(
290 `{"ref":%q,"old":%q,"new":%q,"forced":%v,"deleted":%v}`,
291 u.Ref, u.Old, u.New, u.IsForce, u.IsDelete))
292
293 // Any ref update — branch or tag — schedules the push mirrors.
294 s.st.MarkMirrorsDirty(req.RepoID, "push")
295
296 // Tag pushes run the tag-triggered CI jobs.
297 if tag, ok := strings.CutPrefix(u.Ref, "refs/tags/"); ok && !u.IsDelete && pushedRepoErr == nil {
298 s.queueTagBuilds(pushedRepo, req.UserID, tag, u.New)
299 }
300
301 branch, ok := cutHeads(u.Ref)
302 if !ok {
303 continue
304 }
305 // Commits landing on the default branch act on issue references
306 // in their messages (closes #N, plain #N).
307 if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete {
308 dir := control.RepoDir(s.cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name)
309 control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, req.Scope, u.Old, u.New)
310 control.RecordLandedCommits(s.st, dir, pushedRepo, u.Old, u.New)
311 }
312 // A branch push with a .gitbay/ci.yml queues one build per job.
313 if pushedRepoErr == nil && !u.IsDelete {
314 s.queueBuilds(pushedRepo, req.UserID, branch, u.Old, u.New)
315 }
316 if u.IsForce {
317 s.st.Audit(req.UserID, "push.forced", map[string]any{
318 "repo": req.RepoID, "ref": u.Ref, "old": u.Old, "new": u.New})
319 }
320 mrs, err := s.st.OpenMRsBySource(req.RepoID, branch)
321 if err != nil {
322 slog.Error("post-receive: listing MRs", "err", err)
323 continue
324 }
325 srcRepo, err := s.st.RepoByID(req.RepoID)
326 if err != nil {
327 continue
328 }
329 srcDir := control.RepoDir(s.cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
330 for _, mr := range mrs {
331 target, err := s.st.RepoByID(mr.RepoID)
332 if err != nil {
333 continue
334 }
335 if u.IsDelete {
336 if mr.State == "open" {
337 s.st.SetMRState(mr.ID, "source_gone")
338 }
339 if mr.QueuedAt != "" {
340 control.TryQueuedMerge(s.st, s.cfg, mr.ID) // dequeues: the source is gone
341 }
342 continue // head ref retained: the diff stays viewable
343 }
344 dstDir := control.RepoDir(s.cfg.Server.Root, target.OwnerName, target.Name)
345 headRef := fmt.Sprintf("refs/merge-requests/%d/head", mr.Number)
346 if err := gitutil.FetchInto(dstDir, srcDir, u.New, headRef); err != nil {
347 slog.Error("post-receive: refreshing MR head", "mr", mr.Number, "err", err)
348 continue
349 }
350 // The merge base as it stands now, so a later range-diff
351 // compares each revision against the target it was written
352 // on rather than against today's. Best-effort: a base that
353 // cannot be worked out costs precision, not the record.
354 base, err := gitutil.MergeBase(dstDir, "refs/heads/"+mr.TargetRef, headRef)
355 if err != nil {
356 base = ""
357 }
358 if err := s.st.UpdateMRHead(mr.ID, u.New, base, sameChange(dstDir, mr, base, u.New)); err != nil {
359 slog.Error("post-receive: recording MR head", "mr", mr.Number, "err", err)
360 }
361 if srcRepo.ID != target.ID {
362 control.QueueMRBuilds(s.st, s.cfg.Server.Root, s.cfg.Server.SiteURL,
363 target, req.UserID, mr.Number, u.New)
364 }
365 if mr.State == "source_gone" {
366 s.st.SetMRState(mr.ID, "open") // branch came back
367 }
368 // A queued merge stays queued across a push by someone who can
369 // merge it, and the new head has to pass the gates on its own.
370 if mr.QueuedAt != "" {
371 control.QueuedMergePushed(s.st, s.cfg, mr.ID, req.UserID, req.Scope)
372 }
373 }
374 }
375}
376
377// adoptDefaultBranch moves an unborn HEAD to the first branch a push
378// creates. A repository is initialised with HEAD at the stored default,
379// and a first push of master or trunk left HEAD naming a branch that did
380// not exist: clones checked out nothing and every surface asked git for
381// a branch that was not there (#189). A push that includes the default
382// branch itself needs nothing.
383func (s *Server) adoptDefaultBranch(repo *store.Repo, updates []policy.RefUpdate) {
384 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
385 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch); err == nil {
386 return
387 }
388 for _, u := range updates {
389 branch, ok := cutHeads(u.Ref)
390 if !ok || u.IsDelete || !gitutil.ZeroSHA(u.Old) {
391 continue
392 }
393 if err := gitutil.SetHead(dir, branch); err != nil {
394 slog.Error("post-receive: moving HEAD", "repo", repo.Path(), "err", err)
395 return
396 }
397 if err := s.st.UpdateDefaultBranch(repo.ID, branch); err != nil {
398 slog.Error("post-receive: recording default branch", "repo", repo.Path(), "err", err)
399 return
400 }
401 repo.DefaultBranch = branch
402 return
403 }
404}
405
406// sameChange reports whether the new head proposes the diff the old one
407// did: the patch-id of each revision against its own merge base. A
408// rebase onto a moved target changes every sha and nothing about the
409// change, and the reviews of it should not go stale for that (#198).
410// Any doubt answers false, which is the old behaviour.
411func sameChange(dir string, mr store.MR, newBase, newHead string) bool {
412 if mr.HeadSHA == "" || newBase == "" || mr.HeadSHA == newHead {
413 return false
414 }
415 oldBase, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mr.HeadSHA)
416 if err != nil {
417 return false
418 }
419 oldID, err := gitutil.PatchID(dir, oldBase, mr.HeadSHA)
420 if err != nil || oldID == "" {
421 return false
422 }
423 newID, err := gitutil.PatchID(dir, newBase, newHead)
424 return err == nil && newID == oldID
425}
426
427// queueBuilds queues the push jobs for a branch update. The work is
428// shared with the merge path, which moves a ref without reaching a hook.
429func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, old, sha string) {
430 control.QueueBranchBuilds(
431 s.st, s.cfg.Server.Root, s.cfg.Server.SiteURL,
432 repo, userID, branch, old, sha, time.Now())
433}
434
435// queueTagBuilds runs the jobs whose tag pattern matches a pushed tag.
436// The build records the tag as its ref and the peeled commit as its sha,
437// so statuses land on the commit, not an annotated tag object.
438func (s *Server) queueTagBuilds(repo store.Repo, userID int64, tag, pushed string) {
439 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
440 sha, err := gitutil.PeelToCommit(dir, pushed)
441 if err != nil {
442 return
443 }
444 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
445 if err != nil {
446 return
447 }
448 jobs, err := ci.Parse(raw)
449 if err != nil {
450 return // the branch push already reported ci/config
451 }
452 for _, j := range jobs {
453 if j.Tags == "" {
454 continue
455 }
456 if ok, _ := path.Match(j.Tags, tag); !ok {
457 continue
458 }
459 steps, _ := json.Marshal(j.Steps)
460 n, err := s.st.CreateBuild(repo.ID, j.Name, sha, tag, string(steps), j.Image, "", true)
461 if err != nil {
462 slog.Error("queueing tag build", "repo", repo.Path(), "job", j.Name, "err", err)
463 continue
464 }
465 url := fmt.Sprintf("%s/%s/builds/%d", s.cfg.Server.SiteURL, repo.Path(), n)
466 s.st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "tag "+tag, url, userID)
467 }
468}
469
470func cutHeads(ref string) (string, bool) {
471 const p = "refs/heads/"
472 if len(ref) > len(p) && ref[:len(p)] == p {
473 return ref[len(p):], true
474 }
475 return "", false
278 control.RefsUpdated(s.st, s.cfg, req.RepoID, req.UserID, req.Scope, req.Updates)
476279}
477280
478281// Ask sends one request from the hook process to the daemon. stream is
internal/hookd/pushshapes_test.go +1 −1
@@ -354,7 +354,7 @@ var pushShapes = []pushShape{
354354 f.git(f.src, "tag", "v1")
355355 f.sync()
356356 f.mark(f.base)
357 f.srv.queueTagBuilds(f.repo, f.uid, "v1", f.base)
357 control.QueueTagBuilds(f.st, f.srv.cfg, f.repo, f.uid, "v1", f.base)
358358 }, []string{"—", "—", "—", "—", "queued", "—"}},
359359
360360 {"schedule tick on the default branch", func(f *shapeFixture) {
internal/httpd/mractions.go +18
@@ -140,6 +140,13 @@ func (s *Server) mrDiffCommentSubmit(w http.ResponseWriter, r *http.Request, u s
140140 return
141141 }
142142 extra = []string{"--path", path, "--line", line}
143 if start := strings.TrimSpace(r.FormValue("start_line")); start != "" {
144 if n, err := strconv.ParseInt(start, 10, 64); err != nil || n < 1 {
145 s.mrDiffRedirect(w, r, "the first line is a line number")
146 return
147 }
148 extra = append(extra, "--start-line", start)
149 }
143150 if r.FormValue("side") == "old" {
144151 extra = append(extra, "--old")
145152 }
@@ -179,6 +186,17 @@ func (s *Server) mrThreadSubmit(w http.ResponseWriter, r *http.Request, u store.
179186 s.done(w, r, code, msg, s.mrRedirect)
180187}
181188
189// mrSuggestionSubmit commits a thread's suggestion to the source branch.
190func (s *Server) mrSuggestionSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
191 id := strings.TrimSpace(r.FormValue("thread"))
192 if _, err := strconv.ParseInt(id, 10, 64); err != nil {
193 s.mrDiffRedirect(w, r, "bad thread id")
194 return
195 }
196 _, msg, code := s.runControlCode(u, mrArgs(r, "apply-suggestion", id))
197 s.done(w, r, code, msg, s.mrDiffRedirect)
198}
199
182200// mrNewPage is the create form: branches to choose from, plus whatever
183201// the last attempt had in it so a refusal does not lose the draft.
184202type mrNewPage struct {
internal/httpd/routes.go +2
@@ -225,6 +225,8 @@ func (s *Server) Routes() []Route {
225225 Handler: s.checkOrigin(s.requireUser(s.mrThreadSubmit))},
226226 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/diff-comment", Mutating: true,
227227 Handler: s.checkOrigin(s.requireUser(s.mrDiffCommentSubmit))},
228 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/suggestion", Mutating: true,
229 Handler: s.checkOrigin(s.requireUser(s.mrSuggestionSubmit))},
228230 Route{Method: "GET", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}",
229231 Handler: s.requireUser(s.editForm)},
230232 Route{Method: "POST", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", Mutating: true,
internal/httpd/suggestion_test.go added +74
@@ -0,0 +1,74 @@
1package httpd
2
3import (
4 "html/template"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/control"
9 "gitbay.org/gitbay/internal/store"
10 "gitbay.org/gitbay/internal/web"
11)
12
13func renderSuggestion(t *testing.T, sg *control.SuggestionOut, canApply bool) string {
14 t.Helper()
15 view := newSuggestionView(sg, canApply, "gitbay mr apply-suggestion alice/app 1 7")
16 var sb strings.Builder
17 if err := web.Render(&sb, "mr.html", mrPageData{
18 repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
19 DetachedThreads: []diffThread{{ID: 7, Suggestion: view,
20 Comments: []renderedComment{{Author: "bob", BodyHTML: template.HTML("<p>try</p>")}}}},
21 }); err != nil {
22 t.Fatalf("render: %v", err)
23 }
24 return sb.String()
25}
26
27// A suggestion renders as the lines it replaces and the ones it proposes,
28// with a button for someone who can push to the source branch.
29func TestSuggestionRendersAsDiff(t *testing.T) {
30 sg := &control.SuggestionOut{StartLine: 4, EndLine: 5, Original: "old a\r\nold b\r\n",
31 Replacement: "new a\n", Apply: "server"}
32 out := renderSuggestion(t, sg, true)
33 for _, w := range []string{
34 `<tr class="del"><td class="ln">4</td><td class="src">old a</td>`,
35 `<tr class="del"><td class="ln">5</td><td class="src">old b</td>`,
36 `<tr class="add"><td class="ln">4</td><td class="src">new a</td>`,
37 `action="/krz/gitbay/mrs/42/suggestion"`, `name="thread" value="7"`, "Apply suggestion",
38 } {
39 if !strings.Contains(out, w) {
40 t.Errorf("page lacks %q", w)
41 }
42 }
43 if out := renderSuggestion(t, sg, false); strings.Contains(out, "Apply suggestion") {
44 t.Error("apply button shown to someone who cannot push to the source branch")
45 }
46}
47
48// Where the server cannot sign, the page gives the command; an outdated
49// suggestion says why and offers neither.
50func TestSuggestionLocalAndOutdated(t *testing.T) {
51 local := renderSuggestion(t, &control.SuggestionOut{StartLine: 1, EndLine: 1, Original: "a\n",
52 Replacement: "b\n", Apply: "local"}, true)
53 if !strings.Contains(local, "<code>gitbay mr apply-suggestion alice/app 1 7</code>") || strings.Contains(local, "Apply suggestion</button>") {
54 t.Error("require-signed suggestion does not give the CLI command in place of the button")
55 }
56 stale := renderSuggestion(t, &control.SuggestionOut{StartLine: 1, EndLine: 1, Original: "a\n",
57 Replacement: "b\n", Apply: "server", Outdated: true, Reason: "the lines it replaces have changed"}, true)
58 if !strings.Contains(stale, "outdated suggestion: the lines it replaces have changed") || strings.Contains(stale, "Apply suggestion</button>") {
59 t.Error("outdated suggestion is not marked, or still offers the button")
60 }
61}
62
63// The thread body renders without the raw block the diff stands in for.
64func TestAttachThreadsStripsSuggestionBlock(t *testing.T) {
65 md := func(src, _ string) template.HTML { return template.HTML(src) }
66 cm := store.DiffComment{ID: 3, Author: "bob", HeadSHA: "h", Path: "a.go", Side: "new", Line: 2,
67 Body: "try this\n```suggestion\nx\n```\n"}
68 view := &suggestionView{}
69 _, detached := attachThreads(nil, []store.DiffComment{cm}, "h", md, reviewRights{},
70 map[int64]*suggestionView{3: view})
71 if len(detached) != 1 || detached[0].Suggestion != view || string(detached[0].Comments[0].BodyHTML) != "try this" {
72 t.Fatalf("thread = %+v", detached)
73 }
74}
internal/httpd/web.go +61 −4
@@ -39,6 +39,7 @@ import (
3939 "gitbay.org/gitbay/internal/gitutil"
4040 "gitbay.org/gitbay/internal/sig"
4141 "gitbay.org/gitbay/internal/store"
42 "gitbay.org/gitbay/internal/suggest"
4243 "gitbay.org/gitbay/internal/web"
4344)
4445
@@ -1494,6 +1495,38 @@ type diffThread struct {
14941495 Pending bool
14951496 CanResolve bool
14961497 Comments []renderedComment
1498 Suggestion *suggestionView
1499}
1500
1501// suggestionView is a thread's suggestion as the page shows it: the lines
1502// it replaces and the ones it proposes, numbered from Start, and whether
1503// the viewer can apply it here or needs the CLI.
1504type suggestionView struct {
1505 Start int64
1506 Old, New []suggestionLine
1507 Outdated bool
1508 Reason string
1509 Local bool // the repositories require signed commits: apply from a clone
1510 CanApply bool // the viewer can push to the source branch of an open MR
1511 Command string // the CLI command that applies it
1512}
1513
1514type suggestionLine struct {
1515 N int64
1516 Text string
1517}
1518
1519// newSuggestionView lays out s for the page.
1520func newSuggestionView(s *control.SuggestionOut, canApply bool, command string) *suggestionView {
1521 v := &suggestionView{Start: s.StartLine, Outdated: s.Outdated, Reason: s.Reason,
1522 Local: s.Apply == "local", CanApply: canApply, Command: command}
1523 for i, l := range suggest.FromText(strings.ReplaceAll(s.Original, "\r\n", "\n")) {
1524 v.Old = append(v.Old, suggestionLine{s.StartLine + int64(i), l})
1525 }
1526 for i, l := range suggest.FromText(s.Replacement) {
1527 v.New = append(v.New, suggestionLine{s.StartLine + int64(i), l})
1528 }
1529 return v
14971530}
14981531
14991532// reviewRights decides which thread controls a viewer sees. mr resolve
@@ -1511,8 +1544,10 @@ func (r reviewRights) canResolve(threadAuthor string) bool {
15111544
15121545// attachThreads injects review threads under their anchored diff lines;
15131546// threads whose anchor no longer appears (stale after force-push, or on a
1514// context line outside the current diff) are returned separately.
1515func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) {
1547// context line outside the current diff) are returned separately. A
1548// thread root in suggestions renders its suggestion as a diff, and its
1549// body without the block.
1550func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights, suggestions map[int64]*suggestionView) ([]diffFile, []diffThread) {
15161551 type anchor struct {
15171552 path string
15181553 side string
@@ -1525,10 +1560,15 @@ func attachThreads(files []diffFile, comments []store.DiffComment, headSHA strin
15251560 var order []int64
15261561 for _, cm := range comments {
15271562 if cm.ReplyTo == 0 {
1563 body := cm.Body
1564 if suggestions[cm.ID] != nil {
1565 body = suggest.Strip(body)
1566 }
15281567 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
15291568 Pending: cm.Pending,
15301569 CanResolve: rights.canResolve(cm.Author),
1531 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1570 Suggestion: suggestions[cm.ID],
1571 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(body, "md")}}}
15321572 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
15331573 order = append(order, cm.ID)
15341574 } else if th, ok := threads[cm.ReplyTo]; ok {
@@ -2186,9 +2226,26 @@ func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm stri
21862226 }
21872227 md := s.ugcFor(r, p.Repo)
21882228 canWrite := s.canWriteRepo(r, p.Repo)
2229 // Applying a suggestion pushes to the source branch, so the button
2230 // follows write on the source repository, which for a fork is not
2231 // the one this page is in.
2232 canApply := false
2233 if p.Viewer != "" && m.State == "open" {
2234 if src, err := s.st.RepoByID(m.SourceRepoID); err == nil {
2235 canApply = s.canWriteRepo(r, src)
2236 }
2237 }
2238 suggestions := map[int64]*suggestionView{}
2239 sgs := control.Suggestions(s.st, s.cfg.Server.Root, p.Repo, m, diffComments)
2240 for _, cm := range diffComments {
2241 if sg := sgs[cm.ID]; sg != nil {
2242 suggestions[cm.ID] = newSuggestionView(sg, canApply && !cm.Pending,
2243 fmt.Sprintf("gitbay mr apply-suggestion %s %d %d", p.Repo.Path(), m.Number, cm.ID))
2244 }
2245 }
21892246 var detachedThreads []diffThread
21902247 files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
2191 reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
2248 reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite}, suggestions)
21922249 if p.Viewer != "" {
21932250 markCompose(files, r.URL.Query())
21942251 }
internal/store/diffcomments.go +36 −12
@@ -13,6 +13,7 @@ type DiffComment struct {
1313 Path string
1414 Side string
1515 Line int64
16 StartLine int64 // first line of a range ending at Line; 0 for Line alone
1617 Body string
1718 ReplyTo int64 // 0 for thread roots
1819 ResolvedBy string
@@ -23,8 +24,9 @@ type DiffComment struct {
2324}
2425
2526// AddDiffComment creates a thread root (replyTo 0) or a reply. Replies
26// inherit the root's anchor and must belong to the same MR.
27func (s *Store) AddDiffComment(mrID, authorID int64, headSHA, path, side string, line int64, body string, replyTo int64, pending bool) (int64, error) {
27// inherit the root's anchor and must belong to the same MR. startLine is
28// the first line of a range ending at line, or 0.
29func (s *Store) AddDiffComment(mrID, authorID int64, headSHA, path, side string, line, startLine int64, body string, replyTo int64, pending bool) (int64, error) {
2830 if replyTo != 0 {
2931 var rootMR int64
3032 var rootReply sql.NullInt64
@@ -43,8 +45,8 @@ func (s *Store) AddDiffComment(mrID, authorID int64, headSHA, path, side string,
4345 return 0, fmt.Errorf("reply to the thread root %d, not to a reply", rootReply.Int64)
4446 }
4547 err = s.DB.QueryRow(
46 "SELECT head_sha, path, side, line FROM mr_diff_comments WHERE id = ?", replyTo).
47 Scan(&headSHA, &path, &side, &line)
48 "SELECT head_sha, path, side, line, start_line FROM mr_diff_comments WHERE id = ?", replyTo).
49 Scan(&headSHA, &path, &side, &line, &startLine)
4850 if err != nil {
4951 return 0, err
5052 }
@@ -54,9 +56,9 @@ func (s *Store) AddDiffComment(mrID, authorID int64, headSHA, path, side string,
5456 reply = replyTo
5557 }
5658 res, err := s.DB.Exec(`
57 INSERT INTO mr_diff_comments (mr_id, author_id, head_sha, path, side, line, body, reply_to, pending)
58 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
59 mrID, authorID, headSHA, path, side, line, body, reply, pending)
59 INSERT INTO mr_diff_comments (mr_id, author_id, head_sha, path, side, line, start_line, body, reply_to, pending)
60 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
61 mrID, authorID, headSHA, path, side, line, startLine, body, reply, pending)
6062 if err != nil {
6163 return 0, err
6264 }
@@ -68,8 +70,7 @@ func (s *Store) AddDiffComment(mrID, authorID int64, headSHA, path, side string,
6870// anonymous reader, who sees only what is published.
6971func (s *Store) ListDiffComments(mrID, viewer int64) ([]DiffComment, error) {
7072 rows, err := s.DB.Query(`
71 SELECT c.id, u.username, c.head_sha, c.path, c.side, c.line, c.body,
72 COALESCE(c.reply_to, 0), COALESCE(r.username, ''), c.pending, c.created_at
73 SELECT `+diffCommentCols+`
7374 FROM mr_diff_comments c
7475 JOIN users u ON u.id = c.author_id
7576 LEFT JOIN users r ON r.id = c.resolved_by
@@ -81,9 +82,8 @@ func (s *Store) ListDiffComments(mrID, viewer int64) ([]DiffComment, error) {
8182 defer rows.Close()
8283 var out []DiffComment
8384 for rows.Next() {
84 var c DiffComment
85 if err := rows.Scan(&c.ID, &c.Author, &c.HeadSHA, &c.Path, &c.Side, &c.Line, &c.Body,
86 &c.ReplyTo, &c.ResolvedBy, &c.Pending, &c.CreatedAt); err != nil {
85 c, err := scanDiffComment(rows)
86 if err != nil {
8787 return nil, err
8888 }
8989 out = append(out, c)
@@ -91,6 +91,30 @@ func (s *Store) ListDiffComments(mrID, viewer int64) ([]DiffComment, error) {
9191 return out, rows.Err()
9292}
9393
94const diffCommentCols = `c.id, u.username, c.head_sha, c.path, c.side, c.line, c.start_line, c.body,
95 COALESCE(c.reply_to, 0), COALESCE(r.username, ''), c.pending, c.created_at`
96
97func scanDiffComment(row interface{ Scan(...any) error }) (DiffComment, error) {
98 var c DiffComment
99 err := row.Scan(&c.ID, &c.Author, &c.HeadSHA, &c.Path, &c.Side, &c.Line, &c.StartLine, &c.Body,
100 &c.ReplyTo, &c.ResolvedBy, &c.Pending, &c.CreatedAt)
101 return c, err
102}
103
104// DiffCommentByID returns one comment on an MR, published or pending.
105func (s *Store) DiffCommentByID(mrID, id int64) (DiffComment, error) {
106 c, err := scanDiffComment(s.DB.QueryRow(`
107 SELECT `+diffCommentCols+`
108 FROM mr_diff_comments c
109 JOIN users u ON u.id = c.author_id
110 LEFT JOIN users r ON r.id = c.resolved_by
111 WHERE c.mr_id = ? AND c.id = ?`, mrID, id))
112 if errors.Is(err, sql.ErrNoRows) {
113 return c, ErrNotFound
114 }
115 return c, err
116}
117
94118// SetThreadResolved resolves or unresolves a thread root.
95119func (s *Store) SetThreadResolved(mrID, rootID, byUser int64, resolved bool) error {
96120 var q string
internal/store/migrations/0069_thread_range.down.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE mr_diff_comments DROP COLUMN start_line;
internal/store/migrations/0069_thread_range.up.sql added +4
@@ -0,0 +1,4 @@
1-- A review thread may anchor to a range of lines ending at line: a
2-- suggestion replaces start_line through line. 0 means the thread is on
3-- line alone, which every existing row is.
4ALTER TABLE mr_diff_comments ADD COLUMN start_line INTEGER NOT NULL DEFAULT 0;
internal/store/mrs_test.go +3 −3
@@ -208,14 +208,14 @@ func TestMRCommentCounts(t *testing.T) {
208208 if err := s.AddMRSystemComment(mr1.ID, uid, "merged"); err != nil {
209209 t.Fatal(err)
210210 }
211 rootID, err := s.AddDiffComment(mr1.ID, uid, "abc123", "file.txt", "new", 1, "root", 0, false)
211 rootID, err := s.AddDiffComment(mr1.ID, uid, "abc123", "file.txt", "new", 1, 0, "root", 0, false)
212212 if err != nil {
213213 t.Fatal(err)
214214 }
215 if _, err := s.AddDiffComment(mr1.ID, uid, "abc123", "file.txt", "new", 1, "reply", rootID, false); err != nil {
215 if _, err := s.AddDiffComment(mr1.ID, uid, "abc123", "file.txt", "new", 1, 0, "reply", rootID, false); err != nil {
216216 t.Fatal(err)
217217 }
218 if _, err := s.AddDiffComment(mr1.ID, uid, "abc123", "file.txt", "new", 2, "pending root", 0, true); err != nil {
218 if _, err := s.AddDiffComment(mr1.ID, uid, "abc123", "file.txt", "new", 2, 0, "pending root", 0, true); err != nil {
219219 t.Fatal(err)
220220 }
221221
internal/store/pending_test.go +5 −5
@@ -35,10 +35,10 @@ func pendingFixture(t *testing.T) (*Store, int64, int64, int64) {
3535// else, until they submit.
3636func TestPendingCommentsArePrivate(t *testing.T) {
3737 s, mrID, author, other := pendingFixture(t)
38 if _, err := s.AddDiffComment(mrID, other, "abc123", "a.go", "new", 3, "half a thought", 0, true); err != nil {
38 if _, err := s.AddDiffComment(mrID, other, "abc123", "a.go", "new", 3, 0, "half a thought", 0, true); err != nil {
3939 t.Fatal(err)
4040 }
41 if _, err := s.AddDiffComment(mrID, other, "abc123", "a.go", "new", 9, "said out loud", 0, false); err != nil {
41 if _, err := s.AddDiffComment(mrID, other, "abc123", "a.go", "new", 9, 0, "said out loud", 0, false); err != nil {
4242 t.Fatal(err)
4343 }
4444
@@ -66,7 +66,7 @@ func TestPendingCommentsArePrivate(t *testing.T) {
6666// so nobody else could resolve it.
6767func TestPendingThreadsDoNotBlockMerges(t *testing.T) {
6868 s, mrID, _, other := pendingFixture(t)
69 if _, err := s.AddDiffComment(mrID, other, "abc123", "a.go", "new", 3, "pending", 0, true); err != nil {
69 if _, err := s.AddDiffComment(mrID, other, "abc123", "a.go", "new", 3, 0, "pending", 0, true); err != nil {
7070 t.Fatal(err)
7171 }
7272 n, err := s.UnresolvedThreadCount(mrID)
@@ -88,12 +88,12 @@ func TestPendingThreadsDoNotBlockMerges(t *testing.T) {
8888func TestPublishAndDiscardPending(t *testing.T) {
8989 s, mrID, author, other := pendingFixture(t)
9090 for i := 0; i < 3; i++ {
91 if _, err := s.AddDiffComment(mrID, other, "abc123", "a.go", "new", int64(i+1), "note", 0, true); err != nil {
91 if _, err := s.AddDiffComment(mrID, other, "abc123", "a.go", "new", int64(i+1), 0, "note", 0, true); err != nil {
9292 t.Fatal(err)
9393 }
9494 }
9595 // Another reviewer's batch is untouched by either operation.
96 if _, err := s.AddDiffComment(mrID, author, "abc123", "b.go", "new", 1, "mine", 0, true); err != nil {
96 if _, err := s.AddDiffComment(mrID, author, "abc123", "b.go", "new", 1, 0, "mine", 0, true); err != nil {
9797 t.Fatal(err)
9898 }
9999
internal/suggest/suggest.go added +176
@@ -0,0 +1,176 @@
1// Package suggest reads the replacement lines a review comment proposes
2// in a fenced suggestion block, and applies them to a file's anchored
3// line range. The server's apply and the CLI's local apply share it, so
4// both produce the same bytes.
5package suggest
6
7import (
8 "bytes"
9 "errors"
10 "fmt"
11 "strings"
12)
13
14// Parse returns the lines of the one ```suggestion block in body. found
15// is false when there is none. An empty block proposes deleting the
16// range; a block holding one empty line proposes a blank line.
17func Parse(body string) (lines []string, found bool, err error) {
18 start, end, err := locate(body)
19 if err != nil || start < 0 {
20 return nil, false, err
21 }
22 all := strings.Split(normalize(body), "\n")
23 return append([]string{}, all[start+1:end]...), true, nil
24}
25
26// Strip returns body without its suggestion block, for rendering the
27// prose around a suggestion that is shown as a diff instead.
28func Strip(body string) string {
29 start, end, err := locate(body)
30 if err != nil || start < 0 {
31 return body
32 }
33 all := strings.Split(normalize(body), "\n")
34 return strings.TrimSpace(strings.Join(append(all[:start:start], all[end+1:]...), "\n"))
35}
36
37func normalize(body string) string { return strings.ReplaceAll(body, "\r\n", "\n") }
38
39// locate finds the suggestion block's opening and closing fence lines.
40// start is -1 when there is no block.
41func locate(body string) (start, end int, err error) {
42 all := strings.Split(normalize(body), "\n")
43 start = -1
44 for i := 0; i < len(all); i++ {
45 fence, ok := opening(all[i])
46 if !ok {
47 continue
48 }
49 if start >= 0 {
50 return -1, -1, errors.New("a comment carries one suggestion block")
51 }
52 j := i + 1
53 for ; j < len(all); j++ {
54 if closing(all[j], fence) {
55 break
56 }
57 }
58 if j == len(all) {
59 return -1, -1, errors.New("the suggestion block is not closed")
60 }
61 start, end = i, j
62 i = j
63 }
64 return start, end, nil
65}
66
67// opening reports whether line opens a suggestion block, and the length
68// of its backtick fence.
69func opening(line string) (int, bool) {
70 t := strings.TrimLeft(line, " ")
71 if len(line)-len(t) > 3 {
72 return 0, false
73 }
74 n := len(t) - len(strings.TrimLeft(t, "`"))
75 if n < 3 {
76 return 0, false
77 }
78 return n, strings.TrimSpace(t[n:]) == "suggestion"
79}
80
81func closing(line string, fence int) bool {
82 t := strings.TrimSpace(line)
83 return len(t) >= fence && strings.Trim(t, "`") == ""
84}
85
86// Text is the replacement as one string, every line ending in a newline:
87// "" deletes the range and "\n" is one blank line.
88func Text(lines []string) string {
89 var b strings.Builder
90 for _, l := range lines {
91 b.WriteString(l)
92 b.WriteByte('\n')
93 }
94 return b.String()
95}
96
97// FromText undoes Text.
98func FromText(s string) []string {
99 if s == "" {
100 return nil
101 }
102 return strings.Split(strings.TrimSuffix(s, "\n"), "\n")
103}
104
105// split cuts content into lines, each keeping its terminator. The last
106// line has none when the file does not end in a newline.
107func split(content []byte) [][]byte {
108 var out [][]byte
109 for len(content) > 0 {
110 i := bytes.IndexByte(content, '\n')
111 if i < 0 {
112 out = append(out, content)
113 break
114 }
115 out = append(out, content[:i+1])
116 content = content[i+1:]
117 }
118 return out
119}
120
121// Range returns lines start through end (1-based, inclusive) of content
122// with their terminators, and false when the file is shorter than that.
123func Range(content []byte, start, end int) ([]byte, bool) {
124 lines := split(content)
125 if start < 1 || end < start || end > len(lines) {
126 return nil, false
127 }
128 return bytes.Join(lines[start-1:end], nil), true
129}
130
131// Apply replaces lines start through end of content with repl. The
132// replacement takes the line ending the file uses there, CRLF or LF, and
133// the last replacement line keeps whatever ended the range, so a range
134// at the end of a file with no final newline still has none.
135func Apply(content []byte, start, end int, repl []string) ([]byte, error) {
136 lines := split(content)
137 if start < 1 || end < start {
138 return nil, fmt.Errorf("bad line range %d-%d", start, end)
139 }
140 if end > len(lines) {
141 return nil, fmt.Errorf("the file has %d lines; the suggestion ends at line %d", len(lines), end)
142 }
143 last := lines[end-1]
144 eol, lastEOL := []byte("\n"), []byte{}
145 switch {
146 case bytes.HasSuffix(last, []byte("\r\n")):
147 eol, lastEOL = []byte("\r\n"), []byte("\r\n")
148 case bytes.HasSuffix(last, []byte("\n")):
149 lastEOL = []byte("\n")
150 case end > 1 && bytes.HasSuffix(lines[end-2], []byte("\r\n")):
151 eol = []byte("\r\n")
152 }
153 var b bytes.Buffer
154 for _, l := range lines[:start-1] {
155 b.Write(l)
156 }
157 for i, r := range repl {
158 b.WriteString(r)
159 if i == len(repl)-1 {
160 b.Write(lastEOL)
161 } else {
162 b.Write(eol)
163 }
164 }
165 for _, l := range lines[end:] {
166 b.Write(l)
167 }
168 return b.Bytes(), nil
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.
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}
internal/suggest/suggest_test.go added +100
@@ -0,0 +1,100 @@
1package suggest
2
3import (
4 "reflect"
5 "strings"
6 "testing"
7)
8
9func TestParse(t *testing.T) {
10 cases := []struct {
11 name string
12 body string
13 lines []string
14 found bool
15 err string
16 }{
17 {"none", "just prose\n```go\nx\n```\n", nil, false, ""},
18 {"one line", "try this\n```suggestion\nreturn nil\n```\n", []string{"return nil"}, true, ""},
19 {"several", "```suggestion\na\n\tb\n```", []string{"a", "\tb"}, true, ""},
20 {"deletion", "drop it\n```suggestion\n```\n", []string{}, true, ""},
21 {"blank line", "```suggestion\n\n```\n", []string{""}, true, ""},
22 {"crlf body", "x\r\n```suggestion\r\nnew\r\n```\r\n", []string{"new"}, true, ""},
23 {"longer fence", "````suggestion\n```\n````\n", []string{"```"}, true, ""},
24 {"unclosed", "```suggestion\nnew\n", nil, false, "not closed"},
25 {"two", "```suggestion\na\n```\n```suggestion\nb\n```\n", nil, false, "one suggestion"},
26 }
27 for _, c := range cases {
28 lines, found, err := Parse(c.body)
29 if c.err != "" {
30 if err == nil || !strings.Contains(err.Error(), c.err) {
31 t.Errorf("%s: err = %v, want %q", c.name, err, c.err)
32 }
33 continue
34 }
35 if err != nil || found != c.found || (c.found && !reflect.DeepEqual(lines, c.lines)) {
36 t.Errorf("%s: Parse = %q %v %v, want %q %v", c.name, lines, found, err, c.lines, c.found)
37 }
38 }
39}
40
41func TestStrip(t *testing.T) {
42 if got := Strip("use this\n```suggestion\nx\n```\nthanks"); got != "use this\nthanks" {
43 t.Errorf("Strip = %q", got)
44 }
45 if got := Strip("```suggestion\nx\n```"); got != "" {
46 t.Errorf("Strip of a bare block = %q", got)
47 }
48}
49
50func TestTextRoundTrip(t *testing.T) {
51 for _, lines := range [][]string{nil, {""}, {"a"}, {"a", "", "b"}} {
52 if got := FromText(Text(lines)); !reflect.DeepEqual(got, lines) && !(len(got) == 0 && len(lines) == 0) {
53 t.Errorf("FromText(Text(%q)) = %q", lines, got)
54 }
55 }
56}
57
58func TestApply(t *testing.T) {
59 cases := []struct {
60 name string
61 content string
62 start, end int
63 repl []string
64 want string
65 err string
66 }{
67 {"one line", "a\nb\nc\n", 2, 2, []string{"B"}, "a\nB\nc\n", ""},
68 {"range to more", "a\nb\nc\nd\n", 2, 3, []string{"x", "y", "z"}, "a\nx\ny\nz\nd\n", ""},
69 {"range to fewer", "a\nb\nc\nd\n", 1, 3, []string{"x"}, "x\nd\n", ""},
70 {"deletion", "a\nb\nc\n", 2, 2, nil, "a\nc\n", ""},
71 {"delete all", "a\nb\n", 1, 2, nil, "", ""},
72 {"eof no newline", "a\nb", 2, 2, []string{"B", "C"}, "a\nB\nC", ""},
73 {"eof with newline", "a\nb\n", 2, 2, []string{"B"}, "a\nB\n", ""},
74 {"crlf", "a\r\nb\r\nc\r\n", 2, 2, []string{"x", "y"}, "a\r\nx\r\ny\r\nc\r\n", ""},
75 {"crlf eof no newline", "a\r\nb", 2, 2, []string{"x", "y"}, "a\r\nx\r\ny", ""},
76 {"past eof", "a\nb\n", 2, 3, []string{"x"}, "", "has 2 lines"},
77 {"bad range", "a\n", 2, 1, nil, "", "bad line range"},
78 }
79 for _, c := range cases {
80 got, err := Apply([]byte(c.content), c.start, c.end, c.repl)
81 if c.err != "" {
82 if err == nil || !strings.Contains(err.Error(), c.err) {
83 t.Errorf("%s: err = %v, want %q", c.name, err, c.err)
84 }
85 continue
86 }
87 if err != nil || string(got) != c.want {
88 t.Errorf("%s: Apply = %q, %v; want %q", c.name, got, err, c.want)
89 }
90 }
91}
92
93func TestRange(t *testing.T) {
94 if got, ok := Range([]byte("a\r\nb\nc"), 2, 3); !ok || string(got) != "b\nc" {
95 t.Errorf("Range = %q %v", got, ok)
96 }
97 if _, ok := Range([]byte("a\n"), 1, 2); ok {
98 t.Error("Range past the end reported ok")
99 }
100}
internal/web/static/style.css +4
@@ -1293,6 +1293,10 @@ a.authorlink:hover { color: var(--link); }
12931293.thread textarea { width: 100%; }
12941294.thread.composing p, .thread details.threadreply p { margin: var(--sp-2) 0 0; }
12951295form.threadact { margin: var(--sp-1) 0 0; padding: 0 var(--sp-3) var(--sp-2); }
1296.thread .suggestion { margin: var(--sp-2) 0 0; }
1297.thread .suggestion table.difftable { margin: var(--sp-1) 0 0; }
1298.thread .suggestion table.difftable tr.add td,
1299.thread .suggestion table.difftable tr.del td { display: table-cell; }
12961300
12971301nav.subtabs {
12981302 display: flex;
internal/web/templates/layout.html +16 −1
@@ -219,6 +219,7 @@
219219 <input type="hidden" name="path" value="{{.Path}}">
220220 <input type="hidden" name="line" value="{{if eq .Class "del"}}{{.OldLine}}{{else}}{{.NewLine}}{{end}}">
221221 <input type="hidden" name="side" value="{{if eq .Class "del"}}old{{else}}new{{end}}">
222 {{if ne .Class "del"}}<p><label>From line <input type="number" name="start_line" min="1" max="{{.NewLine}}" placeholder="{{.NewLine}}"></label> to {{.NewLine}}; a <code>```suggestion</code> block proposes replacement lines</p>{{end}}
222223 <p><textarea name="body" aria-label="Comment on {{.Path}}" rows="3" placeholder="Comment on this line" autofocus></textarea></p>
223224 <p><button type="submit" class="btn">Comment</button>
224225 <button type="submit" name="pending" value="on" class="btn">Add to review</button>
@@ -235,6 +236,7 @@
235236 <input type="hidden" name="path" value="{{.Path}}">
236237 <input type="hidden" name="line" value="{{if eq .Class "del"}}{{.OldLine}}{{else}}{{.NewLine}}{{end}}">
237238 <input type="hidden" name="side" value="{{if eq .Class "del"}}old{{else}}new{{end}}">
239 {{if ne .Class "del"}}<p><label>From line <input type="number" name="start_line" min="1" max="{{.NewLine}}" placeholder="{{.NewLine}}"></label> to {{.NewLine}}; a <code>```suggestion</code> block proposes replacement lines</p>{{end}}
238240 <p><textarea name="body" aria-label="Comment on {{.Path}}" rows="3" placeholder="Comment on this line" autofocus></textarea></p>
239241 <p><button type="submit" class="btn">Comment</button>
240242 <button type="submit" name="pending" value="on" class="btn">Add to review</button>
@@ -255,7 +257,7 @@
255257{{define "thread"}}{{$t := .T}}<div class="thread{{if $t.Resolved}} resolved{{end}}{{if $t.Pending}} pending{{end}}{{if .Class}} {{.Class}}{{end}}" id="thread-{{$t.ID}}">
256258{{if $t.Pending}}<p class="threadstate">pending — only you can see this until you submit your review</p>{{end}}
257259{{if or $t.Resolved (and .Class $t.Stale)}}<p class="threadstate">{{if and .Class $t.Stale}}stale{{end}}{{if $t.Resolved}}{{if and .Class $t.Stale}} · {{end}}resolved by {{$t.Resolved}}{{end}}</p>{{end}}
258{{range $t.Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}
260{{range $i, $c := $t.Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{if and (eq $i 0) $t.Suggestion}}{{template "suggestion" dict "S" $t.Suggestion "T" $t.ID "Base" $.Base}}{{end}}{{end}}
259261{{if .Viewer}}<details class="threadreply"><summary>Reply</summary>
260262<form method="post" action="{{.Base}}/diff-comment">
261263 <input type="hidden" name="reply" value="{{$t.ID}}">
@@ -264,3 +266,16 @@
264266</form></details>{{end}}
265267{{if $t.CanResolve}}<form method="post" action="{{.Base}}/thread" class="threadact"><input type="hidden" name="thread" value="{{$t.ID}}"><button type="submit" name="action" value="{{if $t.Resolved}}unresolve{{else}}resolve{{end}}" class="linklike">{{if $t.Resolved}}Reopen thread{{else}}Resolve thread{{end}}</button></form>{{end}}
266268</div>{{end}}
269
270{{/* suggestion renders the lines a thread's suggestion replaces and the
271 ones it proposes, with the apply button, or the command to run where
272 the server cannot sign the commit. */}}
273{{define "suggestion"}}{{$s := .S}}<div class="suggestion">
274<p class="threadstate">{{if $s.Outdated}}outdated suggestion: {{$s.Reason}}{{else}}suggested change{{end}}</p>
275<div class="tablewrap"><table class="difftable">
276{{range $s.Old}}<tr class="del"><td class="ln">{{.N}}</td><td class="src">{{.Text}}</td></tr>
277{{end}}{{range $s.New}}<tr class="add"><td class="ln">{{.N}}</td><td class="src">{{.Text}}</td></tr>
278{{end}}</table></div>
279{{if and $s.CanApply (not $s.Outdated)}}{{if $s.Local}}<p class="threadstate">This repository requires signed commits, which the server cannot make. Apply it from a clone: <code>{{$s.Command}}</code></p>
280{{else}}<form method="post" action="{{.Base}}/suggestion" class="threadact"><input type="hidden" name="thread" value="{{.T}}"><button type="submit" class="btn">Apply suggestion</button></form>
281{{end}}{{end}}</div>{{end}}