internal/control/diffcomment.go
348 lines · 12248 bytes
10 symbols in this file
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "slices"
8 "strconv"
9 "strings"
10
11 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/policy"
13 "gitbay.org/gitbay/internal/protocol"
14 "gitbay.org/gitbay/internal/store"
15 "gitbay.org/gitbay/internal/suggest"
16)
17
18func init() {
19 register(Command{Path: []string{"mr", "diff-comment"},
20 Summary: "comment on a diff line",
21 Usage: "mr diff-comment <owner/name> <n> --path <file> --line <l> [--start-line <s>] [--old] [--pending] [--reply <id>] [--message <m> | --file -]",
22 Flags: []Flag{
23 {"--path", "<file>", "the file 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", ""},
26 {"--old", "", "the line is on the old side of the diff", ""},
27 {"--pending", "", "hold the comment for `mr review --comment`", ""},
28 {"--reply", "<id>", "reply to this thread instead of opening one", ""},
29 {"--message", "<m>", "the comment's text", ""},
30 {"--file", "-", "read the comment from stdin", ""},
31 },
32 Examples: []string{
33 `mr diff-comment krz/gitbay 431 --path internal/control/build.go --line 42 --message "why is this a switch"`,
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",
36 },
37 ReadsStdin: true, Run: runDiffComment})
38 register(Command{Path: []string{"mr", "threads"},
39 Summary: "review threads on an MR",
40 Usage: "mr threads <owner/name> <n>",
41 Examples: []string{"mr threads krz/gitbay 431"},
42 ReadOnly: true, Run: runMRThreads})
43 register(Command{Path: []string{"mr", "resolve"},
44 Summary: "resolve a review thread",
45 Usage: "mr resolve <owner/name> <n> <thread-id>",
46 Examples: []string{"mr resolve krz/gitbay 431 12"},
47 Run: runMRResolve})
48 register(Command{Path: []string{"mr", "unresolve"},
49 Summary: "reopen a review thread",
50 Usage: "mr unresolve <owner/name> <n> <thread-id>",
51 Examples: []string{"mr unresolve krz/gitbay 431 12"},
52 Run: runMRUnresolve})
53}
54
55func runDiffComment(c *Ctx, args []string) int {
56 f, err := c.parseArgs(args, flagSpec{Values: []string{"--path", "--line", "--start-line", "--reply", "--message", "--file"},
57 Bools: []string{"--old", "--pending"}, MaxPos: -1,
58 Usage: "mr diff-comment <owner/name> <n> --path <file> --line <l> [--start-line <s>] [--old] [--pending] [--reply <id>] [--message <m> | --file -]"})
59 if err != nil {
60 return c.fail(protocol.ExitUsage, "%v", err)
61 }
62 rest := f.Pos
63 path, message, file, old := f.Value("--path"), f.Value("--message"), f.Value("--file"), f.Has("--old")
64 var line, startLine, replyTo int64
65 if f.Has("--line") {
66 n, err := strconv.ParseInt(f.Value("--line"), 10, 64)
67 if err != nil || n < 1 {
68 return c.fail(protocol.ExitUsage, "--line must be a positive number")
69 }
70 line = n
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 }
79 if f.Has("--reply") {
80 n, err := strconv.ParseInt(f.Value("--reply"), 10, 64)
81 if err != nil || n < 1 {
82 return c.fail(protocol.ExitUsage, "--reply must be a thread id")
83 }
84 replyTo = n
85 }
86 repo, mr, code := mrRef(c, rest, policy.CanRead)
87 if code >= 0 {
88 return code
89 }
90 if code := refuseArchived(c, repo); code >= 0 {
91 return code
92 }
93 if replyTo == 0 && (path == "" || line == 0) {
94 return c.fail(protocol.ExitUsage, "a new thread needs --path and --line (or reply to one with --reply <id>)")
95 }
96 body, err := bodyFrom(c, message, file)
97 if err != nil {
98 return c.failInput(err)
99 }
100 if strings.TrimSpace(body) == "" {
101 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
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 }
116
117 side := "new"
118 if old {
119 side = "old"
120 }
121 if replyTo == 0 {
122 // The path must actually be part of the MR's diff.
123 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
124 base := mr.MergedBase
125 if base == "" {
126 b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mrHeadRef(mr.Number))
127 if err != nil {
128 return c.fail(protocol.ExitFailure, "%v", err)
129 }
130 base = b
131 }
132 files, err := gitutil.DiffFiles(dir, base, mrHeadRef(mr.Number))
133 if err != nil {
134 return c.fail(protocol.ExitFailure, "%v", err)
135 }
136 if !slices.Contains(files, path) {
137 return c.fail(protocol.ExitUsage, "%s is not part of this merge request's diff", path)
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 }
149 }
150
151 pending := f.Has("--pending")
152 id, err := c.Store.AddDiffComment(mr.ID, c.User.ID, mr.HeadSHA, path, side, line, startLine, body, replyTo, pending)
153 if err != nil {
154 if errors.Is(err, store.ErrNotFound) {
155 return c.fail(protocol.ExitNotFound, "%v", err)
156 }
157 return c.failErr(err)
158 }
159 // A pending comment is not part of the conversation yet, so it does
160 // not reach anyone's inbox. `mr review` is what says it out loud.
161 if !pending {
162 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
163 notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number,
164 subject: mrSubject(repo, mr.Number, mr.Title),
165 action: fmt.Sprintf("commented on %s:%d in !%d", path, line, mr.Number),
166 excerpt: body, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
167 }
168 notifyMentions(c, repo, mrThread, mr.ID, mr.Number, mr.Title, body)
169 }
170 return c.emit(map[string]any{"id": id, "thread": firstNonZero(replyTo, id), "pending": pending}, func(w io.Writer) {
171 what := "thread %d opened on %s:%d in %s!%d\n"
172 if replyTo != 0 {
173 fmt.Fprintf(w, "replied to thread %d on %s!%d\n", replyTo, repo.Path(), mr.Number)
174 } else {
175 fmt.Fprintf(w, what, id, path, line, repo.Path(), mr.Number)
176 }
177 if pending {
178 n := c.Store.CountPendingComments(mr.ID, c.User.ID)
179 fmt.Fprintf(w, "pending: %d comment(s) in this review, submit with `gitbay mr review %s %d --comment`\n",
180 n, repo.Path(), mr.Number)
181 }
182 })
183}
184
185func firstNonZero(a, b int64) int64 {
186 if a != 0 {
187 return a
188 }
189 return b
190}
191
192func runMRThreads(c *Ctx, args []string) int {
193 repo, mr, code := mrRef(c, args, policy.CanRead)
194 if code >= 0 {
195 return code
196 }
197 if len(args) != 2 {
198 return c.usage()
199 }
200 comments, err := c.Store.ListDiffComments(mr.ID, c.User.ID)
201 if err != nil {
202 return c.fail(protocol.ExitFailure, "%v", err)
203 }
204 type commentOut struct {
205 ID int64 `json:"id"`
206 Author string `json:"author"`
207 Body string `json:"body"`
208 CreatedAt string `json:"created_at"`
209 }
210 type threadOut struct {
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"`
220 }
221 suggestions := Suggestions(c.Store, c.Cfg.Server.Root, repo, mr, comments)
222 byRoot := map[int64]*threadOut{}
223 var order []int64
224 for _, cm := range comments {
225 if cm.ReplyTo == 0 {
226 byRoot[cm.ID] = &threadOut{
227 ID: cm.ID, Path: cm.Path, Side: cm.Side, StartLine: cm.StartLine, Line: cm.Line,
228 Stale: cm.HeadSHA != mr.HeadSHA, Resolved: cm.ResolvedBy,
229 Suggestion: suggestions[cm.ID],
230 Comments: []commentOut{{cm.ID, cm.Author, cm.Body, cm.CreatedAt}},
231 }
232 order = append(order, cm.ID)
233 } else if th, ok := byRoot[cm.ReplyTo]; ok {
234 th.Comments = append(th.Comments, commentOut{cm.ID, cm.Author, cm.Body, cm.CreatedAt})
235 }
236 }
237 var ds []threadOut
238 for _, id := range order {
239 ds = append(ds, *byRoot[id])
240 }
241 return c.emit(ds, func(w io.Writer) {
242 for _, th := range ds {
243 marks := ""
244 if th.Resolved != "" {
245 marks += " [resolved by " + th.Resolved + "]"
246 }
247 if th.Stale {
248 marks += " [stale]"
249 }
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 }
266 }
267 }
268 })
269}
270
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
288func setThreadResolved(c *Ctx, args []string, resolved bool) int {
289 if len(args) != 3 {
290 return c.usage()
291 }
292 repo, mr, code := mrRef(c, args[:2], policy.CanRead)
293 if code >= 0 {
294 return code
295 }
296 if code := refuseArchived(c, repo); code >= 0 {
297 return code
298 }
299 threadID, err := strconv.ParseInt(args[2], 10, 64)
300 if err != nil {
301 return c.fail(protocol.ExitUsage, "bad thread id %q", args[2])
302 }
303 ok, err := canResolveThread(c, repo, mr, threadID)
304 if errors.Is(err, store.ErrNotFound) {
305 return c.fail(protocol.ExitNotFound, "no thread %d on %s!%d", threadID, repo.Path(), mr.Number)
306 }
307 if err != nil {
308 return c.fail(protocol.ExitFailure, "%v", err)
309 }
310 if !ok {
311 return c.fail(protocol.ExitDenied, "%s", cannotResolve)
312 }
313 if err := c.Store.SetThreadResolved(mr.ID, threadID, c.User.ID, resolved); err != nil {
314 if errors.Is(err, store.ErrNotFound) {
315 return c.fail(protocol.ExitNotFound, "no thread %d (replies cannot be resolved; use the root id)", threadID)
316 }
317 return c.fail(protocol.ExitFailure, "%v", err)
318 }
319 if resolved {
320 TryQueuedMerge(c.Store, c.Cfg, mr.ID)
321 }
322 verb := "resolved"
323 if !resolved {
324 verb = "reopened"
325 }
326 return c.emit(map[string]any{"thread": threadID, "resolved": resolved}, func(w io.Writer) {
327 fmt.Fprintf(w, "%s thread %d on %s!%d\n", verb, threadID, repo.Path(), mr.Number)
328 })
329}
330
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
347func runMRResolve(c *Ctx, args []string) int { return setThreadResolved(c, args, true) }
348func runMRUnresolve(c *Ctx, args []string) int { return setThreadResolved(c, args, false) }