internal/control/suggestion.go
374 lines · 13257 bytes
1package control
2
3import (
4 "bytes"
5 "errors"
6 "fmt"
7 "io"
8 "strconv"
9
10 "gitbay.org/gitbay/internal/gitutil"
11 "gitbay.org/gitbay/internal/policy"
12 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/store"
14 "gitbay.org/gitbay/internal/suggest"
15)
16
17func init() {
18 register(Command{Path: []string{"mr", "apply-suggestion"},
19 Summary: "commit a review thread's suggestion to the source branch",
20 Usage: "mr apply-suggestion <owner/name> <n> <thread-id>",
21 Examples: []string{"mr apply-suggestion krz/gitbay 431 12"},
22 Run: runMRApplySuggestion})
23}
24
25// SuggestionOut is the change a review thread's ```suggestion block
26// proposes: lines StartLine through EndLine of Path, as they were at
27// Commit (Original), replaced by Replacement. Both texts end every line
28// with its terminator, so "" is no lines. Apply is "server" where `mr
29// apply-suggestion` commits it, or "local" on a repository requiring
30// signed commits, where the CLI commits it with the user's own key.
31type SuggestionOut struct {
32 Path string `json:"path"`
33 StartLine int64 `json:"start_line"`
34 EndLine int64 `json:"end_line"`
35 Commit string `json:"commit"`
36 Blob string `json:"blob,omitempty"`
37 Original string `json:"original"`
38 Replacement string `json:"replacement"`
39 Outdated bool `json:"outdated"`
40 Reason string `json:"reason,omitempty"`
41 Apply string `json:"apply"`
42}
43
44// maxSuggestionBytes bounds the file a suggestion rewrites, the same as
45// a single-file commit over the control plane.
46const maxSuggestionBytes = maxCommitFileBytes
47
48// Reasons a suggestion cannot be applied at a head.
49const (
50 reasonGone = "the file is not at the head: it was renamed or deleted"
51 reasonChanged = "the lines it replaces have changed since it was made"
52 reasonNoBase = "the commit it was made against is no longer available"
53)
54
55// anchoredFiles reads the files suggestions anchor in, for one page or
56// listing: one ls-tree per (commit, path), and every blob through one
57// cat-file --batch process started on first use, so a merge request with
58// many suggestions on a file costs no more processes than one with a
59// single suggestion. Blobs are kept by id up to cacheCap bytes in all;
60// past that one is read again through the same process.
61type anchoredFiles struct {
62 dir string
63 entries map[[2]string]anchoredEntry
64 blobs map[string][]byte
65 cached int64
66 cacheCap int64
67 batch *gitutil.BlobBatch
68 spawned int // git processes started, for the test that bounds it
69}
70
71// anchoredCacheCap bounds the blobs one render keeps.
72const anchoredCacheCap = 8 << 20
73
74type anchoredEntry struct {
75 e gitutil.TreeEntry
76 ok bool
77}
78
79func newAnchoredFiles(dir string) *anchoredFiles {
80 return &anchoredFiles{dir: dir, entries: map[[2]string]anchoredEntry{}, blobs: map[string][]byte{},
81 cacheCap: anchoredCacheCap}
82}
83
84func (f *anchoredFiles) close() {
85 if f.batch != nil {
86 f.batch.Close()
87 }
88}
89
90// read returns the regular file path at commit, with its mode and blob id.
91func (f *anchoredFiles) read(commit, path string) ([]byte, string, string, error) {
92 key := [2]string{commit, path}
93 ent, seen := f.entries[key]
94 if !seen {
95 f.spawned++
96 ent.e, ent.ok = gitutil.StatPath(f.dir, commit, path)
97 f.entries[key] = ent
98 }
99 e := ent.e
100 if !ent.ok {
101 return nil, "", "", fmt.Errorf("%s", reasonGone)
102 }
103 if e.Type != "blob" || (e.Mode != "100644" && e.Mode != "100755") {
104 return nil, "", "", fmt.Errorf("%s is not a regular file", path)
105 }
106 if e.Size > maxSuggestionBytes {
107 return nil, "", "", fmt.Errorf("%s is larger than %d bytes", path, maxSuggestionBytes)
108 }
109 if content, ok := f.blobs[e.SHA]; ok {
110 return content, e.Mode, e.SHA, nil
111 }
112 if f.batch == nil {
113 b, err := gitutil.NewBlobBatch(f.dir)
114 if err != nil {
115 return nil, "", "", err
116 }
117 f.spawned++
118 f.batch = b
119 }
120 content, err := f.batch.Read(e.SHA, maxSuggestionBytes)
121 if err != nil {
122 return nil, "", "", err
123 }
124 if f.cached+int64(len(content)) <= f.cacheCap {
125 f.blobs[e.SHA] = content
126 f.cached += int64(len(content))
127 }
128 return content, e.Mode, e.SHA, nil
129}
130
131// readAnchored reads the regular file path at commit, with its mode.
132func readAnchored(dir, commit, path string) ([]byte, string, error) {
133 f := newAnchoredFiles(dir)
134 defer f.close()
135 content, mode, _, err := f.read(commit, path)
136 return content, mode, err
137}
138
139// suggestionRange is a thread's first and last line.
140func suggestionRange(cm store.DiffComment) (int, int) {
141 return int(firstNonZero(cm.StartLine, cm.Line)), int(cm.Line)
142}
143
144// Suggestions are the suggestions the thread roots among comments carry,
145// by thread id, each checked against the merge request's head. The
146// original lines are read from the commit the comment was made on, in
147// the target repository, which holds every head the merge request has
148// had until gc prunes an abandoned one.
149func Suggestions(st *store.Store, root string, repo store.Repo, mr store.MR, comments []store.DiffComment) map[int64]*SuggestionOut {
150 f := newAnchoredFiles(RepoDir(root, repo.OwnerName, repo.Name))
151 defer f.close()
152 return suggestionsWith(f, func() bool { return signedOnly(st, repo, mr) }, mr, comments)
153}
154
155// suggestionsWith is Suggestions reading through f. signed is asked
156// once, and only when there is a suggestion.
157func suggestionsWith(f *anchoredFiles, signed func() bool, mr store.MR, comments []store.DiffComment) map[int64]*SuggestionOut {
158 out := map[int64]*SuggestionOut{}
159 apply := ""
160 for _, cm := range comments {
161 if cm.ReplyTo != 0 || cm.Side != "new" {
162 continue
163 }
164 lines, found, err := suggest.Parse(cm.Body)
165 if err != nil || !found {
166 continue
167 }
168 if apply == "" {
169 apply = "server"
170 if signed() {
171 apply = "local"
172 }
173 }
174 start, end := suggestionRange(cm)
175 s := &SuggestionOut{Path: cm.Path, StartLine: int64(start), EndLine: int64(end), Commit: cm.HeadSHA,
176 Replacement: suggest.Text(lines), Apply: apply}
177 out[cm.ID] = s
178 base, _, blob, err := f.read(cm.HeadSHA, cm.Path)
179 s.Blob = blob
180 orig, ok := suggest.Range(base, start, end)
181 if err != nil || !ok {
182 s.Outdated, s.Reason = true, reasonNoBase
183 continue
184 }
185 s.Original = string(orig)
186 s.Reason = anchorReason(f, mr.HeadSHA, s)
187 s.Outdated = s.Reason != ""
188 }
189 return out
190}
191
192// anchorReason says why s cannot be applied to the file at head, or ""
193// when the lines it replaces are still what it was made against.
194func anchorReason(f *anchoredFiles, head string, s *SuggestionOut) string {
195 content, _, _, err := f.read(head, s.Path)
196 if err != nil {
197 return err.Error()
198 }
199 now, ok := suggest.Range(content, int(s.StartLine), int(s.EndLine))
200 if !ok || !bytes.Equal(now, []byte(s.Original)) {
201 return reasonChanged
202 }
203 return ""
204}
205
206// signedOnly reports whether the server may not commit a suggestion for
207// this merge request: the source branch or the target it merges into
208// requires signed commits, and the server has no key to sign with.
209func signedOnly(st *store.Store, repo store.Repo, mr store.MR) bool {
210 if repo.Settings.RequireSignedCommits {
211 return true
212 }
213 if mr.SourceRepoID == repo.ID {
214 return false
215 }
216 src, err := st.RepoByID(mr.SourceRepoID)
217 return err != nil || src.Settings.RequireSignedCommits
218}
219
220// runMRApplySuggestion commits a thread's suggestion to the merge
221// request's source branch as the caller, and resolves the thread.
222//
223// It is a push by the caller, and is held to what a push is: the caller
224// needs write on the source repository (for a fork, the fork's writers;
225// write on the target grants nothing there), the update goes through the
226// pre-receive ref policy (policy.CheckPush: protected branches,
227// require-mr) before a compare-and-swap ref update, and RefsUpdated then
228// does what post-receive does, which moves the merge request's head and
229// reaches a queued merge. The server has no signing key, so where either
230// repository requires signed commits it refuses, and the CLI applies the
231// suggestion locally instead.
232func runMRApplySuggestion(c *Ctx, args []string) int {
233 if len(args) != 3 {
234 return c.usage()
235 }
236 repo, mr, code := mrRef(c, args[:2], policy.CanRead)
237 if code >= 0 {
238 return code
239 }
240 if code := refuseArchived(c, repo); code >= 0 {
241 return code
242 }
243 threadID, err := strconv.ParseInt(args[2], 10, 64)
244 if err != nil {
245 return c.fail(protocol.ExitUsage, "bad thread id %q", args[2])
246 }
247 cm, err := c.Store.DiffCommentByID(mr.ID, threadID)
248 if errors.Is(err, store.ErrNotFound) || (err == nil && cm.Pending && cm.Author != c.User.Username) {
249 return c.fail(protocol.ExitNotFound, "no thread %d on %s!%d", threadID, repo.Path(), mr.Number)
250 }
251 if err != nil {
252 return c.failErr(err)
253 }
254 if cm.ReplyTo != 0 {
255 return c.fail(protocol.ExitUsage, "%d is a reply; name the thread root %d", threadID, cm.ReplyTo)
256 }
257 if cm.Pending {
258 return c.fail(protocol.ExitUsage, "thread %d is in your unsubmitted review; submit it with `mr review` first", threadID)
259 }
260 if mr.State != "open" {
261 return c.fail(protocol.ExitUsage, "%s!%d is %s", repo.Path(), mr.Number, mr.State)
262 }
263 s := Suggestions(c.Store, c.Cfg.Server.Root, repo, mr, []store.DiffComment{cm})[cm.ID]
264 if s == nil {
265 return c.fail(protocol.ExitUsage, "thread %d carries no suggestion", threadID)
266 }
267
268 src, err := c.Store.RepoByID(mr.SourceRepoID)
269 if err != nil {
270 return c.failErr(err)
271 }
272 grant, err := c.Store.AccessRole(src.ID, c.User.ID)
273 if err != nil {
274 return c.failErr(err)
275 }
276 source := mr.SourceRef
277 if src.ID != repo.ID {
278 source = src.Path() + ":" + mr.SourceRef
279 }
280 if !policy.CanWrite(c.User, src, grant) {
281 return c.fail(protocol.ExitDenied, "applying a suggestion pushes to %s; only its writers can", source)
282 }
283 if src.Settings.Archived {
284 return c.fail(protocol.ExitDenied, "%s is archived and read-only", src.Path())
285 }
286 if mirrored, err := c.Store.PullMirrored(src.ID); err != nil {
287 return c.failErr(err)
288 } else if mirrored {
289 return c.fail(protocol.ExitDenied, "%s is a pull mirror: its refs come from the upstream", src.Path())
290 }
291 if s.Apply == "local" {
292 return c.fail(protocol.ExitDenied,
293 "%s requires signed commits and the server cannot sign one; apply it from a clone, which commits with your own key: gitbay mr apply-suggestion %s %d %d",
294 source, repo.Path(), mr.Number, threadID)
295 }
296 if code := checkStorageQuota(c, src); code >= 0 {
297 return code
298 }
299 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
300 if err != nil {
301 return c.failErr(err)
302 }
303 if email == "" {
304 return c.fail(protocol.ExitDenied, "commits carry your identity: your account needs a verified primary email")
305 }
306
307 srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name)
308 ref := "refs/heads/" + mr.SourceRef
309 tip, err := gitutil.ResolveRef(srcDir, ref)
310 if err != nil {
311 return c.fail(protocol.ExitUsage, "the source branch %s is gone", source)
312 }
313 if s.Reason == reasonNoBase {
314 return c.fail(protocol.ExitUsage, "suggestion in thread %d cannot be applied: %s", threadID, s.Reason)
315 }
316 files := newAnchoredFiles(srcDir)
317 defer files.close()
318 if reason := anchorReason(files, tip, s); reason != "" {
319 return c.fail(protocol.ExitUsage, "suggestion in thread %d is outdated: %s", threadID, reason)
320 }
321 content, mode, _, err := files.read(tip, s.Path)
322 if err != nil {
323 return c.fail(protocol.ExitUsage, "%v", err)
324 }
325 updated, err := suggest.Apply(content, int(s.StartLine), int(s.EndLine), suggest.FromText(s.Replacement))
326 if err != nil {
327 return c.fail(protocol.ExitUsage, "%v", err)
328 }
329 if bytes.Equal(updated, content) {
330 return c.fail(protocol.ExitUsage, "the suggestion in thread %d changes nothing", threadID)
331 }
332 message := suggest.Message(repo.Path(), mr.Number, threadID, cm.Author)
333 sha, err := gitutil.CommitWithFile(srcDir, tip, s.Path, mode, updated, c.User.Username, email, message)
334 if err != nil {
335 return c.failErr(err)
336 }
337 updates := []policy.RefUpdate{{Ref: ref, Old: tip, New: sha}}
338 if msg := policy.CheckPush(src, updates); msg != "" {
339 return c.fail(protocol.ExitDenied, "%s", msg)
340 }
341 if err := gitutil.UpdateRefCAS(srcDir, ref, sha, tip); err != nil {
342 return c.fail(protocol.ExitFailure, "the source branch moved; reload and retry")
343 }
344 RefsUpdated(c.Store, c.Cfg, src.ID, c.User.ID, c.Scope, updates)
345
346 // The commit has landed, so from here nothing fails the command: a
347 // thread that cannot be resolved is left open and the output says so.
348 resolved, warning := false, ""
349 switch ok, err := canResolveThread(c, repo, mr, threadID); {
350 case err != nil:
351 warning = fmt.Sprintf("thread %d is still open: %v", threadID, err)
352 case !ok:
353 warning = fmt.Sprintf("thread %d is still open: %s", threadID, cannotResolve)
354 default:
355 if err := c.Store.SetThreadResolved(mr.ID, threadID, c.User.ID, true); err != nil {
356 warning = fmt.Sprintf("thread %d is still open: %v", threadID, err)
357 } else {
358 resolved = true
359 TryQueuedMerge(c.Store, c.Cfg, mr.ID)
360 }
361 }
362 d := map[string]any{"thread": threadID, "sha": sha, "source": source, "resolved": resolved}
363 if warning != "" {
364 d["warning"] = warning
365 }
366 return c.emit(d, func(w io.Writer) {
367 if resolved {
368 fmt.Fprintf(w, "applied thread %d to %s at %.10s; thread resolved\n", threadID, source, sha)
369 return
370 }
371 fmt.Fprintf(w, "applied thread %d to %s at %.10s\n", threadID, source, sha)
372 fmt.Fprintln(c.Stderr, "warning:", warning)
373 })
374}