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