internal/control/diffcomment.go
268 lines · 9077 bytes
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)
16
17func init() {
18 register(Command{Path: []string{"mr", "diff-comment"},
19 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 Flags: []Flag{
22 {"--path", "<file>", "the file the comment is on", ""},
23 {"--line", "<l>", "the line the comment is on", ""},
24 {"--old", "", "the line is on the old side of the diff", ""},
25 {"--pending", "", "hold the comment for `mr review --comment`", ""},
26 {"--reply", "<id>", "reply to this thread instead of opening one", ""},
27 {"--message", "<m>", "the comment's text", ""},
28 {"--file", "-", "read the comment from stdin", ""},
29 },
30 Examples: []string{
31 `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",
33 },
34 ReadsStdin: true, Run: runDiffComment})
35 register(Command{Path: []string{"mr", "threads"},
36 Summary: "review threads on an MR",
37 Usage: "mr threads <owner/name> <n>",
38 Examples: []string{"mr threads krz/gitbay 431"},
39 ReadOnly: true, Run: runMRThreads})
40 register(Command{Path: []string{"mr", "resolve"},
41 Summary: "resolve a review thread",
42 Usage: "mr resolve <owner/name> <n> <thread-id>",
43 Examples: []string{"mr resolve krz/gitbay 431 12"},
44 Run: runMRResolve})
45 register(Command{Path: []string{"mr", "unresolve"},
46 Summary: "reopen a review thread",
47 Usage: "mr unresolve <owner/name> <n> <thread-id>",
48 Examples: []string{"mr unresolve krz/gitbay 431 12"},
49 Run: runMRUnresolve})
50}
51
52func runDiffComment(c *Ctx, args []string) int {
53 f, err := parseFlags(args, flagSpec{Values: []string{"--path", "--line", "--reply", "--message", "--file"},
54 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 -]"})
56 if err != nil {
57 return c.fail(protocol.ExitUsage, "%v", err)
58 }
59 rest := f.Pos
60 path, message, file, old := f.Value("--path"), f.Value("--message"), f.Value("--file"), f.Has("--old")
61 var line, replyTo int64
62 if f.Has("--line") {
63 n, err := strconv.ParseInt(f.Value("--line"), 10, 64)
64 if err != nil || n < 1 {
65 return c.fail(protocol.ExitUsage, "--line must be a positive number")
66 }
67 line = n
68 }
69 if f.Has("--reply") {
70 n, err := strconv.ParseInt(f.Value("--reply"), 10, 64)
71 if err != nil || n < 1 {
72 return c.fail(protocol.ExitUsage, "--reply must be a thread id")
73 }
74 replyTo = n
75 }
76 repo, mr, code := mrRef(c, rest, policy.CanRead)
77 if code >= 0 {
78 return code
79 }
80 if code := refuseArchived(c, repo); code >= 0 {
81 return code
82 }
83 if replyTo == 0 && (path == "" || line == 0) {
84 return c.fail(protocol.ExitUsage, "a new thread needs --path and --line (or reply to one with --reply <id>)")
85 }
86 body, err := bodyFrom(c, message, file)
87 if err != nil {
88 return c.failInput(err)
89 }
90 if strings.TrimSpace(body) == "" {
91 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
92 }
93
94 side := "new"
95 if old {
96 side = "old"
97 }
98 if replyTo == 0 {
99 // The path must actually be part of the MR's diff.
100 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
101 base := mr.MergedBase
102 if base == "" {
103 b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mrHeadRef(mr.Number))
104 if err != nil {
105 return c.fail(protocol.ExitFailure, "%v", err)
106 }
107 base = b
108 }
109 files, err := gitutil.DiffFiles(dir, base, mrHeadRef(mr.Number))
110 if err != nil {
111 return c.fail(protocol.ExitFailure, "%v", err)
112 }
113 if !slices.Contains(files, path) {
114 return c.fail(protocol.ExitUsage, "%s is not part of this merge request's diff", path)
115 }
116 }
117
118 pending := f.Has("--pending")
119 id, err := c.Store.AddDiffComment(mr.ID, c.User.ID, mr.HeadSHA, path, side, line, body, replyTo, pending)
120 if err != nil {
121 if errors.Is(err, store.ErrNotFound) {
122 return c.fail(protocol.ExitNotFound, "%v", err)
123 }
124 return c.failErr(err)
125 }
126 // A pending comment is not part of the conversation yet, so it does
127 // not reach anyone's inbox. `mr review` is what says it out loud.
128 if !pending {
129 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
130 notify(c, parts, notice{repo: repo, kind: "mr",
131 subject: mrSubject(repo, mr.Number, mr.Title),
132 action: fmt.Sprintf("commented on %s:%d in !%d", path, line, mr.Number),
133 excerpt: body, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
134 }
135 notifyMentions(c, repo, mrThread, mr.ID, mr.Number, mr.Title, body)
136 }
137 return c.emit(map[string]any{"id": id, "thread": firstNonZero(replyTo, id), "pending": pending}, func(w io.Writer) {
138 what := "thread %d opened on %s:%d in %s!%d\n"
139 if replyTo != 0 {
140 fmt.Fprintf(w, "replied to thread %d on %s!%d\n", replyTo, repo.Path(), mr.Number)
141 } else {
142 fmt.Fprintf(w, what, id, path, line, repo.Path(), mr.Number)
143 }
144 if pending {
145 n := c.Store.CountPendingComments(mr.ID, c.User.ID)
146 fmt.Fprintf(w, "pending: %d comment(s) in this review, submit with `gitbay mr review %s %d --comment`\n",
147 n, repo.Path(), mr.Number)
148 }
149 })
150}
151
152func firstNonZero(a, b int64) int64 {
153 if a != 0 {
154 return a
155 }
156 return b
157}
158
159func runMRThreads(c *Ctx, args []string) int {
160 repo, mr, code := mrRef(c, args, policy.CanRead)
161 if code >= 0 {
162 return code
163 }
164 if len(args) != 2 {
165 return c.usage()
166 }
167 comments, err := c.Store.ListDiffComments(mr.ID, c.User.ID)
168 if err != nil {
169 return c.fail(protocol.ExitFailure, "%v", err)
170 }
171 type commentOut struct {
172 ID int64 `json:"id"`
173 Author string `json:"author"`
174 Body string `json:"body"`
175 CreatedAt string `json:"created_at"`
176 }
177 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"`
185 }
186 byRoot := map[int64]*threadOut{}
187 var order []int64
188 for _, cm := range comments {
189 if cm.ReplyTo == 0 {
190 byRoot[cm.ID] = &threadOut{
191 ID: cm.ID, Path: cm.Path, Side: cm.Side, Line: cm.Line,
192 Stale: cm.HeadSHA != mr.HeadSHA, Resolved: cm.ResolvedBy,
193 Comments: []commentOut{{cm.ID, cm.Author, cm.Body, cm.CreatedAt}},
194 }
195 order = append(order, cm.ID)
196 } else if th, ok := byRoot[cm.ReplyTo]; ok {
197 th.Comments = append(th.Comments, commentOut{cm.ID, cm.Author, cm.Body, cm.CreatedAt})
198 }
199 }
200 var ds []threadOut
201 for _, id := range order {
202 ds = append(ds, *byRoot[id])
203 }
204 _ = repo
205 return c.emit(ds, func(w io.Writer) {
206 for _, th := range ds {
207 marks := ""
208 if th.Resolved != "" {
209 marks += " [resolved by " + th.Resolved + "]"
210 }
211 if th.Stale {
212 marks += " [stale]"
213 }
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)
217 }
218 }
219 })
220}
221
222func setThreadResolved(c *Ctx, args []string, resolved bool) int {
223 if len(args) != 3 {
224 return c.usage()
225 }
226 repo, mr, code := mrRef(c, args[:2], policy.CanRead)
227 if code >= 0 {
228 return code
229 }
230 if code := refuseArchived(c, repo); code >= 0 {
231 return code
232 }
233 threadID, err := strconv.ParseInt(args[2], 10, 64)
234 if err != nil {
235 return c.fail(protocol.ExitUsage, "bad thread id %q", args[2])
236 }
237 // Thread author, MR author, or anyone with write may resolve.
238 author, err := c.Store.DiffCommentAuthor(mr.ID, threadID)
239 if errors.Is(err, store.ErrNotFound) {
240 return c.fail(protocol.ExitNotFound, "no thread %d on %s!%d", threadID, repo.Path(), mr.Number)
241 }
242 if err != nil {
243 return c.fail(protocol.ExitFailure, "%v", err)
244 }
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")
251 }
252 if err := c.Store.SetThreadResolved(mr.ID, threadID, c.User.ID, resolved); err != nil {
253 if errors.Is(err, store.ErrNotFound) {
254 return c.fail(protocol.ExitNotFound, "no thread %d (replies cannot be resolved; use the root id)", threadID)
255 }
256 return c.fail(protocol.ExitFailure, "%v", err)
257 }
258 verb := "resolved"
259 if !resolved {
260 verb = "reopened"
261 }
262 return c.emit(map[string]any{"thread": threadID, "resolved": resolved}, func(w io.Writer) {
263 fmt.Fprintf(w, "%s thread %d on %s!%d\n", verb, threadID, repo.Path(), mr.Number)
264 })
265}
266
267func runMRResolve(c *Ctx, args []string) int { return setThreadResolved(c, args, true) }
268func runMRUnresolve(c *Ctx, args []string) int { return setThreadResolved(c, args, false) }