internal/control/suggestion.go

v1.41.0
gitbay/internal/control/suggestion.go history · blame · raw

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}