Commit 5271aa8b0d

5271aa8b0d040f1bb9dcfcffec98563966f34a26

parent: 9581d34d47

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 04:50 UTC

control: suggestion blocks in diff comments, line ranges, suggestions in mr threads

Ref #288

Layout: unified · split

internal/control/diffcomment.go +84 −19
@@ -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,25 @@ 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 }
186221 byRoot := map[int64]*threadOut{}
187222 var order []int64
188223 for _, cm := range comments {
189224 if cm.ReplyTo == 0 {
190225 byRoot[cm.ID] = &threadOut{
191 ID: cm.ID, Path: cm.Path, Side: cm.Side, Line: cm.Line,
226 ID: cm.ID, Path: cm.Path, Side: cm.Side, StartLine: cm.StartLine, Line: cm.Line,
192227 Stale: cm.HeadSHA != mr.HeadSHA, Resolved: cm.ResolvedBy,
193 Comments: []commentOut{{cm.ID, cm.Author, cm.Body, cm.CreatedAt}},
228 Suggestion: ThreadSuggestion(c.Store, c.Cfg.Server.Root, repo, mr, cm),
229 Comments: []commentOut{{cm.ID, cm.Author, cm.Body, cm.CreatedAt}},
194230 }
195231 order = append(order, cm.ID)
196232 } else if th, ok := byRoot[cm.ReplyTo]; ok {
@@ -201,7 +237,6 @@ func runMRThreads(c *Ctx, args []string) int {
201237 for _, id := range order {
202238 ds = append(ds, *byRoot[id])
203239 }
204 _ = repo
205240 return c.emit(ds, func(w io.Writer) {
206241 for _, th := range ds {
207242 marks := ""
@@ -211,14 +246,44 @@ func runMRThreads(c *Ctx, args []string) int {
211246 if th.Stale {
212247 marks += " [stale]"
213248 }
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)
249 lines := fmt.Sprint(th.Line)
250 if th.StartLine != 0 && th.StartLine != th.Line {
251 lines = fmt.Sprintf("%d-%d", th.StartLine, th.Line)
252 }
253 fmt.Fprintf(w, "thread %d %s:%s (%s)%s\n", th.ID, th.Path, lines, th.Side, marks)
254 for i, cm := range th.Comments {
255 body := cm.Body
256 if i == 0 && th.Suggestion != nil {
257 body = suggest.Strip(body)
258 }
259 if body != "" {
260 fmt.Fprintf(w, " %s: %s\n", cm.Author, body)
261 }
262 if i == 0 && th.Suggestion != nil {
263 writeSuggestion(w, repo, mr, th.ID, cm.Author, th.Suggestion)
264 }
217265 }
218266 }
219267 })
220268}
221269
270// writeSuggestion prints a suggestion as the diff it proposes and how to
271// apply it, or why it can no longer be applied.
272func writeSuggestion(w io.Writer, repo store.Repo, mr store.MR, thread int64, author string, s *SuggestionOut) {
273 switch {
274 case s.Outdated:
275 fmt.Fprintf(w, " %s suggests (outdated: %s):\n", author, s.Reason)
276 default:
277 fmt.Fprintf(w, " %s suggests (gitbay mr apply-suggestion %s %d %d):\n", author, repo.Path(), mr.Number, thread)
278 }
279 for _, l := range suggest.FromText(strings.ReplaceAll(s.Original, "\r\n", "\n")) {
280 fmt.Fprintf(w, " - %s\n", l)
281 }
282 for _, l := range suggest.FromText(s.Replacement) {
283 fmt.Fprintf(w, " + %s\n", l)
284 }
285}
286
222287func setThreadResolved(c *Ctx, args []string, resolved bool) int {
223288 if len(args) != 3 {
224289 return c.usage()
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/suggestion.go added +127
@@ -0,0 +1,127 @@
1package control
2
3import (
4 "bytes"
5 "fmt"
6
7 "gitbay.org/gitbay/internal/gitutil"
8 "gitbay.org/gitbay/internal/store"
9 "gitbay.org/gitbay/internal/suggest"
10)
11
12// SuggestionOut is the change a review thread's ```suggestion block
13// proposes: lines StartLine through EndLine of Path, as they were at
14// Commit (Original), replaced by Replacement. Both texts end every line
15// with its terminator, so "" is no lines. Apply is "server" where `mr
16// apply-suggestion` commits it, or "local" on a repository requiring
17// signed commits, where the CLI commits it with the user's own key.
18type SuggestionOut struct {
19 Path string `json:"path"`
20 StartLine int64 `json:"start_line"`
21 EndLine int64 `json:"end_line"`
22 Commit string `json:"commit"`
23 Blob string `json:"blob,omitempty"`
24 Original string `json:"original"`
25 Replacement string `json:"replacement"`
26 Outdated bool `json:"outdated"`
27 Reason string `json:"reason,omitempty"`
28 Apply string `json:"apply"`
29}
30
31// maxSuggestionBytes bounds the file a suggestion rewrites, the same as
32// a single-file commit over the control plane.
33const maxSuggestionBytes = maxCommitFileBytes
34
35// Reasons a suggestion cannot be applied at a head.
36const (
37 reasonGone = "the file is not at the head: it was renamed or deleted"
38 reasonChanged = "the lines it replaces have changed since it was made"
39 reasonNoBase = "the commit it was made against is no longer available"
40)
41
42// readAnchored reads the regular file path at commit, with its mode.
43func readAnchored(dir, commit, path string) ([]byte, string, error) {
44 e, ok := gitutil.StatPath(dir, commit, path)
45 if !ok {
46 return nil, "", fmt.Errorf("%s", reasonGone)
47 }
48 if e.Type != "blob" || (e.Mode != "100644" && e.Mode != "100755") {
49 return nil, "", fmt.Errorf("%s is not a regular file", path)
50 }
51 if e.Size > maxSuggestionBytes {
52 return nil, "", fmt.Errorf("%s is larger than %d bytes", path, maxSuggestionBytes)
53 }
54 content, err := gitutil.ReadBlob(dir, commit, path, maxSuggestionBytes)
55 if err != nil {
56 return nil, "", err
57 }
58 return content, e.Mode, nil
59}
60
61// suggestionRange is a thread's first and last line.
62func suggestionRange(cm store.DiffComment) (int, int) {
63 return int(firstNonZero(cm.StartLine, cm.Line)), int(cm.Line)
64}
65
66// ThreadSuggestion is the suggestion a thread root carries, checked
67// against the merge request's head, or nil when it carries none. The
68// original lines are read from the commit the comment was made on, in
69// the target repository, which holds every head the merge request has
70// had until gc prunes an abandoned one.
71func ThreadSuggestion(st *store.Store, root string, repo store.Repo, mr store.MR, cm store.DiffComment) *SuggestionOut {
72 if cm.ReplyTo != 0 || cm.Side != "new" {
73 return nil
74 }
75 lines, found, err := suggest.Parse(cm.Body)
76 if err != nil || !found {
77 return nil
78 }
79 start, end := suggestionRange(cm)
80 s := &SuggestionOut{Path: cm.Path, StartLine: int64(start), EndLine: int64(end), Commit: cm.HeadSHA,
81 Replacement: suggest.Text(lines), Apply: "server"}
82 if signedOnly(st, repo, mr) {
83 s.Apply = "local"
84 }
85 dir := RepoDir(root, repo.OwnerName, repo.Name)
86 if e, ok := gitutil.StatPath(dir, cm.HeadSHA, cm.Path); ok {
87 s.Blob = e.SHA
88 }
89 base, _, err := readAnchored(dir, cm.HeadSHA, cm.Path)
90 orig, ok := suggest.Range(base, start, end)
91 if err != nil || !ok {
92 s.Outdated, s.Reason = true, reasonNoBase
93 return s
94 }
95 s.Original = string(orig)
96 s.Reason = anchorReason(dir, mr.HeadSHA, s)
97 s.Outdated = s.Reason != ""
98 return s
99}
100
101// anchorReason says why s cannot be applied to the file at head, or ""
102// when the lines it replaces are still what it was made against.
103func anchorReason(dir, head string, s *SuggestionOut) string {
104 content, _, err := readAnchored(dir, head, s.Path)
105 if err != nil {
106 return err.Error()
107 }
108 now, ok := suggest.Range(content, int(s.StartLine), int(s.EndLine))
109 if !ok || !bytes.Equal(now, []byte(s.Original)) {
110 return reasonChanged
111 }
112 return ""
113}
114
115// signedOnly reports whether the server may not commit a suggestion for
116// this merge request: the source branch or the target it merges into
117// requires signed commits, and the server has no key to sign with.
118func signedOnly(st *store.Store, repo store.Repo, mr store.MR) bool {
119 if repo.Settings.RequireSignedCommits {
120 return true
121 }
122 if mr.SourceRepoID == repo.ID {
123 return false
124 }
125 src, err := st.RepoByID(mr.SourceRepoID)
126 return err != nil || src.Settings.RequireSignedCommits
127}
internal/control/suggestion_test.go added +192
@@ -0,0 +1,192 @@
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 argv := []string{"mr", "diff-comment", f.repo.Path(), "1", "--path", path,
53 "--start-line", strconv.Itoa(start), "--line", strconv.Itoa(end), "--file", "-"}
54 if code := Dispatch(c, argv); code != protocol.ExitOK {
55 f.t.Fatalf("diff-comment: exit %d, %s", code, errOut.String())
56 }
57 var env struct {
58 Data struct {
59 Thread int64 `json:"thread"`
60 } `json:"data"`
61 }
62 if err := json.Unmarshal([]byte(out.String()), &env); err != nil {
63 f.t.Fatal(err)
64 }
65 return strconv.Itoa(int(env.Data.Thread))
66}
67
68type threadJSON struct {
69 ID int64 `json:"id"`
70 StartLine int64 `json:"start_line"`
71 Line int64 `json:"line"`
72 Resolved string `json:"resolved_by"`
73 Suggestion *SuggestionOut `json:"suggestion"`
74}
75
76func (f *queueFixture) threads(u store.User) []threadJSON {
77 f.t.Helper()
78 out := f.mustRun(u, "mr", "threads", f.repo.Path(), "1", "--json")
79 var env struct {
80 Data []threadJSON `json:"data"`
81 }
82 if err := json.Unmarshal([]byte(out), &env); err != nil {
83 f.t.Fatalf("threads JSON: %v\n%s", err, out)
84 }
85 return env.Data
86}
87
88func (f *queueFixture) suggestion(u store.User, thread string) *SuggestionOut {
89 f.t.Helper()
90 for _, th := range f.threads(u) {
91 if strconv.Itoa(int(th.ID)) == thread {
92 return th.Suggestion
93 }
94 }
95 f.t.Fatalf("no thread %s", thread)
96 return nil
97}
98
99// A suggestion over a range reads back from mr threads as structure:
100// the anchor, the commit and blob it was made against, the lines it
101// replaces and the replacement.
102func TestSuggestionInThreads(t *testing.T) {
103 f := newSuggestFixture(t, nil)
104 id := f.suggest(f.alice, "lib.txt", 2, 3, "TWO", "THREE", "extra")
105 s := f.suggestion(f.alice, id)
106 if s == nil {
107 t.Fatal("thread carries no suggestion")
108 }
109 blob := strings.TrimSpace(f.git(f.dir, "rev-parse", f.headSHA+":lib.txt"))
110 want := SuggestionOut{Path: "lib.txt", StartLine: 2, EndLine: 3, Commit: f.headSHA, Blob: blob,
111 Original: "two\nthree\n", Replacement: "TWO\nTHREE\nextra\n", Apply: "server"}
112 if *s != want {
113 t.Fatalf("suggestion = %+v\nwant %+v", *s, want)
114 }
115 text := f.mustRun(f.alice, "mr", "threads", f.repo.Path(), "1")
116 for _, w := range []string{"lib.txt:2-3", "- two", "+ THREE", "mr apply-suggestion alice/app 1 " + id} {
117 if !strings.Contains(text, w) {
118 t.Errorf("threads text lacks %q:\n%s", w, text)
119 }
120 }
121 if strings.Contains(text, "```suggestion") {
122 t.Errorf("threads text repeats the raw block:\n%s", text)
123 }
124}
125
126// The suggestion goes stale when the lines it replaces change, and not
127// when the file changes elsewhere.
128func TestSuggestionOutdated(t *testing.T) {
129 f := newSuggestFixture(t, nil)
130 id := f.suggest(f.alice, "lib.txt", 2, 2, "TWO")
131 f.write("lib.txt", "one\ntwo\nthree\nfour\nFIVE\n")
132 f.git(f.src, "commit", "-q", "-am", "elsewhere")
133 f.moveHead()
134 if s := f.suggestion(f.alice, id); s.Outdated {
135 t.Fatalf("a change below the range outdated the suggestion: %+v", s)
136 }
137 f.write("lib.txt", "zero\none\ntwo\nthree\nfour\nFIVE\n")
138 f.git(f.src, "commit", "-q", "-am", "shift")
139 f.moveHead()
140 if s := f.suggestion(f.alice, id); !s.Outdated || s.Reason != reasonChanged {
141 t.Fatalf("suggestion after its lines moved = %+v, want outdated", s)
142 }
143 f.git(f.src, "rm", "-q", "lib.txt")
144 f.git(f.src, "commit", "-q", "-m", "gone")
145 f.moveHead()
146 if s := f.suggestion(f.alice, id); !s.Outdated || s.Reason != reasonGone {
147 t.Fatalf("suggestion on a deleted file = %+v, want outdated", s)
148 }
149}
150
151// On a repository requiring signed commits the server does not commit a
152// suggestion, and the thread says the CLI applies it locally.
153func TestSuggestionSignedIsLocal(t *testing.T) {
154 f := newSuggestFixture(t, func(s *store.RepoSettings) { s.RequireSignedCommits = true })
155 id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
156 if s := f.suggestion(f.alice, id); s.Apply != "local" {
157 t.Fatalf("apply = %q, want local", s.Apply)
158 }
159}
160
161// What diff-comment refuses before storing a suggestion it could never
162// apply.
163func TestSuggestionRefusals(t *testing.T) {
164 f := newSuggestFixture(t, nil)
165 id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
166 run := func(body string, extra ...string) (int, string) {
167 var out, errOut strings.Builder
168 c := &Ctx{User: f.alice, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut,
169 Stdin: strings.NewReader(body)}
170 c.Cfg.Server.Root = f.root
171 return Dispatch(c, append([]string{"mr", "diff-comment", f.repo.Path(), "1", "--file", "-"}, extra...)), errOut.String()
172 }
173 block := "```suggestion\nx\n```\n"
174 cases := []struct {
175 name string
176 body string
177 args []string
178 want string
179 }{
180 {"old side", block, []string{"--path", "lib.txt", "--line", "1", "--old"}, "drop --old"},
181 {"reply", block, []string{"--reply", id}, "its own thread"},
182 {"past the end", block, []string{"--path", "lib.txt", "--start-line", "5", "--line", "6"}, "no lines 5-6"},
183 {"unclosed", "```suggestion\nx\n", []string{"--path", "lib.txt", "--line", "1"}, "not closed"},
184 {"start after line", "plain", []string{"--path", "lib.txt", "--start-line", "3", "--line", "2"}, "no greater than --line"},
185 }
186 for _, c := range cases {
187 code, errOut := run(c.body, c.args...)
188 if code != protocol.ExitUsage || !strings.Contains(errOut, c.want) {
189 t.Errorf("%s: exit %d %q, want usage with %q", c.name, code, errOut, c.want)
190 }
191 }
192}
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