internal/control/mr.go

04832701858d14bea6b62e4bc963f1d1897ca629
gitbay/internal/control/mr.go history · blame · raw

1001 lines · 33540 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	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, mr.Number))
 538	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
 539		notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
 540			notifyBody(c, fmt.Sprintf("commented on !%d", mr.Number), body, fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
 541	}
 542	return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
 543		fmt.Fprintf(w, "commented on %s!%d\n", repo.Path(), mr.Number)
 544	})
 545}
 546
 547func runMRReview(c *Ctx, args []string) int {
 548	verdict := ""
 549	var rest []string
 550	for _, a := range args {
 551		switch a {
 552		case "--approve":
 553			verdict = "approve"
 554		case "--request-changes":
 555			verdict = "request_changes"
 556		case "--comment":
 557			verdict = "comment"
 558		default:
 559			rest = append(rest, a)
 560		}
 561	}
 562	if verdict == "" {
 563		return c.fail(protocol.ExitUsage, "usage: mr review <owner/name> <n> --approve|--request-changes|--comment")
 564	}
 565	repo, mr, code := mrRef(c, rest, policy.CanRead)
 566	if code >= 0 {
 567		return code
 568	}
 569	if code := refuseArchived(c, repo); code >= 0 {
 570		return code
 571	}
 572	if mr.State != "open" {
 573		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
 574	}
 575	if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil {
 576		return c.fail(protocol.ExitFailure, "%v", err)
 577	}
 578	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
 579		notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
 580			notifyBody(c, fmt.Sprintf("reviewed !%d: %s", mr.Number, verdict), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
 581	}
 582	return c.emit(map[string]any{"number": mr.Number, "verdict": verdict}, func(w io.Writer) {
 583		fmt.Fprintf(w, "reviewed %s!%d: %s\n", repo.Path(), mr.Number, verdict)
 584	})
 585}
 586
 587func runMRMerge(c *Ctx, args []string) int {
 588	strategy := ""
 589	var rest []string
 590	for i := 0; i < len(args); i++ {
 591		if args[i] == "--strategy" {
 592			if i+1 >= len(args) {
 593				return c.fail(protocol.ExitUsage, "--strategy requires ff|merge|squash|rebase")
 594			}
 595			strategy = args[i+1]
 596			i++
 597			continue
 598		}
 599		rest = append(rest, args[i])
 600	}
 601	valid := map[string]bool{"": true, "ff": true, "merge": true, "squash": true, "rebase": true}
 602	if !valid[strategy] {
 603		return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase")
 604	}
 605	repo, mr, code := mrRef(c, rest, policy.CanWrite)
 606	if code >= 0 {
 607		return code
 608	}
 609	if code := refuseArchived(c, repo); code >= 0 {
 610		return code
 611	}
 612	if mr.State != "open" && mr.State != "source_gone" {
 613		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
 614	}
 615
 616	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 617	targetRef := "refs/heads/" + mr.TargetRef
 618	targetSHA, err := gitutil.ResolveRef(dir, targetRef)
 619	if err != nil {
 620		return c.fail(protocol.ExitFailure, "target branch %s: %v", mr.TargetRef, err)
 621	}
 622	headSHA, err := gitutil.ResolveRef(dir, mrHeadRef(mr.Number))
 623	if err != nil {
 624		return c.fail(protocol.ExitFailure, "MR head ref: %v", err)
 625	}
 626
 627	// Check gate: with require_checks, the MR head must carry statuses
 628	// and every one of them must be green.
 629	if repo.Settings.RequireChecks {
 630		statuses, err := c.Store.ListCommitStatuses(repo.ID, headSHA)
 631		if err != nil {
 632			return c.fail(protocol.ExitFailure, "%v", err)
 633		}
 634		switch store.CombinedStatus(statuses) {
 635		case "success":
 636		case "":
 637			return c.fail(protocol.ExitDenied,
 638				"%s requires green checks and none were reported on %.10s", repo.Path(), headSHA)
 639		default:
 640			var bad []string
 641			for _, st := range statuses {
 642				if st.State != "success" {
 643					bad = append(bad, st.Context+"="+st.State)
 644				}
 645			}
 646			return c.fail(protocol.ExitDenied,
 647				"%s requires green checks; %.10s has %s", repo.Path(), headSHA, strings.Join(bad, ", "))
 648		}
 649	}
 650
 651	// Review gates: approvals, CODEOWNERS, resolved threads.
 652	if code := c.reviewGates(repo, mr, dir, targetSHA, headSHA); code >= 0 {
 653		return code
 654	}
 655
 656	upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA)
 657	if err != nil {
 658		return c.fail(protocol.ExitFailure, "%v", err)
 659	}
 660	if upToDate {
 661		return c.fail(protocol.ExitUsage, "target already contains the MR head")
 662	}
 663	ffPossible, err := gitutil.IsAncestor(dir, targetSHA, headSHA)
 664	if err != nil {
 665		return c.fail(protocol.ExitFailure, "%v", err)
 666	}
 667
 668	// Signature policy matrix: with require_signed_commits, only
 669	// fast-forward is allowed — squash, rebase-replay, and merge commits
 670	// are all server-created and unsigned, violating the branch's own
 671	// policy — and every landed commit must be verified. An explicit
 672	// rebase when fast-forward is already possible IS a fast-forward
 673	// (nothing is rewritten), so it stays legal.
 674	if repo.Settings.RequireSignedCommits {
 675		if strategy == "merge" || strategy == "squash" || !ffPossible {
 676			return c.fail(protocol.ExitDenied,
 677				"%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again",
 678				repo.Path(), mr.SourceRef, mr.TargetRef)
 679		}
 680		strategy = "ff"
 681		commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
 682		if err != nil {
 683			return c.fail(protocol.ExitFailure, "%v", err)
 684		}
 685		for _, sha := range commits {
 686			raw, err := gitutil.ReadCommit(dir, sha)
 687			if err != nil {
 688				return c.fail(protocol.ExitFailure, "%v", err)
 689			}
 690			parsed, err := sigParse(raw)
 691			if err != nil {
 692				return c.fail(protocol.ExitFailure, "%v", err)
 693			}
 694			res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
 695			if err != nil {
 696				return c.fail(protocol.ExitFailure, "%v", err)
 697			}
 698			if res.State != "verified" {
 699				return c.fail(protocol.ExitDenied,
 700					"%s requires signed commits: %.10s is %s", repo.Path(), sha, res.State)
 701			}
 702		}
 703	}
 704	if strategy == "" {
 705		if ffPossible {
 706			strategy = "ff"
 707		} else {
 708			strategy = "merge"
 709		}
 710	}
 711	if strategy == "rebase" && ffPossible {
 712		// Nothing to rewrite: a rebase onto an ancestor is a fast-forward,
 713		// and taking it keeps the original commits and their signatures.
 714		strategy = "ff"
 715	}
 716
 717	// Every server-created commit needs the merger's verified identity.
 718	mergerEmail := ""
 719	if strategy != "ff" {
 720		email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
 721		if err != nil {
 722			return c.fail(protocol.ExitFailure, "%v", err)
 723		}
 724		if email == "" {
 725			return c.fail(protocol.ExitDenied,
 726				"%s merges create commits carrying your identity: verify a primary email first (or use a fast-forward merge)", strategy)
 727		}
 728		mergerEmail = email
 729	}
 730
 731	var newSHA string
 732	switch strategy {
 733	case "ff":
 734		if !ffPossible {
 735			return c.fail(protocol.ExitUsage,
 736				"fast-forward not possible: %s has diverged from the MR head; use --strategy merge or rebase and re-push", mr.TargetRef)
 737		}
 738		newSHA = headSHA
 739
 740	case "merge":
 741		tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
 742		if err != nil {
 743			return c.fail(protocol.ExitFailure, "%v", err)
 744		}
 745		if conflict {
 746			return c.fail(protocol.ExitUsage,
 747				"merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
 748		}
 749		msg := fmt.Sprintf("Merge request !%d: %s\n\nMerged %s into %s", mr.Number, mr.Title, mr.SourceRef, mr.TargetRef)
 750		newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, mergerEmail, msg)
 751		if err != nil {
 752			return c.fail(protocol.ExitFailure, "%v", err)
 753		}
 754
 755	case "squash":
 756		// One new commit with the merged tree. Authorship credit goes to
 757		// the MR author (their verified identity when they have one); the
 758		// committer is the merger.
 759		tree := ""
 760		if ffPossible {
 761			t, err := gitutil.ResolveTree(dir, headSHA)
 762			if err != nil {
 763				return c.fail(protocol.ExitFailure, "%v", err)
 764			}
 765			tree = t
 766		} else {
 767			t, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
 768			if err != nil {
 769				return c.fail(protocol.ExitFailure, "%v", err)
 770			}
 771			if conflict {
 772				return c.fail(protocol.ExitUsage,
 773					"merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
 774			}
 775			tree = t
 776		}
 777		authorName, authorEmail := c.User.Username, mergerEmail
 778		if author, err := c.Store.UserByUsername(mr.Author); err == nil {
 779			if ae, err := c.Store.PrimaryVerifiedEmail(author.ID); err == nil && ae != "" {
 780				authorName, authorEmail = author.Username, ae
 781			}
 782		}
 783		msg := fmt.Sprintf("%s (!%d)", mr.Title, mr.Number)
 784		if mr.Body != "" {
 785			msg += "\n\n" + mr.Body
 786		}
 787		var err error
 788		newSHA, err = gitutil.CommitTreeIdent(dir, tree, []string{targetSHA},
 789			authorName, authorEmail, "", c.User.Username, mergerEmail, msg)
 790		if err != nil {
 791			return c.fail(protocol.ExitFailure, "%v", err)
 792		}
 793
 794	case "rebase":
 795		commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
 796		if err != nil {
 797			return c.fail(protocol.ExitFailure, "%v", err)
 798		}
 799		// Oldest first.
 800		for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 {
 801			commits[i], commits[j] = commits[j], commits[i]
 802		}
 803		onto := targetSHA
 804		for _, sha := range commits {
 805			parents, err := gitutil.CommitParents(dir, sha)
 806			if err != nil {
 807				return c.fail(protocol.ExitFailure, "%v", err)
 808			}
 809			if len(parents) > 1 {
 810				return c.fail(protocol.ExitUsage,
 811					"the MR contains merge commit %.10s; a rebase merge needs linear history — use --strategy merge or squash", sha)
 812			}
 813			base := onto // root commit: replay against the new tip itself
 814			if len(parents) == 1 {
 815				base = parents[0]
 816			}
 817			tree, conflict, err := gitutil.MergeTreeOnto(dir, base, onto, sha)
 818			if err != nil {
 819				return c.fail(protocol.ExitFailure, "%v", err)
 820			}
 821			if conflict {
 822				return c.fail(protocol.ExitUsage,
 823					"commit %.10s does not apply cleanly onto %s; rebase locally and re-push", sha, mr.TargetRef)
 824			}
 825			aName, aEmail, aDate, err := gitutil.AuthorIdent(dir, sha)
 826			if err != nil {
 827				return c.fail(protocol.ExitFailure, "%v", err)
 828			}
 829			msg, err := gitutil.CommitMessage(dir, sha)
 830			if err != nil {
 831				return c.fail(protocol.ExitFailure, "%v", err)
 832			}
 833			onto, err = gitutil.CommitTreeIdent(dir, tree, []string{onto},
 834				aName, aEmail, aDate, c.User.Username, mergerEmail, msg)
 835			if err != nil {
 836				return c.fail(protocol.ExitFailure, "%v", err)
 837			}
 838		}
 839		newSHA = onto
 840	}
 841
 842	// CAS so a concurrent push between our read and this write fails the
 843	// merge instead of silently discarding the push.
 844	if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
 845		return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err)
 846	}
 847	if err := c.Store.MarkMerged(mr.ID, targetSHA); err != nil {
 848		return c.fail(protocol.ExitFailure, "%v", err)
 849	}
 850	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
 851	// Merges bypass receive-pack, so the commit-message issue actions
 852	// (closes #N, references) run here for the newly landed commits.
 853	if mr.TargetRef == repo.DefaultBranch {
 854		ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA)
 855		RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA)
 856	}
 857	c.Store.MarkMirrorsDirty(repo.ID, "push")
 858	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
 859		notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
 860			notifyBody(c, fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
 861	}
 862	return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) {
 863		fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA)
 864	})
 865}
 866
 867// reviewGates enforces require_approvals (fresh, non-author, latest review
 868// per reviewer; a fresh request-changes blocks), CODEOWNERS coverage, and
 869// require_resolved. Returns -1 to proceed.
 870func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int {
 871	set := repo.Settings
 872	if set.RequireApprovals == 0 && !set.RequireResolved {
 873		return -1
 874	}
 875
 876	if set.RequireApprovals > 0 {
 877		reviews, err := c.Store.ListMRReviews(mr.ID)
 878		if err != nil {
 879			return c.fail(protocol.ExitFailure, "%v", err)
 880		}
 881		// Latest fresh review per reviewer decides their stance.
 882		latest := map[string]string{}
 883		for _, r := range reviews {
 884			if r.Stale || r.Reviewer == mr.Author {
 885				continue
 886			}
 887			latest[r.Reviewer] = r.Verdict
 888		}
 889		var approvers []string
 890		var blockers []string
 891		for who, verdict := range latest {
 892			switch verdict {
 893			case "approve":
 894				approvers = append(approvers, who)
 895			case "request_changes":
 896				blockers = append(blockers, who)
 897			}
 898		}
 899		if len(blockers) > 0 {
 900			slices.Sort(blockers)
 901			return c.fail(protocol.ExitDenied,
 902				"%s requested changes on !%d; resolve their review before merging", strings.Join(blockers, ", "), mr.Number)
 903		}
 904		if len(approvers) < set.RequireApprovals {
 905			return c.fail(protocol.ExitDenied,
 906				"%s requires %d fresh approval(s); !%d has %d", repo.Path(), set.RequireApprovals, mr.Number, len(approvers))
 907		}
 908
 909		// CODEOWNERS: every owned changed file needs an approval from one
 910		// of its owners.
 911		content, err := gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, "CODEOWNERS", 1<<20)
 912		if err != nil {
 913			content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20)
 914		}
 915		if err == nil && len(content) > 0 {
 916			rules := policy.ParseCodeowners(string(content))
 917			base, err := gitutil.MergeBase(dir, targetSHA, headSHA)
 918			if err != nil {
 919				return c.fail(protocol.ExitFailure, "%v", err)
 920			}
 921			files, err := gitutil.DiffFiles(dir, base, headSHA)
 922			if err != nil {
 923				return c.fail(protocol.ExitFailure, "%v", err)
 924			}
 925			approved := map[string]bool{}
 926			for _, a := range approvers {
 927				approved[a] = true
 928			}
 929			missing := map[string][]string{} // owner-set key -> example paths
 930			for _, f := range files {
 931				owners := policy.OwnersFor(rules, f)
 932				if owners == nil {
 933					continue
 934				}
 935				ok := false
 936				for _, o := range owners {
 937					if approved[o] {
 938						ok = true
 939						break
 940					}
 941				}
 942				if !ok {
 943					key := strings.Join(owners, ",")
 944					if len(missing[key]) < 3 {
 945						missing[key] = append(missing[key], f)
 946					}
 947				}
 948			}
 949			if len(missing) > 0 {
 950				var parts []string
 951				for owners, paths := range missing {
 952					parts = append(parts, fmt.Sprintf("%s (owned by %s)", strings.Join(paths, ", "), owners))
 953				}
 954				slices.Sort(parts)
 955				return c.fail(protocol.ExitDenied,
 956					"CODEOWNERS approval missing for: %s", strings.Join(parts, "; "))
 957			}
 958		}
 959	}
 960
 961	if set.RequireResolved {
 962		n, err := c.Store.UnresolvedThreadCount(mr.ID)
 963		if err != nil {
 964			return c.fail(protocol.ExitFailure, "%v", err)
 965		}
 966		if n > 0 {
 967			return c.fail(protocol.ExitDenied,
 968				"%s requires review threads resolved; !%d has %d open (mr threads %s %d)", repo.Path(), mr.Number, n, repo.Path(), mr.Number)
 969		}
 970	}
 971	return -1
 972}
 973
 974func runMRClose(c *Ctx, args []string) int {
 975	repo, mr, code := mrRef(c, args, policy.CanRead)
 976	if code >= 0 {
 977		return code
 978	}
 979	if code := refuseArchived(c, repo); code >= 0 {
 980		return code
 981	}
 982	if len(args) != 2 {
 983		return c.fail(protocol.ExitUsage, "usage: mr close <owner/name> <n>")
 984	}
 985	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
 986	if err != nil {
 987		return c.fail(protocol.ExitFailure, "%v", err)
 988	}
 989	if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
 990		return c.fail(protocol.ExitDenied, "only the author or users with write access can close this MR")
 991	}
 992	if mr.State == "merged" || mr.State == "closed" {
 993		return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State)
 994	}
 995	if err := c.Store.SetMRState(mr.ID, "closed"); err != nil {
 996		return c.fail(protocol.ExitFailure, "%v", err)
 997	}
 998	return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) {
 999		fmt.Fprintf(w, "closed %s!%d\n", repo.Path(), mr.Number)
1000	})
1001}