Commit 5271aa8b0d
Verified · cmc
Layout: unified · split
internal/control/diffcomment.go +84 −19
| @@ -12,15 +12,17 @@ import ( | |||
| 12 | "gitbay.org/gitbay/internal/policy" | 12 | "gitbay.org/gitbay/internal/policy" |
| 13 | "gitbay.org/gitbay/internal/protocol" | 13 | "gitbay.org/gitbay/internal/protocol" |
| 14 | "gitbay.org/gitbay/internal/store" | 14 | "gitbay.org/gitbay/internal/store" |
| 15 | "gitbay.org/gitbay/internal/suggest" | ||
| 15 | ) | 16 | ) |
| 16 | 17 | ||
| 17 | func init() { | 18 | func init() { |
| 18 | register(Command{Path: []string{"mr", "diff-comment"}, | 19 | register(Command{Path: []string{"mr", "diff-comment"}, |
| 19 | Summary: "comment on a diff line", | 20 | 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 -]", |
| 21 | Flags: []Flag{ | 22 | Flags: []Flag{ |
| 22 | {"--path", "<file>", "the file the comment is on", ""}, | 23 | {"--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", ""}, | ||
| 24 | {"--old", "", "the line is on the old side of the diff", ""}, | 26 | {"--old", "", "the line is on the old side of the diff", ""}, |
| 25 | {"--pending", "", "hold the comment for `mr review --comment`", ""}, | 27 | {"--pending", "", "hold the comment for `mr review --comment`", ""}, |
| 26 | {"--reply", "<id>", "reply to this thread instead of opening one", ""}, | 28 | {"--reply", "<id>", "reply to this thread instead of opening one", ""}, |
| @@ -30,6 +32,7 @@ func init() { | |||
| 30 | Examples: []string{ | 32 | Examples: []string{ |
| 31 | `mr diff-comment krz/gitbay 431 --path internal/control/build.go --line 42 --message "why is this a switch"`, | 33 | `mr diff-comment krz/gitbay 431 --path internal/control/build.go --line 42 --message "why is this a switch"`, |
| 32 | "mr diff-comment krz/gitbay 431 --reply 12 --file - < notes.md", | 34 | "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", | ||
| 33 | }, | 36 | }, |
| 34 | ReadsStdin: true, Run: runDiffComment}) | 37 | ReadsStdin: true, Run: runDiffComment}) |
| 35 | register(Command{Path: []string{"mr", "threads"}, | 38 | register(Command{Path: []string{"mr", "threads"}, |
| @@ -50,15 +53,15 @@ func init() { | |||
| 50 | } | 53 | } |
| 51 | 54 | ||
| 52 | func runDiffComment(c *Ctx, args []string) int { | 55 | func 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"}, |
| 54 | Bools: []string{"--old", "--pending"}, MaxPos: -1, | 57 | 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 -]"}) |
| 56 | if err != nil { | 59 | if err != nil { |
| 57 | return c.fail(protocol.ExitUsage, "%v", err) | 60 | return c.fail(protocol.ExitUsage, "%v", err) |
| 58 | } | 61 | } |
| 59 | rest := f.Pos | 62 | rest := f.Pos |
| 60 | path, message, file, old := f.Value("--path"), f.Value("--message"), f.Value("--file"), f.Has("--old") | 63 | 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 |
| 62 | if f.Has("--line") { | 65 | if f.Has("--line") { |
| 63 | n, err := strconv.ParseInt(f.Value("--line"), 10, 64) | 66 | n, err := strconv.ParseInt(f.Value("--line"), 10, 64) |
| 64 | if err != nil || n < 1 { | 67 | if err != nil || n < 1 { |
| @@ -66,6 +69,13 @@ func runDiffComment(c *Ctx, args []string) int { | |||
| 66 | } | 69 | } |
| 67 | line = n | 70 | line = n |
| 68 | } | 71 | } |
| 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 | } | ||
| 69 | if f.Has("--reply") { | 79 | if f.Has("--reply") { |
| 70 | n, err := strconv.ParseInt(f.Value("--reply"), 10, 64) | 80 | n, err := strconv.ParseInt(f.Value("--reply"), 10, 64) |
| 71 | if err != nil || n < 1 { | 81 | if err != nil || n < 1 { |
| @@ -90,6 +100,19 @@ func runDiffComment(c *Ctx, args []string) int { | |||
| 90 | if strings.TrimSpace(body) == "" { | 100 | if strings.TrimSpace(body) == "" { |
| 91 | return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -") | 101 | return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -") |
| 92 | } | 102 | } |
| 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 | } | ||
| 93 | 116 | ||
| 94 | side := "new" | 117 | side := "new" |
| 95 | if old { | 118 | if old { |
| @@ -113,10 +136,20 @@ func runDiffComment(c *Ctx, args []string) int { | |||
| 113 | if !slices.Contains(files, path) { | 136 | if !slices.Contains(files, path) { |
| 114 | return c.fail(protocol.ExitUsage, "%s is not part of this merge request's diff", path) | 137 | return c.fail(protocol.ExitUsage, "%s is not part of this merge request's diff", path) |
| 115 | } | 138 | } |
| 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 | } | ||
| 116 | } | 149 | } |
| 117 | 150 | ||
| 118 | pending := f.Has("--pending") | 151 | 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) |
| 120 | if err != nil { | 153 | if err != nil { |
| 121 | if errors.Is(err, store.ErrNotFound) { | 154 | if errors.Is(err, store.ErrNotFound) { |
| 122 | return c.fail(protocol.ExitNotFound, "%v", err) | 155 | return c.fail(protocol.ExitNotFound, "%v", err) |
| @@ -175,22 +208,25 @@ func runMRThreads(c *Ctx, args []string) int { | |||
| 175 | CreatedAt string `json:"created_at"` | 208 | CreatedAt string `json:"created_at"` |
| 176 | } | 209 | } |
| 177 | type threadOut struct { | 210 | type threadOut struct { |
| 178 | ID int64 `json:"id"` | 211 | ID int64 `json:"id"` |
| 179 | Path string `json:"path"` | 212 | Path string `json:"path"` |
| 180 | Side string `json:"side"` | 213 | Side string `json:"side"` |
| 181 | Line int64 `json:"line"` | 214 | StartLine int64 `json:"start_line,omitempty"` |
| 182 | Stale bool `json:"stale"` | 215 | Line int64 `json:"line"` |
| 183 | Resolved string `json:"resolved_by,omitempty"` | 216 | Stale bool `json:"stale"` |
| 184 | Comments []commentOut `json:"comments"` | 217 | Resolved string `json:"resolved_by,omitempty"` |
| 218 | Suggestion *SuggestionOut `json:"suggestion,omitempty"` | ||
| 219 | Comments []commentOut `json:"comments"` | ||
| 185 | } | 220 | } |
| 186 | byRoot := map[int64]*threadOut{} | 221 | byRoot := map[int64]*threadOut{} |
| 187 | var order []int64 | 222 | var order []int64 |
| 188 | for _, cm := range comments { | 223 | for _, cm := range comments { |
| 189 | if cm.ReplyTo == 0 { | 224 | if cm.ReplyTo == 0 { |
| 190 | byRoot[cm.ID] = &threadOut{ | 225 | 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, |
| 192 | Stale: cm.HeadSHA != mr.HeadSHA, Resolved: cm.ResolvedBy, | 227 | 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}}, | ||
| 194 | } | 230 | } |
| 195 | order = append(order, cm.ID) | 231 | order = append(order, cm.ID) |
| 196 | } else if th, ok := byRoot[cm.ReplyTo]; ok { | 232 | } else if th, ok := byRoot[cm.ReplyTo]; ok { |
| @@ -201,7 +237,6 @@ func runMRThreads(c *Ctx, args []string) int { | |||
| 201 | for _, id := range order { | 237 | for _, id := range order { |
| 202 | ds = append(ds, *byRoot[id]) | 238 | ds = append(ds, *byRoot[id]) |
| 203 | } | 239 | } |
| 204 | _ = repo | ||
| 205 | return c.emit(ds, func(w io.Writer) { | 240 | return c.emit(ds, func(w io.Writer) { |
| 206 | for _, th := range ds { | 241 | for _, th := range ds { |
| 207 | marks := "" | 242 | marks := "" |
| @@ -211,14 +246,44 @@ func runMRThreads(c *Ctx, args []string) int { | |||
| 211 | if th.Stale { | 246 | if th.Stale { |
| 212 | marks += " [stale]" | 247 | marks += " [stale]" |
| 213 | } | 248 | } |
| 214 | fmt.Fprintf(w, "thread %d %s:%d (%s)%s\n", th.ID, th.Path, th.Line, th.Side, marks) | 249 | lines := fmt.Sprint(th.Line) |
| 215 | for _, cm := range th.Comments { | 250 | if th.StartLine != 0 && th.StartLine != th.Line { |
| 216 | fmt.Fprintf(w, " %s: %s\n", cm.Author, cm.Body) | 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 | } | ||
| 217 | } | 265 | } |
| 218 | } | 266 | } |
| 219 | }) | 267 | }) |
| 220 | } | 268 | } |
| 221 | 269 | ||
| 270 | // writeSuggestion prints a suggestion as the diff it proposes and how to | ||
| 271 | // apply it, or why it can no longer be applied. | ||
| 272 | func 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 | |||
| 222 | func setThreadResolved(c *Ctx, args []string, resolved bool) int { | 287 | func setThreadResolved(c *Ctx, args []string, resolved bool) int { |
| 223 | if len(args) != 3 { | 288 | if len(args) != 3 { |
| 224 | return c.usage() | 289 | return c.usage() |
internal/control/mergequeue_test.go +1 −1
| @@ -232,7 +232,7 @@ func TestWhenReadyReviewMerges(t *testing.T) { | |||
| 232 | // Resolving the last open thread merges the queued request. | 232 | // Resolving the last open thread merges the queued request. |
| 233 | func TestWhenReadyThreadResolveMerges(t *testing.T) { | 233 | func TestWhenReadyThreadResolveMerges(t *testing.T) { |
| 234 | f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireResolved = true }) | 234 | 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) |
| 236 | if err != nil { | 236 | if err != nil { |
| 237 | t.Fatal(err) | 237 | t.Fatal(err) |
| 238 | } | 238 | } |
internal/control/suggestion.go added +127
| @@ -0,0 +1,127 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 18 | type 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. | ||
| 33 | const maxSuggestionBytes = maxCommitFileBytes | ||
| 34 | |||
| 35 | // Reasons a suggestion cannot be applied at a head. | ||
| 36 | const ( | ||
| 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. | ||
| 43 | func 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. | ||
| 62 | func 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. | ||
| 71 | func 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. | ||
| 103 | func 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. | ||
| 118 | func 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 15 | func 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. | ||
| 29 | func (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. | ||
| 41 | func (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 | |||
| 68 | type 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 | |||
| 76 | func (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 | |||
| 88 | func (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. | ||
| 102 | func 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. | ||
| 128 | func 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. | ||
| 153 | func 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. | ||
| 163 | func 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 { | |||
| 13 | Path string | 13 | Path string |
| 14 | Side string | 14 | Side string |
| 15 | Line int64 | 15 | Line int64 |
| 16 | StartLine int64 // first line of a range ending at Line; 0 for Line alone | ||
| 16 | Body string | 17 | Body string |
| 17 | ReplyTo int64 // 0 for thread roots | 18 | ReplyTo int64 // 0 for thread roots |
| 18 | ResolvedBy string | 19 | ResolvedBy string |
| @@ -23,8 +24,9 @@ type DiffComment struct { | |||
| 23 | } | 24 | } |
| 24 | 25 | ||
| 25 | // AddDiffComment creates a thread root (replyTo 0) or a reply. Replies | 26 | // AddDiffComment creates a thread root (replyTo 0) or a reply. Replies |
| 26 | // inherit the root's anchor and must belong to the same MR. | 27 | // inherit the root's anchor and must belong to the same MR. startLine is |
| 27 | func (s *Store) AddDiffComment(mrID, authorID int64, headSHA, path, side string, line int64, body string, replyTo int64, pending bool) (int64, error) { | 28 | // the first line of a range ending at line, or 0. |
| 29 | func (s *Store) AddDiffComment(mrID, authorID int64, headSHA, path, side string, line, startLine int64, body string, replyTo int64, pending bool) (int64, error) { | ||
| 28 | if replyTo != 0 { | 30 | if replyTo != 0 { |
| 29 | var rootMR int64 | 31 | var rootMR int64 |
| 30 | var rootReply sql.NullInt64 | 32 | var rootReply sql.NullInt64 |
| @@ -43,8 +45,8 @@ func (s *Store) AddDiffComment(mrID, authorID int64, headSHA, path, side string, | |||
| 43 | return 0, fmt.Errorf("reply to the thread root %d, not to a reply", rootReply.Int64) | 45 | return 0, fmt.Errorf("reply to the thread root %d, not to a reply", rootReply.Int64) |
| 44 | } | 46 | } |
| 45 | err = s.DB.QueryRow( | 47 | err = s.DB.QueryRow( |
| 46 | "SELECT head_sha, path, side, line FROM mr_diff_comments WHERE id = ?", replyTo). | 48 | "SELECT head_sha, path, side, line, start_line FROM mr_diff_comments WHERE id = ?", replyTo). |
| 47 | Scan(&headSHA, &path, &side, &line) | 49 | Scan(&headSHA, &path, &side, &line, &startLine) |
| 48 | if err != nil { | 50 | if err != nil { |
| 49 | return 0, err | 51 | return 0, err |
| 50 | } | 52 | } |
| @@ -54,9 +56,9 @@ func (s *Store) AddDiffComment(mrID, authorID int64, headSHA, path, side string, | |||
| 54 | reply = replyTo | 56 | reply = replyTo |
| 55 | } | 57 | } |
| 56 | res, err := s.DB.Exec(` | 58 | res, err := s.DB.Exec(` |
| 57 | INSERT INTO mr_diff_comments (mr_id, author_id, head_sha, path, side, line, body, reply_to, pending) | 59 | INSERT INTO mr_diff_comments (mr_id, author_id, head_sha, path, side, line, start_line, body, reply_to, pending) |
| 58 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, | 60 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, |
| 59 | mrID, authorID, headSHA, path, side, line, body, reply, pending) | 61 | mrID, authorID, headSHA, path, side, line, startLine, body, reply, pending) |
| 60 | if err != nil { | 62 | if err != nil { |
| 61 | return 0, err | 63 | return 0, err |
| 62 | } | 64 | } |
| @@ -68,8 +70,7 @@ func (s *Store) AddDiffComment(mrID, authorID int64, headSHA, path, side string, | |||
| 68 | // anonymous reader, who sees only what is published. | 70 | // anonymous reader, who sees only what is published. |
| 69 | func (s *Store) ListDiffComments(mrID, viewer int64) ([]DiffComment, error) { | 71 | func (s *Store) ListDiffComments(mrID, viewer int64) ([]DiffComment, error) { |
| 70 | rows, err := s.DB.Query(` | 72 | rows, err := s.DB.Query(` |
| 71 | SELECT c.id, u.username, c.head_sha, c.path, c.side, c.line, c.body, | 73 | SELECT `+diffCommentCols+` |
| 72 | COALESCE(c.reply_to, 0), COALESCE(r.username, ''), c.pending, c.created_at | ||
| 73 | FROM mr_diff_comments c | 74 | FROM mr_diff_comments c |
| 74 | JOIN users u ON u.id = c.author_id | 75 | JOIN users u ON u.id = c.author_id |
| 75 | LEFT JOIN users r ON r.id = c.resolved_by | 76 | LEFT JOIN users r ON r.id = c.resolved_by |
| @@ -81,9 +82,8 @@ func (s *Store) ListDiffComments(mrID, viewer int64) ([]DiffComment, error) { | |||
| 81 | defer rows.Close() | 82 | defer rows.Close() |
| 82 | var out []DiffComment | 83 | var out []DiffComment |
| 83 | for rows.Next() { | 84 | for rows.Next() { |
| 84 | var c DiffComment | 85 | c, err := scanDiffComment(rows) |
| 85 | if err := rows.Scan(&c.ID, &c.Author, &c.HeadSHA, &c.Path, &c.Side, &c.Line, &c.Body, | 86 | if err != nil { |
| 86 | &c.ReplyTo, &c.ResolvedBy, &c.Pending, &c.CreatedAt); err != nil { | ||
| 87 | return nil, err | 87 | return nil, err |
| 88 | } | 88 | } |
| 89 | out = append(out, c) | 89 | out = append(out, c) |
| @@ -91,6 +91,30 @@ func (s *Store) ListDiffComments(mrID, viewer int64) ([]DiffComment, error) { | |||
| 91 | return out, rows.Err() | 91 | return out, rows.Err() |
| 92 | } | 92 | } |
| 93 | 93 | ||
| 94 | const 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 | |||
| 97 | func 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. | ||
| 105 | func (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 | |||
| 94 | // SetThreadResolved resolves or unresolves a thread root. | 118 | // SetThreadResolved resolves or unresolves a thread root. |
| 95 | func (s *Store) SetThreadResolved(mrID, rootID, byUser int64, resolved bool) error { | 119 | func (s *Store) SetThreadResolved(mrID, rootID, byUser int64, resolved bool) error { |
| 96 | var q string | 120 | var q string |
internal/store/migrations/0069_thread_range.down.sql added +1
| @@ -0,0 +1 @@ | |||
| 1 | ALTER 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. | ||
| 4 | ALTER 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) { | |||
| 208 | if err := s.AddMRSystemComment(mr1.ID, uid, "merged"); err != nil { | 208 | if err := s.AddMRSystemComment(mr1.ID, uid, "merged"); err != nil { |
| 209 | t.Fatal(err) | 209 | t.Fatal(err) |
| 210 | } | 210 | } |
| 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) |
| 212 | if err != nil { | 212 | if err != nil { |
| 213 | t.Fatal(err) | 213 | t.Fatal(err) |
| 214 | } | 214 | } |
| 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 { |
| 216 | t.Fatal(err) | 216 | t.Fatal(err) |
| 217 | } | 217 | } |
| 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 { |
| 219 | t.Fatal(err) | 219 | t.Fatal(err) |
| 220 | } | 220 | } |
| 221 | 221 | ||
internal/store/pending_test.go +5 −5
| @@ -35,10 +35,10 @@ func pendingFixture(t *testing.T) (*Store, int64, int64, int64) { | |||
| 35 | // else, until they submit. | 35 | // else, until they submit. |
| 36 | func TestPendingCommentsArePrivate(t *testing.T) { | 36 | func TestPendingCommentsArePrivate(t *testing.T) { |
| 37 | s, mrID, author, other := pendingFixture(t) | 37 | 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 { |
| 39 | t.Fatal(err) | 39 | t.Fatal(err) |
| 40 | } | 40 | } |
| 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 { |
| 42 | t.Fatal(err) | 42 | t.Fatal(err) |
| 43 | } | 43 | } |
| 44 | 44 | ||
| @@ -66,7 +66,7 @@ func TestPendingCommentsArePrivate(t *testing.T) { | |||
| 66 | // so nobody else could resolve it. | 66 | // so nobody else could resolve it. |
| 67 | func TestPendingThreadsDoNotBlockMerges(t *testing.T) { | 67 | func TestPendingThreadsDoNotBlockMerges(t *testing.T) { |
| 68 | s, mrID, _, other := pendingFixture(t) | 68 | 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 { |
| 70 | t.Fatal(err) | 70 | t.Fatal(err) |
| 71 | } | 71 | } |
| 72 | n, err := s.UnresolvedThreadCount(mrID) | 72 | n, err := s.UnresolvedThreadCount(mrID) |
| @@ -88,12 +88,12 @@ func TestPendingThreadsDoNotBlockMerges(t *testing.T) { | |||
| 88 | func TestPublishAndDiscardPending(t *testing.T) { | 88 | func TestPublishAndDiscardPending(t *testing.T) { |
| 89 | s, mrID, author, other := pendingFixture(t) | 89 | s, mrID, author, other := pendingFixture(t) |
| 90 | for i := 0; i < 3; i++ { | 90 | 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 { |
| 92 | t.Fatal(err) | 92 | t.Fatal(err) |
| 93 | } | 93 | } |
| 94 | } | 94 | } |
| 95 | // Another reviewer's batch is untouched by either operation. | 95 | // 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 { |
| 97 | t.Fatal(err) | 97 | t.Fatal(err) |
| 98 | } | 98 | } |
| 99 | 99 | ||