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