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