internal/control/mr.go

b937a769f31481aa5f2e4e70711dea53e22ce6da
gitbay/internal/control/mr.go history · blame · raw

999 lines · 33381 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{"repo", "fork"},
 19		Summary: "fork a repository under your account: repo fork <owner/name> [--name <n>]", Run: runRepoFork})
 20	register(Command{Path: []string{"repo", "settings", "require-approvals"},
 21		Summary: "require N fresh approvals to merge: repo settings require-approvals <owner/name> <n> (0 = off)", Run: runRequireApprovals})
 22	register(Command{Path: []string{"repo", "settings", "require-resolved"},
 23		Summary: "require all review threads resolved to merge: repo settings require-resolved <owner/name> on|off", Run: runRequireResolved})
 24	register(Command{Path: []string{"repo", "settings", "require-checks"},
 25		Summary: "gate merges on green statuses: repo settings require-checks <owner/name> on|off", Run: runRequireChecks})
 26	register(Command{Path: []string{"repo", "settings", "require-signed"},
 27		Summary: "require verified commit signatures: repo settings require-signed <owner/name> on|off", Run: runRequireSigned})
 28	register(Command{Path: []string{"mr", "create"},
 29		Summary:    "open a merge request: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -]",
 30		ReadsStdin: true, Run: runMRCreate})
 31	register(Command{Path: []string{"mr", "list"},
 32		Summary: "list merge requests: mr list <owner/name> [--state open|merged|closed|source_gone|all]", ReadOnly: true, Run: runMRList})
 33	register(Command{Path: []string{"mr", "show"},
 34		Summary: "show a merge request: mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow})
 35	register(Command{Path: []string{"mr", "diff"},
 36		Summary: "show the diff: mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff})
 37	register(Command{Path: []string{"mr", "edit"},
 38		Summary:    "edit title or body: mr edit <owner/name> <n> [--title <t>] [--body <b> | --file -]",
 39		ReadsStdin: true, Run: runMREdit})
 40	register(Command{Path: []string{"mr", "comment"},
 41		Summary:    "comment: mr comment <owner/name> <n> [--message <m> | --file -]",
 42		ReadsStdin: true, Run: runMRComment})
 43	register(Command{Path: []string{"mr", "review"},
 44		Summary: "review: mr review <owner/name> <n> --approve|--request-changes|--comment", Run: runMRReview})
 45	register(Command{Path: []string{"mr", "merge"},
 46		Summary: "merge: mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Run: runMRMerge})
 47	register(Command{Path: []string{"mr", "close"},
 48		Summary: "close without merging: mr close <owner/name> <n>", Run: runMRClose})
 49}
 50
 51func runRepoFork(c *Ctx, args []string) int {
 52	var path, name string
 53	for i := 0; i < len(args); i++ {
 54		switch args[i] {
 55		case "--name":
 56			if i+1 >= len(args) {
 57				return c.fail(protocol.ExitUsage, "--name requires a value")
 58			}
 59			name = args[i+1]
 60			i++
 61		default:
 62			if path != "" {
 63				return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]")
 64			}
 65			path = args[i]
 66		}
 67	}
 68	if path == "" {
 69		return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]")
 70	}
 71	src, code := resolveRepo(c, path, policy.CanRead)
 72	if code >= 0 {
 73		return code
 74	}
 75	if name == "" {
 76		name = src.Name
 77	}
 78	if err := policy.ValidateName(name); err != nil {
 79		return c.fail(protocol.ExitUsage, "%v", err)
 80	}
 81	id, err := c.Store.CreateRepo("user", c.User.ID, name, src.Visibility)
 82	if err != nil {
 83		return c.fail(protocol.ExitFailure, "%v", err)
 84	}
 85	if err := c.Store.SetForkOf(id, src.ID); err != nil {
 86		return c.fail(protocol.ExitFailure, "%v", err)
 87	}
 88	dstDir := RepoDir(c.Cfg.Server.Root, c.User.Username, name)
 89	srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name)
 90	if err := gitutil.InitBare(dstDir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
 91		c.Store.DeleteRepo(id)
 92		return c.fail(protocol.ExitFailure, "%v", err)
 93	}
 94	if desc := gitutil.ReadDescription(srcDir); desc != "" {
 95		gitutil.WriteDescription(dstDir, desc)
 96	}
 97	if err := gitutil.FetchInto(dstDir, srcDir, "refs/heads/*", "refs/heads/*"); err != nil {
 98		// Empty source repos have nothing to fetch; that is fine.
 99		if _, rerr := gitutil.ResolveRef(srcDir, src.DefaultBranch); rerr == nil {
100			c.Store.DeleteRepo(id)
101			return c.fail(protocol.ExitFailure, "copying refs: %v", err)
102		}
103	}
104	forkPath := c.User.Username + "/" + name
105	return c.emit(map[string]string{"path": forkPath, "fork_of": src.Path()}, func(w io.Writer) {
106		fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath)
107	})
108}
109
110func runRequireApprovals(c *Ctx, args []string) int {
111	if len(args) != 2 {
112		return c.fail(protocol.ExitUsage, "usage: repo settings require-approvals <owner/name> <n>")
113	}
114	n, err := strconv.Atoi(args[1])
115	if err != nil || n < 0 || n > 20 {
116		return c.fail(protocol.ExitUsage, "approvals must be 0..20")
117	}
118	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
119	if code >= 0 {
120		return code
121	}
122	s := repo.Settings
123	s.RequireApprovals = n
124	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
125		return c.fail(protocol.ExitFailure, "%v", err)
126	}
127	return c.emit(s, func(w io.Writer) {
128		fmt.Fprintf(w, "require_approvals %d on %s\n", n, repo.Path())
129	})
130}
131
132func runRequireResolved(c *Ctx, args []string) int {
133	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
134		return c.fail(protocol.ExitUsage, "usage: repo settings require-resolved <owner/name> on|off")
135	}
136	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
137	if code >= 0 {
138		return code
139	}
140	s := repo.Settings
141	s.RequireResolved = args[1] == "on"
142	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
143		return c.fail(protocol.ExitFailure, "%v", err)
144	}
145	return c.emit(s, func(w io.Writer) {
146		fmt.Fprintf(w, "require_resolved %s on %s\n", args[1], repo.Path())
147	})
148}
149
150func runRequireChecks(c *Ctx, args []string) int {
151	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
152		return c.fail(protocol.ExitUsage, "usage: repo settings require-checks <owner/name> on|off")
153	}
154	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
155	if code >= 0 {
156		return code
157	}
158	s := repo.Settings
159	s.RequireChecks = args[1] == "on"
160	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
161		return c.fail(protocol.ExitFailure, "%v", err)
162	}
163	return c.emit(s, func(w io.Writer) {
164		fmt.Fprintf(w, "require_checks %s on %s\n", args[1], repo.Path())
165	})
166}
167
168func runRequireSigned(c *Ctx, args []string) int {
169	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
170		return c.fail(protocol.ExitUsage, "usage: repo settings require-signed <owner/name> on|off")
171	}
172	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
173	if code >= 0 {
174		return code
175	}
176	s := repo.Settings
177	s.RequireSignedCommits = args[1] == "on"
178	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
179		return c.fail(protocol.ExitFailure, "%v", err)
180	}
181	return c.emit(s, func(w io.Writer) {
182		fmt.Fprintf(w, "require_signed_commits %s on %s\n", args[1], repo.Path())
183	})
184}
185
186// mrRef parses "<owner/name> <n>" and loads the MR.
187func mrRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.MR, int) {
188	if len(args) < 2 {
189		return store.Repo{}, store.MR{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>")
190	}
191	repo, code := resolveRepo(c, args[0], perm)
192	if code >= 0 {
193		return repo, store.MR{}, code
194	}
195	n, err := strconv.ParseInt(args[1], 10, 64)
196	if err != nil {
197		return repo, store.MR{}, c.fail(protocol.ExitUsage, "bad MR number %q", args[1])
198	}
199	mr, err := c.Store.MRByNumber(repo.ID, n)
200	if errors.Is(err, store.ErrNotFound) {
201		return repo, mr, c.fail(protocol.ExitNotFound, "MR !%d not found in %s", n, repo.Path())
202	}
203	if err != nil {
204		return repo, mr, c.fail(protocol.ExitFailure, "%v", err)
205	}
206	return repo, mr, -1
207}
208
209func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) }
210
211func runMRCreate(c *Ctx, args []string) int {
212	var path, source, target, title, body, file string
213	for i := 0; i < len(args); i++ {
214		switch args[i] {
215		case "--source", "--target", "--title", "--body", "--file":
216			if i+1 >= len(args) {
217				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
218			}
219			v := args[i+1]
220			switch args[i] {
221			case "--source":
222				source = v
223			case "--target":
224				target = v
225			case "--title":
226				title = v
227			case "--body":
228				body = v
229			case "--file":
230				file = v
231			}
232			i++
233		default:
234			if path != "" {
235				return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
236			}
237			path = args[i]
238		}
239	}
240	if path == "" || source == "" || title == "" {
241		return c.fail(protocol.ExitUsage, "usage: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t>")
242	}
243	repo, code := resolveRepo(c, path, policy.CanRead)
244	if code >= 0 {
245		return code
246	}
247	if code := refuseArchived(c, repo); code >= 0 {
248		return code
249	}
250	if target == "" {
251		target = repo.DefaultBranch
252	}
253
254	// Source is "branch" (same repo) or "owner/name:branch" (a fork).
255	srcRepo := repo
256	srcBranch := source
257	if sp, br, ok := strings.Cut(source, ":"); ok {
258		srcBranch = br
259		var scode int
260		srcRepo, scode = resolveRepo(c, sp, policy.CanRead)
261		if scode >= 0 {
262			return scode
263		}
264		if srcRepo.ForkOf != repo.ID && srcRepo.ID != repo.ID {
265			return c.fail(protocol.ExitUsage, "%s is not a fork of %s", srcRepo.Path(), repo.Path())
266		}
267	}
268	srcDir := RepoDir(c.Cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
269	headSHA, err := gitutil.ResolveRef(srcDir, "refs/heads/"+srcBranch)
270	if err != nil {
271		return c.fail(protocol.ExitNotFound, "branch %s not found in %s", srcBranch, srcRepo.Path())
272	}
273	b, err := bodyFrom(c, body, file)
274	if err != nil {
275		return c.fail(protocol.ExitUsage, "%v", err)
276	}
277	n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA)
278	if err != nil {
279		return c.fail(protocol.ExitFailure, "%v", err)
280	}
281	// Fetch the head into the target so the target owns the objects.
282	dstDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
283	if err := gitutil.FetchInto(dstDir, srcDir, headSHA, mrHeadRef(n)); err != nil {
284		return c.fail(protocol.ExitFailure, "recording MR head: %v", err)
285	}
286	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n))
287	if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
288		notifyUsers(c, targets, mrSubject(repo, n, title),
289			notifyBody(c, fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target), b, fmt.Sprintf("%s/mrs/%d", repo.Path(), n)))
290	}
291	return c.emit(map[string]any{"number": n, "head_sha": headSHA}, func(w io.Writer) {
292		fmt.Fprintf(w, "created %s!%d (%s -> %s)\n", repo.Path(), n, source, target)
293	})
294}
295
296type mrOut struct {
297	Number    int64  `json:"number"`
298	Title     string `json:"title"`
299	State     string `json:"state"`
300	Author    string `json:"author"`
301	Source    string `json:"source"` // owner/name:branch, or branch, "" if gone
302	TargetRef string `json:"target_ref"`
303	HeadSHA   string `json:"head_sha"`
304	Body      string `json:"body,omitempty"`
305	CreatedAt string `json:"created_at"`
306}
307
308func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
309	src := ""
310	if m.SourcePath != "" {
311		if m.SourceRepoID == repo.ID {
312			src = m.SourceRef
313		} else {
314			src = m.SourcePath + ":" + m.SourceRef
315		}
316	}
317	o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Author: m.Author,
318		Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, CreatedAt: m.CreatedAt}
319	if withBody {
320		o.Body = m.Body
321	}
322	return o
323}
324
325func runMRList(c *Ctx, args []string) int {
326	state := "open"
327	var path string
328	for i := 0; i < len(args); i++ {
329		switch args[i] {
330		case "--state":
331			if i+1 >= len(args) {
332				return c.fail(protocol.ExitUsage, "--state requires a value")
333			}
334			state = args[i+1]
335			i++
336		default:
337			if path != "" {
338				return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
339			}
340			path = args[i]
341		}
342	}
343	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
344	if path == "" || !valid[state] {
345		return c.fail(protocol.ExitUsage, "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all]")
346	}
347	repo, code := resolveRepo(c, path, policy.CanRead)
348	if code >= 0 {
349		return code
350	}
351	mrs, err := c.Store.ListMRs(repo.ID, state)
352	if err != nil {
353		return c.fail(protocol.ExitFailure, "%v", err)
354	}
355	var ds []mrOut
356	for _, m := range mrs {
357		ds = append(ds, mrToOut(repo, m, false))
358	}
359	return c.emit(ds, func(w io.Writer) {
360		for _, d := range ds {
361			fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s\n", d.Number, d.State, d.Title, d.Source, d.TargetRef)
362		}
363	})
364}
365
366func runMRShow(c *Ctx, args []string) int {
367	repo, mr, code := mrRef(c, args, policy.CanRead)
368	if code >= 0 {
369		return code
370	}
371	if len(args) != 2 {
372		return c.fail(protocol.ExitUsage, "usage: mr show <owner/name> <n>")
373	}
374	comments, err := c.Store.ListMRComments(mr.ID)
375	if err != nil {
376		return c.fail(protocol.ExitFailure, "%v", err)
377	}
378	reviews, err := c.Store.ListMRReviews(mr.ID)
379	if err != nil {
380		return c.fail(protocol.ExitFailure, "%v", err)
381	}
382	statuses, err := c.Store.ListCommitStatuses(repo.ID, mr.HeadSHA)
383	if err != nil {
384		return c.fail(protocol.ExitFailure, "%v", err)
385	}
386	unresolved, err := c.Store.UnresolvedThreadCount(mr.ID)
387	if err != nil {
388		return c.fail(protocol.ExitFailure, "%v", err)
389	}
390	type commentOut struct {
391		Author    string `json:"author"`
392		Body      string `json:"body"`
393		CreatedAt string `json:"created_at"`
394	}
395	type reviewOut struct {
396		Reviewer string `json:"reviewer"`
397		Verdict  string `json:"verdict"`
398		Stale    bool   `json:"stale"`
399	}
400	type checkOut struct {
401		Context string `json:"context"`
402		State   string `json:"state"`
403		URL     string `json:"url,omitempty"`
404	}
405	var checks []checkOut
406	for _, st := range statuses {
407		checks = append(checks, checkOut{st.Context, st.State, st.TargetURL})
408	}
409	var cs []commentOut
410	for _, cm := range comments {
411		cs = append(cs, commentOut{cm.Author, cm.Body, cm.CreatedAt})
412	}
413	var rs []reviewOut
414	for _, r := range reviews {
415		rs = append(rs, reviewOut{r.Reviewer, r.Verdict, r.Stale})
416	}
417	d := struct {
418		mrOut
419		Checks            []checkOut   `json:"checks,omitempty"`
420		Combined          string       `json:"checks_combined,omitempty"`
421		UnresolvedThreads int          `json:"unresolved_threads,omitempty"`
422		Comments          []commentOut `json:"comments,omitempty"`
423		Reviews           []reviewOut  `json:"reviews,omitempty"`
424	}{mrToOut(repo, mr, true), checks, store.CombinedStatus(statuses), unresolved, cs, rs}
425	return c.emit(d, func(w io.Writer) {
426		fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, d.State, d.Author, d.Source, d.TargetRef, d.HeadSHA)
427		if d.Body != "" {
428			fmt.Fprintf(w, "\n%s\n", d.Body)
429		}
430		for _, x := range checks {
431			fmt.Fprintf(w, "check: %s %s\n", x.Context, x.State)
432		}
433		if d.UnresolvedThreads > 0 {
434			fmt.Fprintf(w, "unresolved threads: %d\n", d.UnresolvedThreads)
435		}
436		for _, r := range rs {
437			stale := ""
438			if r.Stale {
439				stale = " (stale)"
440			}
441			fmt.Fprintf(w, "review: %s %s%s\n", r.Reviewer, r.Verdict, stale)
442		}
443		for _, cm := range cs {
444			fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
445		}
446	})
447}
448
449func runMRDiff(c *Ctx, args []string) int {
450	repo, mr, code := mrRef(c, args, policy.CanRead)
451	if code >= 0 {
452		return code
453	}
454	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
455	head := mrHeadRef(mr.Number)
456	// After a merge (especially fast-forward) the live merge-base equals
457	// the head and the diff would vanish; use the recorded base instead.
458	base := mr.MergedBase
459	if base == "" {
460		b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head)
461		if err != nil {
462			return c.fail(protocol.ExitFailure, "%v", err)
463		}
464		base = b
465	}
466	patch, err := gitutil.Diff(dir, base, head, 4<<20)
467	if err != nil {
468		return c.fail(protocol.ExitFailure, "%v", err)
469	}
470	fmt.Fprint(c.Stdout, patch)
471	return protocol.ExitOK
472}
473
474func runMREdit(c *Ctx, args []string) int {
475	rest, title, body, code := editText(c, args, "mr")
476	if code >= 0 {
477		return code
478	}
479	repo, mr, code := mrRef(c, rest, policy.CanRead)
480	if code >= 0 {
481		return code
482	}
483	if code := refuseArchived(c, repo); code >= 0 {
484		return code
485	}
486	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
487	if err != nil {
488		return c.fail(protocol.ExitFailure, "%v", err)
489	}
490	if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
491		return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this merge request")
492	}
493	if err := c.Store.UpdateMRText(mr.ID, title, body); err != nil {
494		return c.fail(protocol.ExitFailure, "%v", err)
495	}
496	return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
497		fmt.Fprintf(w, "edited %s!%d\n", repo.Path(), mr.Number)
498	})
499}
500
501func runMRComment(c *Ctx, args []string) int {
502	var rest []string
503	var message, file string
504	for i := 0; i < len(args); i++ {
505		switch args[i] {
506		case "--message", "--file":
507			if i+1 >= len(args) {
508				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
509			}
510			if args[i] == "--message" {
511				message = args[i+1]
512			} else {
513				file = args[i+1]
514			}
515			i++
516		default:
517			rest = append(rest, args[i])
518		}
519	}
520	repo, mr, code := mrRef(c, rest, policy.CanRead)
521	if code >= 0 {
522		return code
523	}
524	if code := refuseArchived(c, repo); code >= 0 {
525		return code
526	}
527	body, err := bodyFrom(c, message, file)
528	if err != nil {
529		return c.fail(protocol.ExitUsage, "%v", err)
530	}
531	if strings.TrimSpace(body) == "" {
532		return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
533	}
534	if err := c.Store.AddMRComment(mr.ID, c.User.ID, body); err != nil {
535		return c.fail(protocol.ExitFailure, "%v", err)
536	}
537	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
538		notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
539			notifyBody(c, fmt.Sprintf("commented on !%d", mr.Number), body, fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
540	}
541	return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
542		fmt.Fprintf(w, "commented on %s!%d\n", repo.Path(), mr.Number)
543	})
544}
545
546func runMRReview(c *Ctx, args []string) int {
547	verdict := ""
548	var rest []string
549	for _, a := range args {
550		switch a {
551		case "--approve":
552			verdict = "approve"
553		case "--request-changes":
554			verdict = "request_changes"
555		case "--comment":
556			verdict = "comment"
557		default:
558			rest = append(rest, a)
559		}
560	}
561	if verdict == "" {
562		return c.fail(protocol.ExitUsage, "usage: mr review <owner/name> <n> --approve|--request-changes|--comment")
563	}
564	repo, mr, code := mrRef(c, rest, policy.CanRead)
565	if code >= 0 {
566		return code
567	}
568	if code := refuseArchived(c, repo); code >= 0 {
569		return code
570	}
571	if mr.State != "open" {
572		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
573	}
574	if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil {
575		return c.fail(protocol.ExitFailure, "%v", err)
576	}
577	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
578		notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
579			notifyBody(c, fmt.Sprintf("reviewed !%d: %s", mr.Number, verdict), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
580	}
581	return c.emit(map[string]any{"number": mr.Number, "verdict": verdict}, func(w io.Writer) {
582		fmt.Fprintf(w, "reviewed %s!%d: %s\n", repo.Path(), mr.Number, verdict)
583	})
584}
585
586func runMRMerge(c *Ctx, args []string) int {
587	strategy := ""
588	var rest []string
589	for i := 0; i < len(args); i++ {
590		if args[i] == "--strategy" {
591			if i+1 >= len(args) {
592				return c.fail(protocol.ExitUsage, "--strategy requires ff|merge|squash|rebase")
593			}
594			strategy = args[i+1]
595			i++
596			continue
597		}
598		rest = append(rest, args[i])
599	}
600	valid := map[string]bool{"": true, "ff": true, "merge": true, "squash": true, "rebase": true}
601	if !valid[strategy] {
602		return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase")
603	}
604	repo, mr, code := mrRef(c, rest, policy.CanWrite)
605	if code >= 0 {
606		return code
607	}
608	if code := refuseArchived(c, repo); code >= 0 {
609		return code
610	}
611	if mr.State != "open" && mr.State != "source_gone" {
612		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
613	}
614
615	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
616	targetRef := "refs/heads/" + mr.TargetRef
617	targetSHA, err := gitutil.ResolveRef(dir, targetRef)
618	if err != nil {
619		return c.fail(protocol.ExitFailure, "target branch %s: %v", mr.TargetRef, err)
620	}
621	headSHA, err := gitutil.ResolveRef(dir, mrHeadRef(mr.Number))
622	if err != nil {
623		return c.fail(protocol.ExitFailure, "MR head ref: %v", err)
624	}
625
626	// Check gate: with require_checks, the MR head must carry statuses
627	// and every one of them must be green.
628	if repo.Settings.RequireChecks {
629		statuses, err := c.Store.ListCommitStatuses(repo.ID, headSHA)
630		if err != nil {
631			return c.fail(protocol.ExitFailure, "%v", err)
632		}
633		switch store.CombinedStatus(statuses) {
634		case "success":
635		case "":
636			return c.fail(protocol.ExitDenied,
637				"%s requires green checks and none were reported on %.10s", repo.Path(), headSHA)
638		default:
639			var bad []string
640			for _, st := range statuses {
641				if st.State != "success" {
642					bad = append(bad, st.Context+"="+st.State)
643				}
644			}
645			return c.fail(protocol.ExitDenied,
646				"%s requires green checks; %.10s has %s", repo.Path(), headSHA, strings.Join(bad, ", "))
647		}
648	}
649
650	// Review gates: approvals, CODEOWNERS, resolved threads.
651	if code := c.reviewGates(repo, mr, dir, targetSHA, headSHA); code >= 0 {
652		return code
653	}
654
655	upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA)
656	if err != nil {
657		return c.fail(protocol.ExitFailure, "%v", err)
658	}
659	if upToDate {
660		return c.fail(protocol.ExitUsage, "target already contains the MR head")
661	}
662	ffPossible, err := gitutil.IsAncestor(dir, targetSHA, headSHA)
663	if err != nil {
664		return c.fail(protocol.ExitFailure, "%v", err)
665	}
666
667	// Signature policy matrix: with require_signed_commits, only
668	// fast-forward is allowed — squash, rebase-replay, and merge commits
669	// are all server-created and unsigned, violating the branch's own
670	// policy — and every landed commit must be verified. An explicit
671	// rebase when fast-forward is already possible IS a fast-forward
672	// (nothing is rewritten), so it stays legal.
673	if repo.Settings.RequireSignedCommits {
674		if strategy == "merge" || strategy == "squash" || !ffPossible {
675			return c.fail(protocol.ExitDenied,
676				"%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again",
677				repo.Path(), mr.SourceRef, mr.TargetRef)
678		}
679		strategy = "ff"
680		commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
681		if err != nil {
682			return c.fail(protocol.ExitFailure, "%v", err)
683		}
684		for _, sha := range commits {
685			raw, err := gitutil.ReadCommit(dir, sha)
686			if err != nil {
687				return c.fail(protocol.ExitFailure, "%v", err)
688			}
689			parsed, err := sigParse(raw)
690			if err != nil {
691				return c.fail(protocol.ExitFailure, "%v", err)
692			}
693			res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
694			if err != nil {
695				return c.fail(protocol.ExitFailure, "%v", err)
696			}
697			if res.State != "verified" {
698				return c.fail(protocol.ExitDenied,
699					"%s requires signed commits: %.10s is %s", repo.Path(), sha, res.State)
700			}
701		}
702	}
703	if strategy == "" {
704		if ffPossible {
705			strategy = "ff"
706		} else {
707			strategy = "merge"
708		}
709	}
710	if strategy == "rebase" && ffPossible {
711		// Nothing to rewrite: a rebase onto an ancestor is a fast-forward,
712		// and taking it keeps the original commits and their signatures.
713		strategy = "ff"
714	}
715
716	// Every server-created commit needs the merger's verified identity.
717	mergerEmail := ""
718	if strategy != "ff" {
719		email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
720		if err != nil {
721			return c.fail(protocol.ExitFailure, "%v", err)
722		}
723		if email == "" {
724			return c.fail(protocol.ExitDenied,
725				"%s merges create commits carrying your identity: verify a primary email first (or use a fast-forward merge)", strategy)
726		}
727		mergerEmail = email
728	}
729
730	var newSHA string
731	switch strategy {
732	case "ff":
733		if !ffPossible {
734			return c.fail(protocol.ExitUsage,
735				"fast-forward not possible: %s has diverged from the MR head; use --strategy merge or rebase and re-push", mr.TargetRef)
736		}
737		newSHA = headSHA
738
739	case "merge":
740		tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
741		if err != nil {
742			return c.fail(protocol.ExitFailure, "%v", err)
743		}
744		if conflict {
745			return c.fail(protocol.ExitUsage,
746				"merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
747		}
748		msg := fmt.Sprintf("Merge request !%d: %s\n\nMerged %s into %s", mr.Number, mr.Title, mr.SourceRef, mr.TargetRef)
749		newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, mergerEmail, msg)
750		if err != nil {
751			return c.fail(protocol.ExitFailure, "%v", err)
752		}
753
754	case "squash":
755		// One new commit with the merged tree. Authorship credit goes to
756		// the MR author (their verified identity when they have one); the
757		// committer is the merger.
758		tree := ""
759		if ffPossible {
760			t, err := gitutil.ResolveTree(dir, headSHA)
761			if err != nil {
762				return c.fail(protocol.ExitFailure, "%v", err)
763			}
764			tree = t
765		} else {
766			t, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
767			if err != nil {
768				return c.fail(protocol.ExitFailure, "%v", err)
769			}
770			if conflict {
771				return c.fail(protocol.ExitUsage,
772					"merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
773			}
774			tree = t
775		}
776		authorName, authorEmail := c.User.Username, mergerEmail
777		if author, err := c.Store.UserByUsername(mr.Author); err == nil {
778			if ae, err := c.Store.PrimaryVerifiedEmail(author.ID); err == nil && ae != "" {
779				authorName, authorEmail = author.Username, ae
780			}
781		}
782		msg := fmt.Sprintf("%s (!%d)", mr.Title, mr.Number)
783		if mr.Body != "" {
784			msg += "\n\n" + mr.Body
785		}
786		var err error
787		newSHA, err = gitutil.CommitTreeIdent(dir, tree, []string{targetSHA},
788			authorName, authorEmail, "", c.User.Username, mergerEmail, msg)
789		if err != nil {
790			return c.fail(protocol.ExitFailure, "%v", err)
791		}
792
793	case "rebase":
794		commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
795		if err != nil {
796			return c.fail(protocol.ExitFailure, "%v", err)
797		}
798		// Oldest first.
799		for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 {
800			commits[i], commits[j] = commits[j], commits[i]
801		}
802		onto := targetSHA
803		for _, sha := range commits {
804			parents, err := gitutil.CommitParents(dir, sha)
805			if err != nil {
806				return c.fail(protocol.ExitFailure, "%v", err)
807			}
808			if len(parents) > 1 {
809				return c.fail(protocol.ExitUsage,
810					"the MR contains merge commit %.10s; a rebase merge needs linear history — use --strategy merge or squash", sha)
811			}
812			base := onto // root commit: replay against the new tip itself
813			if len(parents) == 1 {
814				base = parents[0]
815			}
816			tree, conflict, err := gitutil.MergeTreeOnto(dir, base, onto, sha)
817			if err != nil {
818				return c.fail(protocol.ExitFailure, "%v", err)
819			}
820			if conflict {
821				return c.fail(protocol.ExitUsage,
822					"commit %.10s does not apply cleanly onto %s; rebase locally and re-push", sha, mr.TargetRef)
823			}
824			aName, aEmail, aDate, err := gitutil.AuthorIdent(dir, sha)
825			if err != nil {
826				return c.fail(protocol.ExitFailure, "%v", err)
827			}
828			msg, err := gitutil.CommitMessage(dir, sha)
829			if err != nil {
830				return c.fail(protocol.ExitFailure, "%v", err)
831			}
832			onto, err = gitutil.CommitTreeIdent(dir, tree, []string{onto},
833				aName, aEmail, aDate, c.User.Username, mergerEmail, msg)
834			if err != nil {
835				return c.fail(protocol.ExitFailure, "%v", err)
836			}
837		}
838		newSHA = onto
839	}
840
841	// CAS so a concurrent push between our read and this write fails the
842	// merge instead of silently discarding the push.
843	if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
844		return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err)
845	}
846	if err := c.Store.MarkMerged(mr.ID, targetSHA); err != nil {
847		return c.fail(protocol.ExitFailure, "%v", err)
848	}
849	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
850	// Merges bypass receive-pack, so the commit-message issue actions
851	// (closes #N, references) run here for the newly landed commits.
852	if mr.TargetRef == repo.DefaultBranch {
853		ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA)
854	}
855	c.Store.MarkMirrorsDirty(repo.ID, "push")
856	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
857		notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
858			notifyBody(c, fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
859	}
860	return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) {
861		fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA)
862	})
863}
864
865// reviewGates enforces require_approvals (fresh, non-author, latest review
866// per reviewer; a fresh request-changes blocks), CODEOWNERS coverage, and
867// require_resolved. Returns -1 to proceed.
868func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int {
869	set := repo.Settings
870	if set.RequireApprovals == 0 && !set.RequireResolved {
871		return -1
872	}
873
874	if set.RequireApprovals > 0 {
875		reviews, err := c.Store.ListMRReviews(mr.ID)
876		if err != nil {
877			return c.fail(protocol.ExitFailure, "%v", err)
878		}
879		// Latest fresh review per reviewer decides their stance.
880		latest := map[string]string{}
881		for _, r := range reviews {
882			if r.Stale || r.Reviewer == mr.Author {
883				continue
884			}
885			latest[r.Reviewer] = r.Verdict
886		}
887		var approvers []string
888		var blockers []string
889		for who, verdict := range latest {
890			switch verdict {
891			case "approve":
892				approvers = append(approvers, who)
893			case "request_changes":
894				blockers = append(blockers, who)
895			}
896		}
897		if len(blockers) > 0 {
898			slices.Sort(blockers)
899			return c.fail(protocol.ExitDenied,
900				"%s requested changes on !%d; resolve their review before merging", strings.Join(blockers, ", "), mr.Number)
901		}
902		if len(approvers) < set.RequireApprovals {
903			return c.fail(protocol.ExitDenied,
904				"%s requires %d fresh approval(s); !%d has %d", repo.Path(), set.RequireApprovals, mr.Number, len(approvers))
905		}
906
907		// CODEOWNERS: every owned changed file needs an approval from one
908		// of its owners.
909		content, err := gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, "CODEOWNERS", 1<<20)
910		if err != nil {
911			content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20)
912		}
913		if err == nil && len(content) > 0 {
914			rules := policy.ParseCodeowners(string(content))
915			base, err := gitutil.MergeBase(dir, targetSHA, headSHA)
916			if err != nil {
917				return c.fail(protocol.ExitFailure, "%v", err)
918			}
919			files, err := gitutil.DiffFiles(dir, base, headSHA)
920			if err != nil {
921				return c.fail(protocol.ExitFailure, "%v", err)
922			}
923			approved := map[string]bool{}
924			for _, a := range approvers {
925				approved[a] = true
926			}
927			missing := map[string][]string{} // owner-set key -> example paths
928			for _, f := range files {
929				owners := policy.OwnersFor(rules, f)
930				if owners == nil {
931					continue
932				}
933				ok := false
934				for _, o := range owners {
935					if approved[o] {
936						ok = true
937						break
938					}
939				}
940				if !ok {
941					key := strings.Join(owners, ",")
942					if len(missing[key]) < 3 {
943						missing[key] = append(missing[key], f)
944					}
945				}
946			}
947			if len(missing) > 0 {
948				var parts []string
949				for owners, paths := range missing {
950					parts = append(parts, fmt.Sprintf("%s (owned by %s)", strings.Join(paths, ", "), owners))
951				}
952				slices.Sort(parts)
953				return c.fail(protocol.ExitDenied,
954					"CODEOWNERS approval missing for: %s", strings.Join(parts, "; "))
955			}
956		}
957	}
958
959	if set.RequireResolved {
960		n, err := c.Store.UnresolvedThreadCount(mr.ID)
961		if err != nil {
962			return c.fail(protocol.ExitFailure, "%v", err)
963		}
964		if n > 0 {
965			return c.fail(protocol.ExitDenied,
966				"%s requires review threads resolved; !%d has %d open (mr threads %s %d)", repo.Path(), mr.Number, n, repo.Path(), mr.Number)
967		}
968	}
969	return -1
970}
971
972func runMRClose(c *Ctx, args []string) int {
973	repo, mr, code := mrRef(c, args, policy.CanRead)
974	if code >= 0 {
975		return code
976	}
977	if code := refuseArchived(c, repo); code >= 0 {
978		return code
979	}
980	if len(args) != 2 {
981		return c.fail(protocol.ExitUsage, "usage: mr close <owner/name> <n>")
982	}
983	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
984	if err != nil {
985		return c.fail(protocol.ExitFailure, "%v", err)
986	}
987	if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
988		return c.fail(protocol.ExitDenied, "only the author or users with write access can close this MR")
989	}
990	if mr.State == "merged" || mr.State == "closed" {
991		return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State)
992	}
993	if err := c.Store.SetMRState(mr.ID, "closed"); err != nil {
994		return c.fail(protocol.ExitFailure, "%v", err)
995	}
996	return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) {
997		fmt.Fprintf(w, "closed %s!%d\n", repo.Path(), mr.Number)
998	})
999}